@sciol/xyzen 0.1.2 → 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.
@@ -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,7 @@
1
+ export default function XyzenNodes() {
2
+ return (
3
+ <div className="flex h-full items-center justify-center">
4
+ <p className="text-neutral-500">节点视图正在建设中...</p>
5
+ </div>
6
+ );
7
+ }
@@ -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;