@sciol/xyzen 0.1.3 → 0.1.4
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/dist/xyzen.es.js +138 -127
- package/dist/xyzen.umd.js +1 -1
- package/package.json +3 -2
- package/src/app/App.tsx +221 -0
- package/src/assets/react.svg +1 -0
- package/src/components/layouts/XyzenChat.tsx +212 -0
- package/src/components/layouts/XyzenHistory.tsx +117 -0
- package/src/components/layouts/XyzenNodes.tsx +7 -0
- package/src/components/layouts/components/ChatBubble.tsx +140 -0
- package/src/components/layouts/components/ChatInput.tsx +173 -0
- package/src/components/layouts/components/EmptyChat.tsx +117 -0
- package/src/components/layouts/components/WelcomeMessage.tsx +104 -0
- package/src/configs/index.ts +1 -0
- package/src/context/XyzenProvider.tsx +0 -0
- package/src/hooks/useTheme.ts +79 -0
- package/src/index.ts +6 -0
- package/src/lib/Markdown.tsx +98 -0
- package/src/lib/formatDate.ts +32 -0
- package/src/main.tsx +9 -0
- package/src/service/xyzenService.ts +90 -0
- package/src/store/xyzenStore.ts +326 -0
- package/src/types/xyzen.d.ts +0 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useXyzen, type Message } from "@/store/xyzenStore";
|
|
3
|
+
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
|
4
|
+
import { AnimatePresence } from "framer-motion";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
|
|
7
|
+
import ChatBubble from "./components/ChatBubble";
|
|
8
|
+
import ChatInput from "./components/ChatInput";
|
|
9
|
+
import EmptyChat from "./components/EmptyChat";
|
|
10
|
+
import WelcomeMessage from "./components/WelcomeMessage";
|
|
11
|
+
|
|
12
|
+
export default function XyzenChat() {
|
|
13
|
+
const {
|
|
14
|
+
activeChatChannel,
|
|
15
|
+
channels,
|
|
16
|
+
assistants,
|
|
17
|
+
sendMessage,
|
|
18
|
+
connectToChannel,
|
|
19
|
+
} = useXyzen();
|
|
20
|
+
|
|
21
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
24
|
+
const [isRetrying, setIsRetrying] = useState(false);
|
|
25
|
+
const [inputHeight, setInputHeight] = useState(80);
|
|
26
|
+
|
|
27
|
+
const messagePaddingBottom = inputHeight + 36;
|
|
28
|
+
const scrollButtonBottom = inputHeight + 48;
|
|
29
|
+
|
|
30
|
+
const currentChannel = activeChatChannel ? channels[activeChatChannel] : null;
|
|
31
|
+
const currentAssistant = currentChannel?.assistantId
|
|
32
|
+
? assistants.find((a) => a.id === currentChannel.assistantId)
|
|
33
|
+
: null;
|
|
34
|
+
const messages: Message[] = currentChannel?.messages || [];
|
|
35
|
+
const connected = currentChannel?.connected || false;
|
|
36
|
+
const error = currentChannel?.error || null;
|
|
37
|
+
|
|
38
|
+
const scrollToBottom = useCallback(
|
|
39
|
+
(force = false) => {
|
|
40
|
+
if (!autoScroll && !force) return;
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
messagesContainerRef.current?.scrollTo({
|
|
43
|
+
top: messagesContainerRef.current.scrollHeight,
|
|
44
|
+
behavior: force ? "auto" : "smooth",
|
|
45
|
+
});
|
|
46
|
+
}, 50);
|
|
47
|
+
},
|
|
48
|
+
[autoScroll],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const handleScroll = () => {
|
|
52
|
+
if (messagesContainerRef.current) {
|
|
53
|
+
const { scrollTop, scrollHeight, clientHeight } =
|
|
54
|
+
messagesContainerRef.current;
|
|
55
|
+
const isNearBottom = scrollHeight - scrollTop - clientHeight < 80;
|
|
56
|
+
setAutoScroll(isNearBottom);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleSendMessage = (inputMessage: string) => {
|
|
61
|
+
if (!inputMessage.trim() || !activeChatChannel) return;
|
|
62
|
+
sendMessage(inputMessage);
|
|
63
|
+
setAutoScroll(true);
|
|
64
|
+
setTimeout(() => scrollToBottom(true), 100);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleRetryConnection = () => {
|
|
68
|
+
if (!currentChannel) return;
|
|
69
|
+
setIsRetrying(true);
|
|
70
|
+
connectToChannel(currentChannel.sessionId, currentChannel.id);
|
|
71
|
+
setTimeout(() => {
|
|
72
|
+
setIsRetrying(false);
|
|
73
|
+
}, 2000);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (autoScroll) {
|
|
78
|
+
scrollToBottom();
|
|
79
|
+
}
|
|
80
|
+
}, [messages.length, autoScroll, scrollToBottom]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const container = messagesContainerRef.current;
|
|
84
|
+
if (container) {
|
|
85
|
+
scrollToBottom(true);
|
|
86
|
+
container.addEventListener("scroll", handleScroll, { passive: true });
|
|
87
|
+
return () => container.removeEventListener("scroll", handleScroll);
|
|
88
|
+
}
|
|
89
|
+
}, [activeChatChannel, scrollToBottom]);
|
|
90
|
+
|
|
91
|
+
const handleInputHeightChange = (newHeight: number) => {
|
|
92
|
+
setInputHeight(newHeight);
|
|
93
|
+
if (autoScroll) {
|
|
94
|
+
setTimeout(() => scrollToBottom(true), 100);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (!activeChatChannel) {
|
|
99
|
+
return <EmptyChat />;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="flex h-full flex-col">
|
|
104
|
+
{currentAssistant ? (
|
|
105
|
+
<div className="flex-shrink-0 px-4 pb-2">
|
|
106
|
+
<h2 className="text-lg font-medium text-neutral-800 dark:text-white">
|
|
107
|
+
{currentAssistant.title}
|
|
108
|
+
</h2>
|
|
109
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
110
|
+
{currentAssistant.description}
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
<div className="flex-shrink-0 px-4 pb-2">
|
|
115
|
+
<h2 className="text-lg font-medium text-neutral-800 dark:text-white">
|
|
116
|
+
自由对话
|
|
117
|
+
</h2>
|
|
118
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
119
|
+
您可以在这里与AI助手自由讨论任何话题
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{!connected && (
|
|
125
|
+
<div className="mb-1 flex flex-shrink-0 items-center justify-between rounded-md bg-amber-50 px-3 py-1.5 dark:bg-amber-900/20">
|
|
126
|
+
<span className="text-xs text-amber-700 dark:text-amber-200">
|
|
127
|
+
{error || "正在连接聊天服务..."}
|
|
128
|
+
</span>
|
|
129
|
+
<button
|
|
130
|
+
onClick={handleRetryConnection}
|
|
131
|
+
disabled={isRetrying}
|
|
132
|
+
className="ml-2 rounded-md p-1 text-amber-700 hover:bg-amber-100 focus:outline-none focus:ring-1 focus:ring-amber-500 dark:text-amber-300 dark:hover:bg-amber-800/30"
|
|
133
|
+
title="重试连接"
|
|
134
|
+
>
|
|
135
|
+
<ArrowPathIcon
|
|
136
|
+
className={`h-4 w-4 ${isRetrying ? "animate-spin" : ""}`}
|
|
137
|
+
/>
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
<div className="relative flex h-[calc(100vh-12rem)] flex-grow flex-col">
|
|
143
|
+
<div
|
|
144
|
+
ref={messagesContainerRef}
|
|
145
|
+
className="absolute inset-0 overflow-y-auto rounded-lg bg-neutral-50 pt-6 dark:bg-black"
|
|
146
|
+
style={{
|
|
147
|
+
scrollbarWidth: "thin",
|
|
148
|
+
scrollbarColor: "rgba(156,163,175,0.5) transparent",
|
|
149
|
+
paddingBottom: `${messagePaddingBottom}px`,
|
|
150
|
+
}}
|
|
151
|
+
onScroll={handleScroll}
|
|
152
|
+
>
|
|
153
|
+
<div className="px-3">
|
|
154
|
+
{messages.length === 0 ? (
|
|
155
|
+
<WelcomeMessage />
|
|
156
|
+
) : (
|
|
157
|
+
<div className="space-y-6">
|
|
158
|
+
<AnimatePresence>
|
|
159
|
+
{messages.map((msg) => (
|
|
160
|
+
<ChatBubble key={msg.id} message={msg} />
|
|
161
|
+
))}
|
|
162
|
+
</AnimatePresence>
|
|
163
|
+
<div ref={messagesEndRef} className="h-4" />
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{!autoScroll && messages.length > 0 && (
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => {
|
|
172
|
+
setAutoScroll(true);
|
|
173
|
+
scrollToBottom(true);
|
|
174
|
+
}}
|
|
175
|
+
className="absolute right-4 z-20 rounded-full bg-indigo-600 p-2 text-white shadow-md transition-colors hover:bg-indigo-700"
|
|
176
|
+
style={{
|
|
177
|
+
bottom: `${scrollButtonBottom}px`,
|
|
178
|
+
}}
|
|
179
|
+
aria-label="Scroll to bottom"
|
|
180
|
+
>
|
|
181
|
+
<svg
|
|
182
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
183
|
+
className="h-4 w-4"
|
|
184
|
+
fill="none"
|
|
185
|
+
viewBox="0 0 24 24"
|
|
186
|
+
stroke="currentColor"
|
|
187
|
+
>
|
|
188
|
+
<path
|
|
189
|
+
strokeLinecap="round"
|
|
190
|
+
strokeLinejoin="round"
|
|
191
|
+
strokeWidth={2}
|
|
192
|
+
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
|
193
|
+
/>
|
|
194
|
+
</svg>
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<div
|
|
199
|
+
className="absolute bottom-0 left-0 right-0 rounded-b-lg border-t border-neutral-200 bg-white p-2 shadow-[0_-2px_5px_rgba(0,0,0,0.05)] dark:border-neutral-800 dark:bg-black dark:shadow-[0_-2px_5px_rgba(0,0,0,0.2)]"
|
|
200
|
+
style={{ zIndex: 15 }}
|
|
201
|
+
>
|
|
202
|
+
<ChatInput
|
|
203
|
+
onSendMessage={handleSendMessage}
|
|
204
|
+
disabled={!connected}
|
|
205
|
+
placeholder="输入消息..."
|
|
206
|
+
onHeightChange={handleInputHeightChange}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatTime } from "@/lib/formatDate";
|
|
4
|
+
import { type ChatHistoryItem, useXyzen } from "@/store/xyzenStore";
|
|
5
|
+
import { MapPinIcon } from "@heroicons/react/20/solid";
|
|
6
|
+
import { ChevronRightIcon, ClockIcon } from "@heroicons/react/24/outline";
|
|
7
|
+
import { useEffect } from "react";
|
|
8
|
+
|
|
9
|
+
export default function XyzenHistory() {
|
|
10
|
+
const {
|
|
11
|
+
chatHistory,
|
|
12
|
+
chatHistoryLoading,
|
|
13
|
+
fetchChatHistory,
|
|
14
|
+
setActiveChatChannel,
|
|
15
|
+
togglePinChat,
|
|
16
|
+
setTabIndex,
|
|
17
|
+
} = useXyzen();
|
|
18
|
+
|
|
19
|
+
// 组件挂载时加载聊天历史
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
fetchChatHistory();
|
|
22
|
+
}, [fetchChatHistory]);
|
|
23
|
+
|
|
24
|
+
// 根据置顶状态对聊天记录进行排序
|
|
25
|
+
const sortedHistory = [...chatHistory].sort((a, b) => {
|
|
26
|
+
if (a.isPinned && !b.isPinned) return -1;
|
|
27
|
+
if (!a.isPinned && b.isPinned) return 1;
|
|
28
|
+
const dateA = new Date(a.updatedAt);
|
|
29
|
+
const dateB = new Date(b.updatedAt);
|
|
30
|
+
return dateB.getTime() - dateA.getTime();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 激活聊天会话
|
|
34
|
+
const activateChat = (chatId: string) => {
|
|
35
|
+
setActiveChatChannel(chatId);
|
|
36
|
+
setTabIndex(0); // 切换到聊天标签页
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// 切换置顶状态
|
|
40
|
+
const handleTogglePin = (e: React.MouseEvent, chatId: string) => {
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
togglePinChat(chatId);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (chatHistoryLoading) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex h-full items-center justify-center">
|
|
48
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-300 border-t-indigo-600"></div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (sortedHistory.length === 0) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex h-full flex-col items-center justify-center px-4 text-center text-neutral-400">
|
|
56
|
+
<ClockIcon className="h-12 w-12 opacity-50" />
|
|
57
|
+
<p className="mt-4">没有聊天记录</p>
|
|
58
|
+
<p className="mt-1 text-xs">创建新对话开始聊天</p>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-2 px-4">
|
|
65
|
+
{sortedHistory.map((chat: ChatHistoryItem) => (
|
|
66
|
+
<div
|
|
67
|
+
key={chat.id}
|
|
68
|
+
className="group relative flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 p-3 hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-900"
|
|
69
|
+
onClick={() => activateChat(chat.id)}
|
|
70
|
+
>
|
|
71
|
+
<div className="flex-1 overflow-hidden">
|
|
72
|
+
<div className="flex items-center">
|
|
73
|
+
{chat.isPinned && (
|
|
74
|
+
<MapPinIcon className="mr-1.5 h-3.5 w-3.5 rotate-45 text-indigo-500 dark:text-indigo-400" />
|
|
75
|
+
)}
|
|
76
|
+
<h3
|
|
77
|
+
className={`truncate text-sm font-medium ${
|
|
78
|
+
chat.isPinned
|
|
79
|
+
? "text-indigo-700 dark:text-indigo-400"
|
|
80
|
+
: "text-neutral-800 dark:text-white"
|
|
81
|
+
}`}
|
|
82
|
+
>
|
|
83
|
+
{chat.title}
|
|
84
|
+
</h3>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="mt-1 flex items-center text-xs text-neutral-500">
|
|
87
|
+
<span className="truncate">{chat.assistantTitle}</span>
|
|
88
|
+
<span className="mx-1.5">·</span>
|
|
89
|
+
<span className="whitespace-nowrap">
|
|
90
|
+
{formatTime(chat.updatedAt)}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
{chat.lastMessage && (
|
|
94
|
+
<p className="mt-1 truncate text-xs text-neutral-500">
|
|
95
|
+
{chat.lastMessage}
|
|
96
|
+
</p>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
<div className="ml-4 flex items-center gap-2">
|
|
100
|
+
<button
|
|
101
|
+
className="invisible rounded p-1 text-neutral-400 hover:bg-neutral-200 hover:text-neutral-700 group-hover:visible dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
|
|
102
|
+
title={chat.isPinned ? "取消置顶" : "置顶会话"}
|
|
103
|
+
onClick={(e) => handleTogglePin(e, chat.id)}
|
|
104
|
+
>
|
|
105
|
+
<MapPinIcon
|
|
106
|
+
className={`h-4 w-4 ${chat.isPinned ? "rotate-45" : ""}`}
|
|
107
|
+
/>
|
|
108
|
+
</button>
|
|
109
|
+
<div className="rounded-full p-1.5 text-neutral-400 group-hover:text-neutral-500">
|
|
110
|
+
<ChevronRightIcon className="h-4 w-4" />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import Markdown from "@/lib/Markdown";
|
|
2
|
+
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
import React, { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
export interface Message {
|
|
7
|
+
id: string;
|
|
8
|
+
sender: string;
|
|
9
|
+
content: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
avatar?: string;
|
|
12
|
+
isCurrentUser?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ChatBubbleProps {
|
|
16
|
+
message: Message;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ChatBubble: React.FC<ChatBubbleProps> = ({ message }) => {
|
|
20
|
+
const { isCurrentUser, content, timestamp, avatar } = message;
|
|
21
|
+
const [imageError, setImageError] = useState(false);
|
|
22
|
+
const [userAvatar, setUserAvatar] = useState<string | undefined>(avatar);
|
|
23
|
+
|
|
24
|
+
// 如果是当前用户,从localStorage获取最新的用户头像
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (isCurrentUser) {
|
|
27
|
+
try {
|
|
28
|
+
const userInfoStr = localStorage.getItem("userInfo");
|
|
29
|
+
if (userInfoStr) {
|
|
30
|
+
const userInfo = JSON.parse(userInfoStr);
|
|
31
|
+
if (userInfo.avatar) {
|
|
32
|
+
setUserAvatar(userInfo.avatar);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error("Failed to get avatar from localStorage", e);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}, [isCurrentUser]);
|
|
40
|
+
|
|
41
|
+
// Updated time format to include seconds
|
|
42
|
+
const formattedTime = new Date(timestamp).toLocaleTimeString([], {
|
|
43
|
+
hour: "2-digit",
|
|
44
|
+
minute: "2-digit",
|
|
45
|
+
second: "2-digit",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Different styles for user vs AI messages
|
|
49
|
+
const messageStyles = isCurrentUser
|
|
50
|
+
? "border-l-4 border-blue-400 bg-blue-50/50 dark:border-blue-600 dark:bg-blue-900/20"
|
|
51
|
+
: "border-l-4 border-neutral-300 bg-white dark:border-neutral-600 dark:bg-neutral-800/50";
|
|
52
|
+
|
|
53
|
+
// 获取头像 URL 但避免使用不存在的默认头像文件
|
|
54
|
+
const getAvatarUrl = (avatarPath?: string) => {
|
|
55
|
+
if (!avatarPath) return null;
|
|
56
|
+
|
|
57
|
+
// 如果已经是完整URL就直接使用
|
|
58
|
+
if (avatarPath.startsWith("http")) {
|
|
59
|
+
return avatarPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 如果是相对路径,根据环境添加正确的基本URL
|
|
63
|
+
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
|
64
|
+
return avatarPath.startsWith("/")
|
|
65
|
+
? `${baseUrl}${avatarPath}`
|
|
66
|
+
: `${baseUrl}/${avatarPath}`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// 渲染头像,使用初始字母作为最后的备用选项
|
|
70
|
+
const renderAvatar = () => {
|
|
71
|
+
// 如果已经知道图像加载失败,或者没有提供头像
|
|
72
|
+
if (imageError || !userAvatar) {
|
|
73
|
+
// 显示用户或AI的首字母作为头像
|
|
74
|
+
const initial = isCurrentUser
|
|
75
|
+
? message.sender?.charAt(0)?.toUpperCase() || "U"
|
|
76
|
+
: "A";
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
|
81
|
+
isCurrentUser
|
|
82
|
+
? "bg-blue-500 text-white"
|
|
83
|
+
: "bg-purple-500 text-white"
|
|
84
|
+
}`}
|
|
85
|
+
>
|
|
86
|
+
<span className="text-xs font-medium">{initial}</span>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 尝试加载实际头像
|
|
92
|
+
const avatarUrl = getAvatarUrl(userAvatar);
|
|
93
|
+
return (
|
|
94
|
+
<img
|
|
95
|
+
src={avatarUrl || ""}
|
|
96
|
+
alt={isCurrentUser ? "You" : "Assistant"}
|
|
97
|
+
className="h-6 w-6 rounded-full shadow-sm transition-transform duration-200 group-hover:scale-110"
|
|
98
|
+
onError={() => setImageError(true)} // 加载失败时设置状态,不再尝试加载图片
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<motion.div
|
|
105
|
+
initial={{ opacity: 0, y: 20 }}
|
|
106
|
+
animate={{ opacity: 1, y: 0 }}
|
|
107
|
+
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
108
|
+
className="group relative w-full pl-8"
|
|
109
|
+
>
|
|
110
|
+
{/* Timestamp - hidden by default, shown on hover */}
|
|
111
|
+
<div className="absolute -top-6 left-8 z-10 transform opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
|
112
|
+
<span className="rounded px-2 py-1 text-xs text-neutral-500 dark:text-neutral-400">
|
|
113
|
+
{formattedTime}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Avatar - positioned to the left */}
|
|
118
|
+
<div className="absolute left-0 top-1">{renderAvatar()}</div>
|
|
119
|
+
|
|
120
|
+
{/* Message content */}
|
|
121
|
+
<div
|
|
122
|
+
className={`w-full rounded-none ${messageStyles} transition-all duration-200 hover:shadow-sm`}
|
|
123
|
+
>
|
|
124
|
+
<div className="p-3">
|
|
125
|
+
<div
|
|
126
|
+
className={`prose prose-neutral dark:prose-invert prose-sm max-w-none ${
|
|
127
|
+
isCurrentUser
|
|
128
|
+
? "text-sm text-neutral-800 dark:text-neutral-200"
|
|
129
|
+
: "text-sm text-neutral-700 dark:text-neutral-300"
|
|
130
|
+
}`}
|
|
131
|
+
>
|
|
132
|
+
{isCurrentUser ? <p>{content}</p> : <Markdown content={content} />}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</motion.div>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export default ChatBubble;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DragEndEvent,
|
|
3
|
+
type DragMoveEvent,
|
|
4
|
+
DndContext,
|
|
5
|
+
PointerSensor,
|
|
6
|
+
useDraggable,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
} from "@dnd-kit/core";
|
|
10
|
+
import { PaperAirplaneIcon } from "@heroicons/react/24/solid";
|
|
11
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
12
|
+
|
|
13
|
+
interface ChatInputProps {
|
|
14
|
+
onSendMessage: (message: string) => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
onHeightChange?: (height: number) => void; // New prop to report height changes
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Draggable resize handle component
|
|
21
|
+
const ResizeHandle = () => {
|
|
22
|
+
const { attributes, listeners, setNodeRef } = useDraggable({
|
|
23
|
+
id: "resize-handle",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
ref={setNodeRef}
|
|
29
|
+
{...listeners}
|
|
30
|
+
{...attributes}
|
|
31
|
+
className="absolute -top-2 left-0 right-0 -mx-2 h-0.5 cursor-ns-resize transition-colors hover:bg-indigo-600"
|
|
32
|
+
title="拖动调整高度"
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const ChatInput: React.FC<ChatInputProps> = ({
|
|
38
|
+
onSendMessage,
|
|
39
|
+
disabled = false,
|
|
40
|
+
placeholder = "输入消息...",
|
|
41
|
+
onHeightChange,
|
|
42
|
+
}) => {
|
|
43
|
+
const [inputMessage, setInputMessage] = useState("");
|
|
44
|
+
const [textareaHeight, setTextareaHeight] = useState(() => {
|
|
45
|
+
// Try to get saved height from localStorage
|
|
46
|
+
const savedHeight = localStorage.getItem("chatInputHeight");
|
|
47
|
+
return savedHeight ? parseInt(savedHeight, 10) : 80; // Default 80px
|
|
48
|
+
});
|
|
49
|
+
// 添加一个状态来跟踪输入法的组合状态
|
|
50
|
+
const [isComposing, setIsComposing] = useState(false);
|
|
51
|
+
|
|
52
|
+
// Keep track of initial height when drag starts
|
|
53
|
+
const initialHeightRef = useRef(textareaHeight);
|
|
54
|
+
// Keep track of total delta during drag
|
|
55
|
+
const dragDeltaRef = useRef(0);
|
|
56
|
+
|
|
57
|
+
// Setup dnd sensors with lower activation distance for more responsiveness
|
|
58
|
+
const sensors = useSensors(
|
|
59
|
+
useSensor(PointerSensor, {
|
|
60
|
+
activationConstraint: {
|
|
61
|
+
distance: 0, // Decreased from 1 to improve responsiveness
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Save height to localStorage when it changes and report height changes to parent
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
localStorage.setItem("chatInputHeight", textareaHeight.toString());
|
|
69
|
+
if (onHeightChange) {
|
|
70
|
+
onHeightChange(textareaHeight);
|
|
71
|
+
}
|
|
72
|
+
}, [textareaHeight, onHeightChange]);
|
|
73
|
+
|
|
74
|
+
// Handle drag start to set initial height reference
|
|
75
|
+
const handleDragStart = () => {
|
|
76
|
+
initialHeightRef.current = textareaHeight;
|
|
77
|
+
dragDeltaRef.current = 0;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Handle drag move to update height in real-time
|
|
81
|
+
const handleDragMove = (event: DragMoveEvent) => {
|
|
82
|
+
const { delta } = event;
|
|
83
|
+
dragDeltaRef.current = delta.y;
|
|
84
|
+
|
|
85
|
+
// Calculate new height based on initial height and total delta
|
|
86
|
+
const newHeight = Math.max(60, initialHeightRef.current - delta.y);
|
|
87
|
+
setTextareaHeight(newHeight);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Handle drag end for final cleanup
|
|
91
|
+
const handleDragEnd = (_: DragEndEvent) => {
|
|
92
|
+
// Final height adjustment already done during move, just ensure it's saved
|
|
93
|
+
const finalHeight = Math.max(
|
|
94
|
+
60,
|
|
95
|
+
initialHeightRef.current - dragDeltaRef.current,
|
|
96
|
+
);
|
|
97
|
+
setTextareaHeight(finalHeight);
|
|
98
|
+
|
|
99
|
+
// Reset the refs
|
|
100
|
+
dragDeltaRef.current = 0;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleSendMessage = () => {
|
|
104
|
+
if (!inputMessage.trim()) return;
|
|
105
|
+
onSendMessage(inputMessage);
|
|
106
|
+
setInputMessage("");
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// 处理输入法组合开始事件
|
|
110
|
+
const handleCompositionStart = () => {
|
|
111
|
+
setIsComposing(true);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// 处理输入法组合结束事件
|
|
115
|
+
const handleCompositionEnd = () => {
|
|
116
|
+
setIsComposing(false);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Handle Enter key to send message
|
|
120
|
+
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
121
|
+
// 如果是在输入法组合状态中,不处理回车键事件
|
|
122
|
+
if (e.key === "Enter" && !e.shiftKey && !isComposing) {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
handleSendMessage();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="w-full">
|
|
130
|
+
<DndContext
|
|
131
|
+
sensors={sensors}
|
|
132
|
+
onDragStart={handleDragStart}
|
|
133
|
+
onDragMove={handleDragMove}
|
|
134
|
+
onDragEnd={handleDragEnd}
|
|
135
|
+
>
|
|
136
|
+
<div className="group relative">
|
|
137
|
+
{/* Drag handle - only visible on hover */}
|
|
138
|
+
<ResizeHandle />
|
|
139
|
+
|
|
140
|
+
<textarea
|
|
141
|
+
value={inputMessage}
|
|
142
|
+
onChange={(e) => setInputMessage(e.target.value)}
|
|
143
|
+
onKeyDown={handleKeyPress}
|
|
144
|
+
onCompositionStart={handleCompositionStart}
|
|
145
|
+
onCompositionEnd={handleCompositionEnd}
|
|
146
|
+
placeholder={placeholder}
|
|
147
|
+
className="w-full resize-none bg-white py-2 pl-3 pr-12 text-sm placeholder-neutral-500 focus:outline-none dark:bg-black dark:text-white dark:placeholder-neutral-400"
|
|
148
|
+
style={{
|
|
149
|
+
height: `${textareaHeight}px`,
|
|
150
|
+
transition: "none", // Remove transition to make height follow mouse exactly
|
|
151
|
+
}}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
/>
|
|
154
|
+
<button
|
|
155
|
+
onClick={handleSendMessage}
|
|
156
|
+
disabled={disabled || !inputMessage.trim()}
|
|
157
|
+
className="absolute bottom-4 right-2 rounded-md
|
|
158
|
+
bg-indigo-600 p-1.5 text-white transition-colors
|
|
159
|
+
hover:bg-indigo-700 disabled:bg-neutral-300 disabled:text-neutral-500 dark:disabled:bg-neutral-700 dark:disabled:text-neutral-400"
|
|
160
|
+
aria-label="Send message"
|
|
161
|
+
>
|
|
162
|
+
<PaperAirplaneIcon className="h-4 w-4" />
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</DndContext>
|
|
166
|
+
<p className="mt-0.5 text-xs text-neutral-500">
|
|
167
|
+
按 Enter 发送,Shift+Enter 换行
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export default ChatInput;
|