@amaster.ai/components-templates 1.6.0 → 1.10.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/components/ai-assistant/package.json +10 -12
- package/components/ai-assistant/template/ai-assistant.tsx +48 -7
- package/components/ai-assistant/template/components/chat-assistant-message.tsx +78 -7
- package/components/ai-assistant/template/components/chat-display-mode-switcher.tsx +35 -11
- package/components/ai-assistant/template/components/chat-floating-button.tsx +2 -1
- package/components/ai-assistant/template/components/chat-floating-card.tsx +49 -3
- package/components/ai-assistant/template/components/chat-header.tsx +1 -1
- package/components/ai-assistant/template/components/chat-input.tsx +57 -22
- package/components/ai-assistant/template/components/chat-messages.tsx +118 -25
- package/components/ai-assistant/template/components/chat-recommends.tsx +79 -15
- package/components/ai-assistant/template/components/voice-input.tsx +11 -2
- package/components/ai-assistant/template/hooks/useAssistantSize.ts +360 -0
- package/components/ai-assistant/template/hooks/useConversation.ts +0 -23
- package/components/ai-assistant/template/hooks/useDisplayMode.tsx +52 -5
- package/components/ai-assistant/template/hooks/useDraggable.ts +11 -3
- package/components/ai-assistant/template/hooks/usePosition.ts +19 -31
- package/components/ai-assistant/template/i18n.ts +8 -0
- package/components/ai-assistant/template/types.ts +2 -0
- package/components/ai-assistant-taro/package.json +16 -8
- package/components/ai-assistant-taro/template/components/ChatAssistantMessage.tsx +24 -2
- package/components/ai-assistant-taro/template/components/ChatInput.tsx +50 -28
- package/components/ai-assistant-taro/template/components/RecommendedQuestions.tsx +39 -0
- package/components/ai-assistant-taro/template/components/markdown.tsx +343 -137
- package/components/ai-assistant-taro/template/hooks/useConversation.ts +542 -424
- package/components/ai-assistant-taro/template/index.tsx +2 -2
- package/components/ai-assistant-taro/template/types.ts +16 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import { useEffect, useRef, useCallback } from "react";
|
|
3
|
-
import type { Conversation } from "../types";
|
|
4
|
-
import ChatAssistantMessage, {
|
|
2
|
+
import { useEffect, useRef, useCallback, useMemo } from "react";
|
|
3
|
+
import type { Conversation, MessagesItem } from "../types";
|
|
4
|
+
import ChatAssistantMessage, {
|
|
5
|
+
ChatAssistantCollapsedGroup,
|
|
6
|
+
ChatDivider,
|
|
7
|
+
isCollapsibleAssistantMessage,
|
|
8
|
+
} from "./chat-assistant-message";
|
|
5
9
|
import ChatUserMessage from "./chat-user-message";
|
|
6
10
|
import { cn } from "@/lib/utils";
|
|
7
11
|
import { LoaderCircle } from "lucide-react";
|
|
@@ -18,6 +22,77 @@ interface ChatMessagesProps {
|
|
|
18
22
|
onLoadMore?: () => void;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
type RenderableMessageItem =
|
|
26
|
+
| {
|
|
27
|
+
type: "message";
|
|
28
|
+
message: MessagesItem;
|
|
29
|
+
key: string;
|
|
30
|
+
isNewest: boolean;
|
|
31
|
+
showAvatar: boolean;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
type: "group";
|
|
35
|
+
messages: MessagesItem[];
|
|
36
|
+
key: string;
|
|
37
|
+
isNewest: boolean;
|
|
38
|
+
showAvatar: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface RenderableConversation {
|
|
42
|
+
taskId: string;
|
|
43
|
+
addDivider: boolean | undefined;
|
|
44
|
+
items: RenderableMessageItem[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const buildRenderableMessages = (messages: MessagesItem[]): RenderableMessageItem[] => {
|
|
48
|
+
const items: RenderableMessageItem[] = [];
|
|
49
|
+
|
|
50
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
51
|
+
const message = messages[index];
|
|
52
|
+
const key = message.messageId || `message-${index}`;
|
|
53
|
+
|
|
54
|
+
if (!isCollapsibleAssistantMessage(message)) {
|
|
55
|
+
items.push({
|
|
56
|
+
type: "message",
|
|
57
|
+
message,
|
|
58
|
+
key,
|
|
59
|
+
isNewest: index === messages.length - 1,
|
|
60
|
+
showAvatar: index === 0 || messages[index - 1].role !== "assistant",
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const startIndex = index;
|
|
66
|
+
const groupedMessages: MessagesItem[] = [message];
|
|
67
|
+
|
|
68
|
+
while (
|
|
69
|
+
index + 1 < messages.length &&
|
|
70
|
+
isCollapsibleAssistantMessage(messages[index + 1])
|
|
71
|
+
) {
|
|
72
|
+
groupedMessages.push(messages[index + 1]);
|
|
73
|
+
index += 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
items.push({
|
|
77
|
+
...(groupedMessages.length > 1
|
|
78
|
+
? {
|
|
79
|
+
type: "group" as const,
|
|
80
|
+
messages: groupedMessages,
|
|
81
|
+
}
|
|
82
|
+
: {
|
|
83
|
+
type: "message" as const,
|
|
84
|
+
message: groupedMessages[0],
|
|
85
|
+
}),
|
|
86
|
+
key,
|
|
87
|
+
isNewest: index === messages.length - 1,
|
|
88
|
+
showAvatar:
|
|
89
|
+
startIndex === 0 || messages[startIndex - 1].role !== "assistant",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return items;
|
|
94
|
+
};
|
|
95
|
+
|
|
21
96
|
const ChatMessages: React.FC<ChatMessagesProps> = ({
|
|
22
97
|
conversations,
|
|
23
98
|
isLoading,
|
|
@@ -60,6 +135,23 @@ const ChatMessages: React.FC<ChatMessagesProps> = ({
|
|
|
60
135
|
}, [handleScroll, scrollAreaRef]);
|
|
61
136
|
|
|
62
137
|
const convLength = conversations.length;
|
|
138
|
+
const renderableConversations = useMemo<RenderableConversation[]>(
|
|
139
|
+
() =>
|
|
140
|
+
conversations.map((conversation, index) => {
|
|
141
|
+
const historyId = conversation.historyId || "";
|
|
142
|
+
const lastHistoryId = conversations[index - 1]?.historyId || "";
|
|
143
|
+
const addDivider =
|
|
144
|
+
(index > 0 && historyId !== lastHistoryId) ||
|
|
145
|
+
conversation.system?.level === "newConversation";
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
taskId: conversation.taskId,
|
|
149
|
+
addDivider,
|
|
150
|
+
items: buildRenderableMessages(conversation.messages),
|
|
151
|
+
};
|
|
152
|
+
}),
|
|
153
|
+
[conversations],
|
|
154
|
+
);
|
|
63
155
|
|
|
64
156
|
return (
|
|
65
157
|
<div
|
|
@@ -80,7 +172,7 @@ const ChatMessages: React.FC<ChatMessagesProps> = ({
|
|
|
80
172
|
}}
|
|
81
173
|
data-role="chat-messages"
|
|
82
174
|
>
|
|
83
|
-
{isLoadingHistory ? (
|
|
175
|
+
{isLoadingHistory && convLength > 0 ? (
|
|
84
176
|
<div
|
|
85
177
|
key="loading-history"
|
|
86
178
|
className="flex justify-center items-center gap-2 text-center"
|
|
@@ -96,37 +188,38 @@ const ChatMessages: React.FC<ChatMessagesProps> = ({
|
|
|
96
188
|
</div>
|
|
97
189
|
) : null}
|
|
98
190
|
|
|
99
|
-
{
|
|
100
|
-
const len = conversation.messages.length;
|
|
101
|
-
const historyId = conversation.historyId || "";
|
|
102
|
-
const lastHistoryId = conversations[index - 1]?.historyId || "";
|
|
103
|
-
let addDivider =
|
|
104
|
-
(index > 0 && historyId !== lastHistoryId) ||
|
|
105
|
-
conversation.system?.level === "newConversation";
|
|
106
|
-
|
|
191
|
+
{renderableConversations.map((conversation) => {
|
|
107
192
|
return (
|
|
108
193
|
<div key={conversation.taskId} className="flex flex-col gap-4">
|
|
109
|
-
{addDivider && (
|
|
194
|
+
{conversation.addDivider && (
|
|
110
195
|
<ChatDivider key={`${conversation.taskId}-divider`} />
|
|
111
196
|
)}
|
|
112
|
-
{conversation.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
197
|
+
{conversation.items.map((item) => {
|
|
198
|
+
if (item.type === "group") {
|
|
199
|
+
return (
|
|
200
|
+
<ChatAssistantCollapsedGroup
|
|
201
|
+
key={item.key}
|
|
202
|
+
messages={item.messages}
|
|
203
|
+
isNewest={item.isNewest}
|
|
204
|
+
isLoading={isLoading}
|
|
205
|
+
showAvatar={item.showAvatar}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (item.message.role === "assistant") {
|
|
116
211
|
return (
|
|
117
212
|
<ChatAssistantMessage
|
|
118
|
-
key={key}
|
|
119
|
-
message={message}
|
|
120
|
-
isNewest={isNewest}
|
|
213
|
+
key={item.key}
|
|
214
|
+
message={item.message}
|
|
215
|
+
isNewest={item.isNewest}
|
|
121
216
|
isLoading={isLoading}
|
|
122
|
-
showAvatar={
|
|
123
|
-
msgIndex === 0 ||
|
|
124
|
-
conversation.messages[msgIndex - 1].role !== "assistant"
|
|
125
|
-
}
|
|
217
|
+
showAvatar={item.showAvatar}
|
|
126
218
|
/>
|
|
127
219
|
);
|
|
128
220
|
}
|
|
129
|
-
|
|
221
|
+
|
|
222
|
+
return <ChatUserMessage key={item.key} message={item.message} />;
|
|
130
223
|
})}
|
|
131
224
|
</div>
|
|
132
225
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
1
2
|
import { Button } from "@/components/ui/button";
|
|
2
3
|
import { getText } from "../i18n";
|
|
3
4
|
|
|
@@ -12,23 +13,86 @@ const ChatRecommends: React.FC<{
|
|
|
12
13
|
onSend,
|
|
13
14
|
disabled,
|
|
14
15
|
}) => {
|
|
16
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const dragStateRef = useRef<{
|
|
18
|
+
pointerId: number;
|
|
19
|
+
startX: number;
|
|
20
|
+
startScrollLeft: number;
|
|
21
|
+
} | null>(null);
|
|
22
|
+
const [isDraggingScroll, setIsDraggingScroll] = useState(false);
|
|
23
|
+
|
|
15
24
|
if (hidden || !data || data.length === 0) return null;
|
|
25
|
+
|
|
26
|
+
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
27
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
28
|
+
|
|
29
|
+
const container = scrollRef.current;
|
|
30
|
+
if (!container) return;
|
|
31
|
+
|
|
32
|
+
dragStateRef.current = {
|
|
33
|
+
pointerId: e.pointerId,
|
|
34
|
+
startX: e.clientX,
|
|
35
|
+
startScrollLeft: container.scrollLeft,
|
|
36
|
+
};
|
|
37
|
+
setIsDraggingScroll(false);
|
|
38
|
+
container.setPointerCapture(e.pointerId);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
42
|
+
const container = scrollRef.current;
|
|
43
|
+
const dragState = dragStateRef.current;
|
|
44
|
+
if (!container || !dragState || dragState.pointerId !== e.pointerId) return;
|
|
45
|
+
|
|
46
|
+
const deltaX = e.clientX - dragState.startX;
|
|
47
|
+
if (Math.abs(deltaX) > 4) {
|
|
48
|
+
setIsDraggingScroll(true);
|
|
49
|
+
}
|
|
50
|
+
container.scrollLeft = dragState.startScrollLeft - deltaX;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
54
|
+
const container = scrollRef.current;
|
|
55
|
+
const dragState = dragStateRef.current;
|
|
56
|
+
if (!container || !dragState || dragState.pointerId !== e.pointerId) return;
|
|
57
|
+
|
|
58
|
+
if (container.hasPointerCapture(e.pointerId)) {
|
|
59
|
+
container.releasePointerCapture(e.pointerId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
window.requestAnimationFrame(() => {
|
|
63
|
+
setIsDraggingScroll(false);
|
|
64
|
+
});
|
|
65
|
+
dragStateRef.current = null;
|
|
66
|
+
};
|
|
67
|
+
|
|
16
68
|
return (
|
|
17
|
-
<div
|
|
18
|
-
{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
69
|
+
<div
|
|
70
|
+
ref={scrollRef}
|
|
71
|
+
className="overflow-x-auto overflow-y-hidden px-4 pt-2 pb-1 cursor-grab active:cursor-grabbing [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
72
|
+
style={{ touchAction: "pan-x" }}
|
|
73
|
+
onPointerDown={handlePointerDown}
|
|
74
|
+
onPointerMove={handlePointerMove}
|
|
75
|
+
onPointerUp={handlePointerEnd}
|
|
76
|
+
onPointerCancel={handlePointerEnd}
|
|
77
|
+
>
|
|
78
|
+
<div className="flex w-max min-w-full gap-2">
|
|
79
|
+
{data.map((prompt, index) => (
|
|
80
|
+
<Button
|
|
81
|
+
key={prompt}
|
|
82
|
+
variant="outline"
|
|
83
|
+
onClick={() => {
|
|
84
|
+
if (!isDraggingScroll) {
|
|
85
|
+
onSend(prompt);
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
className="h-auto shrink-0 rounded-full px-2 py-1 text-xs text-nowrap transition-all duration-200 animate-in fade-in-0 slide-in-from-bottom-1"
|
|
90
|
+
style={{ animationDelay: `${index * 50}ms` }}
|
|
91
|
+
>
|
|
92
|
+
{prompt}
|
|
93
|
+
</Button>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
32
96
|
</div>
|
|
33
97
|
);
|
|
34
98
|
};
|
|
@@ -3,12 +3,15 @@ import { Mic, StopCircle } from "lucide-react";
|
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
5
|
import { useVoiceInput } from "../hooks/useVoiceInput";
|
|
6
|
+
import { useEffect } from "react";
|
|
6
7
|
|
|
7
8
|
const VoiceInputButton: React.FC<{
|
|
8
9
|
onChange: (text: string) => void;
|
|
9
10
|
disabled?: boolean;
|
|
10
11
|
value?: string;
|
|
11
|
-
|
|
12
|
+
onRunningChange?: (running: boolean) => void;
|
|
13
|
+
compact?: boolean;
|
|
14
|
+
}> = ({ onChange, disabled, value, onRunningChange, compact }) => {
|
|
12
15
|
const {
|
|
13
16
|
start,
|
|
14
17
|
stop,
|
|
@@ -23,12 +26,18 @@ const VoiceInputButton: React.FC<{
|
|
|
23
26
|
value,
|
|
24
27
|
});
|
|
25
28
|
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (onRunningChange) {
|
|
31
|
+
onRunningChange(running);
|
|
32
|
+
}
|
|
33
|
+
}, [running, onRunningChange]);
|
|
34
|
+
|
|
26
35
|
return (
|
|
27
36
|
<Button
|
|
28
37
|
variant="ghost"
|
|
29
38
|
onClick={stoppable ? stop : status === "idle" ? start : undefined}
|
|
30
39
|
disabled={finalDisabled}
|
|
31
|
-
className={cn("h-
|
|
40
|
+
className={cn("cursor-pointer text-xs", compact ? "h-7 w-7 rounded-md" : "h-8 w-8 rounded-lg", {
|
|
32
41
|
"bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-primary-foreground animate-pulse":
|
|
33
42
|
running,
|
|
34
43
|
"w-auto": statusText,
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { IDisplayMode } from "../types";
|
|
4
|
+
|
|
5
|
+
interface Size {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface AssistantSizeState {
|
|
11
|
+
floating: Size;
|
|
12
|
+
sideLeftWidth: number;
|
|
13
|
+
sideRightWidth: number;
|
|
14
|
+
halfTopHeight: number;
|
|
15
|
+
halfBottomHeight: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ResizeType =
|
|
19
|
+
| "floating-corner"
|
|
20
|
+
| "side-left"
|
|
21
|
+
| "side-right"
|
|
22
|
+
| "half-top"
|
|
23
|
+
| "half-bottom";
|
|
24
|
+
|
|
25
|
+
const STORAGE_KEY = `${location.hostname}-ai-assistant-sizes`;
|
|
26
|
+
const BUTTON_SIZE = 56;
|
|
27
|
+
const EDGE_GAP = 16;
|
|
28
|
+
|
|
29
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
30
|
+
Math.min(Math.max(value, min), max);
|
|
31
|
+
|
|
32
|
+
const getViewportBounds = () => {
|
|
33
|
+
const maxFloatingWidth = Math.max(360, window.innerWidth - EDGE_GAP * 2);
|
|
34
|
+
const maxFloatingHeight = Math.max(320, window.innerHeight - EDGE_GAP * 2);
|
|
35
|
+
const maxSideWidth = Math.max(320, Math.min(720, window.innerWidth - 96));
|
|
36
|
+
const maxHalfHeight = Math.max(280, window.innerHeight - 96);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
floating: {
|
|
40
|
+
minWidth: 360,
|
|
41
|
+
maxWidth: maxFloatingWidth,
|
|
42
|
+
minHeight: 360,
|
|
43
|
+
maxHeight: maxFloatingHeight,
|
|
44
|
+
},
|
|
45
|
+
side: {
|
|
46
|
+
minWidth: 320,
|
|
47
|
+
maxWidth: maxSideWidth,
|
|
48
|
+
},
|
|
49
|
+
half: {
|
|
50
|
+
minHeight: 280,
|
|
51
|
+
maxHeight: maxHalfHeight,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const getDefaultState = (): AssistantSizeState => {
|
|
57
|
+
const bounds = getViewportBounds();
|
|
58
|
+
const defaultFloatingWidth = clamp(420, bounds.floating.minWidth, bounds.floating.maxWidth);
|
|
59
|
+
const defaultFloatingHeight = clamp(
|
|
60
|
+
window.innerHeight < 600 ? window.innerHeight - 32 : window.innerHeight - 200,
|
|
61
|
+
bounds.floating.minHeight,
|
|
62
|
+
bounds.floating.maxHeight,
|
|
63
|
+
);
|
|
64
|
+
const defaultSideWidth = clamp(420, bounds.side.minWidth, bounds.side.maxWidth);
|
|
65
|
+
const defaultHalfHeight = clamp(
|
|
66
|
+
Math.round(window.innerHeight * 0.5),
|
|
67
|
+
bounds.half.minHeight,
|
|
68
|
+
bounds.half.maxHeight,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
floating: {
|
|
73
|
+
width: defaultFloatingWidth,
|
|
74
|
+
height: defaultFloatingHeight,
|
|
75
|
+
},
|
|
76
|
+
sideLeftWidth: defaultSideWidth,
|
|
77
|
+
sideRightWidth: defaultSideWidth,
|
|
78
|
+
halfTopHeight: defaultHalfHeight,
|
|
79
|
+
halfBottomHeight: defaultHalfHeight,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const normalizeState = (state: AssistantSizeState): AssistantSizeState => {
|
|
84
|
+
const bounds = getViewportBounds();
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
floating: {
|
|
88
|
+
width: clamp(
|
|
89
|
+
state.floating.width,
|
|
90
|
+
bounds.floating.minWidth,
|
|
91
|
+
bounds.floating.maxWidth,
|
|
92
|
+
),
|
|
93
|
+
height: clamp(
|
|
94
|
+
state.floating.height,
|
|
95
|
+
bounds.floating.minHeight,
|
|
96
|
+
bounds.floating.maxHeight,
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
sideLeftWidth: clamp(
|
|
100
|
+
state.sideLeftWidth,
|
|
101
|
+
bounds.side.minWidth,
|
|
102
|
+
bounds.side.maxWidth,
|
|
103
|
+
),
|
|
104
|
+
sideRightWidth: clamp(
|
|
105
|
+
state.sideRightWidth,
|
|
106
|
+
bounds.side.minWidth,
|
|
107
|
+
bounds.side.maxWidth,
|
|
108
|
+
),
|
|
109
|
+
halfTopHeight: clamp(
|
|
110
|
+
state.halfTopHeight,
|
|
111
|
+
bounds.half.minHeight,
|
|
112
|
+
bounds.half.maxHeight,
|
|
113
|
+
),
|
|
114
|
+
halfBottomHeight: clamp(
|
|
115
|
+
state.halfBottomHeight,
|
|
116
|
+
bounds.half.minHeight,
|
|
117
|
+
bounds.half.maxHeight,
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getSavedState = (): AssistantSizeState => {
|
|
123
|
+
const fallback = getDefaultState();
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
127
|
+
if (!saved) return fallback;
|
|
128
|
+
const parsed = JSON.parse(saved) as Partial<AssistantSizeState>;
|
|
129
|
+
return normalizeState({
|
|
130
|
+
floating: {
|
|
131
|
+
width: parsed.floating?.width ?? fallback.floating.width,
|
|
132
|
+
height: parsed.floating?.height ?? fallback.floating.height,
|
|
133
|
+
},
|
|
134
|
+
sideLeftWidth: parsed.sideLeftWidth ?? fallback.sideLeftWidth,
|
|
135
|
+
sideRightWidth: parsed.sideRightWidth ?? fallback.sideRightWidth,
|
|
136
|
+
halfTopHeight: parsed.halfTopHeight ?? fallback.halfTopHeight,
|
|
137
|
+
halfBottomHeight: parsed.halfBottomHeight ?? fallback.halfBottomHeight,
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const useAssistantSize = (displayMode: IDisplayMode) => {
|
|
145
|
+
const [sizes, setSizes] = useState<AssistantSizeState>(getSavedState);
|
|
146
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
147
|
+
const resizeRef = useRef<{
|
|
148
|
+
type: ResizeType;
|
|
149
|
+
startX: number;
|
|
150
|
+
startY: number;
|
|
151
|
+
sizes: AssistantSizeState;
|
|
152
|
+
} | null>(null);
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(sizes));
|
|
156
|
+
}, [sizes]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const handleResize = () => {
|
|
160
|
+
setSizes((prev) => {
|
|
161
|
+
const normalized = normalizeState(prev);
|
|
162
|
+
return JSON.stringify(normalized) === JSON.stringify(prev)
|
|
163
|
+
? prev
|
|
164
|
+
: normalized;
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
window.addEventListener("resize", handleResize);
|
|
169
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!isResizing) return;
|
|
174
|
+
|
|
175
|
+
const previousUserSelect = document.body.style.userSelect;
|
|
176
|
+
document.body.style.userSelect = "none";
|
|
177
|
+
|
|
178
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
179
|
+
const activeResize = resizeRef.current;
|
|
180
|
+
if (!activeResize) return;
|
|
181
|
+
|
|
182
|
+
const deltaX = event.clientX - activeResize.startX;
|
|
183
|
+
const deltaY = event.clientY - activeResize.startY;
|
|
184
|
+
const bounds = getViewportBounds();
|
|
185
|
+
|
|
186
|
+
setSizes((prev) => {
|
|
187
|
+
const next = { ...prev, floating: { ...prev.floating } };
|
|
188
|
+
|
|
189
|
+
switch (activeResize.type) {
|
|
190
|
+
case "floating-corner":
|
|
191
|
+
next.floating.width = clamp(
|
|
192
|
+
activeResize.sizes.floating.width - deltaX,
|
|
193
|
+
bounds.floating.minWidth,
|
|
194
|
+
bounds.floating.maxWidth,
|
|
195
|
+
);
|
|
196
|
+
next.floating.height = clamp(
|
|
197
|
+
activeResize.sizes.floating.height - deltaY,
|
|
198
|
+
bounds.floating.minHeight,
|
|
199
|
+
bounds.floating.maxHeight,
|
|
200
|
+
);
|
|
201
|
+
break;
|
|
202
|
+
case "side-left":
|
|
203
|
+
next.sideLeftWidth = clamp(
|
|
204
|
+
activeResize.sizes.sideLeftWidth + deltaX,
|
|
205
|
+
bounds.side.minWidth,
|
|
206
|
+
bounds.side.maxWidth,
|
|
207
|
+
);
|
|
208
|
+
break;
|
|
209
|
+
case "side-right":
|
|
210
|
+
next.sideRightWidth = clamp(
|
|
211
|
+
activeResize.sizes.sideRightWidth - deltaX,
|
|
212
|
+
bounds.side.minWidth,
|
|
213
|
+
bounds.side.maxWidth,
|
|
214
|
+
);
|
|
215
|
+
break;
|
|
216
|
+
case "half-top":
|
|
217
|
+
next.halfTopHeight = clamp(
|
|
218
|
+
activeResize.sizes.halfTopHeight + deltaY,
|
|
219
|
+
bounds.half.minHeight,
|
|
220
|
+
bounds.half.maxHeight,
|
|
221
|
+
);
|
|
222
|
+
break;
|
|
223
|
+
case "half-bottom":
|
|
224
|
+
next.halfBottomHeight = clamp(
|
|
225
|
+
activeResize.sizes.halfBottomHeight - deltaY,
|
|
226
|
+
bounds.half.minHeight,
|
|
227
|
+
bounds.half.maxHeight,
|
|
228
|
+
);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return next;
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const handlePointerUp = () => {
|
|
237
|
+
resizeRef.current = null;
|
|
238
|
+
setIsResizing(false);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
window.addEventListener("pointermove", handlePointerMove);
|
|
242
|
+
window.addEventListener("pointerup", handlePointerUp);
|
|
243
|
+
|
|
244
|
+
return () => {
|
|
245
|
+
document.body.style.userSelect = previousUserSelect;
|
|
246
|
+
window.removeEventListener("pointermove", handlePointerMove);
|
|
247
|
+
window.removeEventListener("pointerup", handlePointerUp);
|
|
248
|
+
};
|
|
249
|
+
}, [isResizing]);
|
|
250
|
+
|
|
251
|
+
const getElementDimensions = useCallback(
|
|
252
|
+
(isOpen: boolean) => {
|
|
253
|
+
if (!isOpen) {
|
|
254
|
+
return {
|
|
255
|
+
width: BUTTON_SIZE,
|
|
256
|
+
height: BUTTON_SIZE,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
switch (displayMode) {
|
|
261
|
+
case "floating":
|
|
262
|
+
return sizes.floating;
|
|
263
|
+
case "side-left":
|
|
264
|
+
return {
|
|
265
|
+
width: sizes.sideLeftWidth,
|
|
266
|
+
height: window.innerHeight,
|
|
267
|
+
};
|
|
268
|
+
case "side-right":
|
|
269
|
+
return {
|
|
270
|
+
width: sizes.sideRightWidth,
|
|
271
|
+
height: window.innerHeight,
|
|
272
|
+
};
|
|
273
|
+
case "half-top":
|
|
274
|
+
return {
|
|
275
|
+
width: window.innerWidth,
|
|
276
|
+
height: sizes.halfTopHeight,
|
|
277
|
+
};
|
|
278
|
+
case "half-bottom":
|
|
279
|
+
return {
|
|
280
|
+
width: window.innerWidth,
|
|
281
|
+
height: sizes.halfBottomHeight,
|
|
282
|
+
};
|
|
283
|
+
case "fullscreen":
|
|
284
|
+
return {
|
|
285
|
+
width: window.innerWidth,
|
|
286
|
+
height: window.innerHeight,
|
|
287
|
+
};
|
|
288
|
+
default:
|
|
289
|
+
return sizes.floating;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[displayMode, sizes],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const cardStyle = useMemo<React.CSSProperties | undefined>(() => {
|
|
296
|
+
switch (displayMode) {
|
|
297
|
+
case "floating":
|
|
298
|
+
return {
|
|
299
|
+
width: sizes.floating.width,
|
|
300
|
+
height: sizes.floating.height,
|
|
301
|
+
};
|
|
302
|
+
case "side-left":
|
|
303
|
+
return {
|
|
304
|
+
width: sizes.sideLeftWidth,
|
|
305
|
+
};
|
|
306
|
+
case "side-right":
|
|
307
|
+
return {
|
|
308
|
+
width: sizes.sideRightWidth,
|
|
309
|
+
};
|
|
310
|
+
case "half-top":
|
|
311
|
+
return {
|
|
312
|
+
height: sizes.halfTopHeight,
|
|
313
|
+
};
|
|
314
|
+
case "half-bottom":
|
|
315
|
+
return {
|
|
316
|
+
height: sizes.halfBottomHeight,
|
|
317
|
+
};
|
|
318
|
+
default:
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
}, [displayMode, sizes]);
|
|
322
|
+
|
|
323
|
+
const startResize = useCallback(
|
|
324
|
+
(type: ResizeType, event: React.PointerEvent) => {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
event.stopPropagation();
|
|
327
|
+
resizeRef.current = {
|
|
328
|
+
type,
|
|
329
|
+
startX: event.clientX,
|
|
330
|
+
startY: event.clientY,
|
|
331
|
+
sizes,
|
|
332
|
+
};
|
|
333
|
+
setIsResizing(true);
|
|
334
|
+
},
|
|
335
|
+
[sizes],
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const resizeType: ResizeType | null =
|
|
339
|
+
displayMode === "floating"
|
|
340
|
+
? "floating-corner"
|
|
341
|
+
: displayMode === "side-left"
|
|
342
|
+
? "side-left"
|
|
343
|
+
: displayMode === "side-right"
|
|
344
|
+
? "side-right"
|
|
345
|
+
: displayMode === "half-top"
|
|
346
|
+
? "half-top"
|
|
347
|
+
: displayMode === "half-bottom"
|
|
348
|
+
? "half-bottom"
|
|
349
|
+
: null;
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
cardStyle,
|
|
353
|
+
getElementDimensions,
|
|
354
|
+
isResizing,
|
|
355
|
+
resizeType,
|
|
356
|
+
sideLeftWidth: sizes.sideLeftWidth,
|
|
357
|
+
sideRightWidth: sizes.sideRightWidth,
|
|
358
|
+
startResize,
|
|
359
|
+
};
|
|
360
|
+
};
|