@langgraph-js/ui 2.1.3 → 2.2.1

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.
@@ -1,34 +1,54 @@
1
1
  import React, { useState, useCallback } from "react";
2
2
  import { TmpFilesClient } from "../FileUpload";
3
+ import { File, UploadCloudIcon } from "lucide-react";
4
+
5
+ export type SupportedFileType = "image" | "video" | "audio" | "other";
6
+
7
+ const getFileType = (file: File): SupportedFileType => {
8
+ if (file.type.startsWith("image/")) return "image";
9
+ if (file.type === "video/mp4") return "video";
10
+ if (file.type.startsWith("audio/")) return "audio";
11
+ return "other";
12
+ };
13
+
14
+ interface UploadedFile {
15
+ file: File;
16
+ url: string;
17
+ type: SupportedFileType;
18
+ }
3
19
 
4
20
  interface FileListProps {
5
- onFileUploaded: (url: string) => void;
21
+ onFileUploaded: (url: string, type: SupportedFileType) => void;
22
+ onFileRemoved: (url: string, type: SupportedFileType) => void;
6
23
  }
7
24
 
8
- const FileList: React.FC<FileListProps> = ({ onFileUploaded }) => {
9
- const [files, setFiles] = useState<File[]>([]);
25
+ const FileList: React.FC<FileListProps> = ({ onFileUploaded, onFileRemoved }) => {
26
+ const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
10
27
  const client = new TmpFilesClient();
11
28
  const MAX_FILES = 3;
12
29
 
13
30
  const handleFileChange = useCallback(
14
31
  async (event: React.ChangeEvent<HTMLInputElement>) => {
15
32
  const selectedFiles = Array.from(event.target.files || []);
16
- const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/"));
33
+ const mediaFiles = selectedFiles;
17
34
 
18
35
  // 检查是否超过最大数量限制
19
- if (files.length + imageFiles.length > MAX_FILES) {
20
- alert(`最多只能上传${MAX_FILES}张图片`);
36
+ if (uploadedFiles.length + mediaFiles.length > MAX_FILES) {
37
+ alert(`最多只能上传${MAX_FILES}个文件`);
21
38
  event.target.value = "";
22
39
  return;
23
40
  }
24
41
 
25
- setFiles((prev) => [...prev, ...imageFiles]);
26
-
27
- for (const file of imageFiles) {
42
+ for (const file of mediaFiles) {
28
43
  try {
29
44
  const result = await client.upload(file);
30
45
  if (result.data?.url) {
31
- onFileUploaded(result.data.url);
46
+ const fileType = getFileType(file);
47
+ if (fileType) {
48
+ const uploadedFile = { file, url: result.data.url, type: fileType };
49
+ setUploadedFiles((prev) => [...prev, uploadedFile]);
50
+ onFileUploaded(result.data.url, fileType);
51
+ }
32
52
  }
33
53
  } catch (error) {
34
54
  console.error("Upload failed:", error);
@@ -37,38 +57,62 @@ const FileList: React.FC<FileListProps> = ({ onFileUploaded }) => {
37
57
 
38
58
  event.target.value = "";
39
59
  },
40
- [onFileUploaded, files.length]
60
+ [onFileUploaded, uploadedFiles.length]
41
61
  );
42
62
 
43
- const removeFile = useCallback((index: number) => {
44
- setFiles((prev) => prev.filter((_, i) => i !== index));
45
- }, []);
63
+ const removeFile = useCallback(
64
+ (index: number) => {
65
+ const fileToRemove = uploadedFiles[index];
66
+ if (fileToRemove) {
67
+ setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
68
+ onFileRemoved(fileToRemove.url, fileToRemove.type);
69
+ }
70
+ },
71
+ [uploadedFiles, onFileRemoved]
72
+ );
46
73
 
47
74
  return (
48
- <div className="flex gap-2 rounded-lg flex-1">
49
- {files.length < MAX_FILES && (
75
+ <div className="flex gap-2 flex-1">
76
+ {uploadedFiles.length < MAX_FILES && (
50
77
  <label
51
- className={`inline-flex items-center justify-center w-20 h-20 text-gray-500 bg-gray-100 rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-200 ${files.length === 0 ? "w-8 h-8" : ""}`}
78
+ className={`inline-flex items-center justify-center text-gray-700 bg-white border border-gray-200 rounded-xl cursor-pointer transition-colors hover:bg-gray-100 hover:border-gray-300 ${uploadedFiles.length === 0 ? "w-10 h-10" : "w-20 h-20"}`}
52
79
  >
53
- <svg viewBox="0 0 24 24" width={files.length === 0 ? "20" : "32"} height={files.length === 0 ? "20" : "32"} fill="currentColor">
54
- <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z" />
55
- <path d="M20 4h-3.17l-1.24-1.35A1.99 1.99 0 0 0 14.12 2H9.88c-.56 0-1.1.24-1.48.65L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 13c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z" />
56
- </svg>
57
- <input type="file" accept="image/*" multiple onChange={handleFileChange} className="hidden" />
80
+ <UploadCloudIcon size={uploadedFiles.length === 0 ? 20 : 32} />
81
+ <input type="file" accept="*" multiple onChange={handleFileChange} className="hidden" />
58
82
  </label>
59
83
  )}
60
84
  <div className="flex flex-wrap gap-2">
61
- {files.map((file, index) => (
62
- <div key={index} className="relative w-20 h-20 rounded-lg overflow-hidden">
63
- <img src={URL.createObjectURL(file)} alt={file.name} className="w-full h-full object-cover border border-gray-200" />
64
- <button
65
- className="absolute top-0.5 right-0.5 w-5 h-5 bg-black/50 text-white rounded-full flex items-center justify-center text-base leading-none hover:bg-black/70 transition-colors"
66
- onClick={() => removeFile(index)}
67
- >
68
- ×
69
- </button>
70
- </div>
71
- ))}
85
+ {uploadedFiles.map((uploadedFile, index) => {
86
+ const { file, type: fileType, url } = uploadedFile;
87
+
88
+ return (
89
+ <div key={index} className="relative w-20 h-20 rounded-xl overflow-hidden border border-gray-200">
90
+ {fileType === "image" ? (
91
+ <img src={URL.createObjectURL(file)} alt={file.name} className="w-full h-full object-cover" />
92
+ ) : fileType === "video" ? (
93
+ <video src={URL.createObjectURL(file)} className="w-full h-full object-cover" />
94
+ ) : fileType === "audio" ? (
95
+ <div className="w-full h-full bg-gray-100 flex items-center justify-center">
96
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" className="text-gray-500">
97
+ <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
98
+ </svg>
99
+ </div>
100
+ ) : fileType === "other" ? (
101
+ <div className="w-full h-full bg-gray-100 flex items-center justify-center">
102
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" className="text-gray-500">
103
+ <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
104
+ </svg>
105
+ </div>
106
+ ) : null}
107
+ <button
108
+ className="absolute top-1 right-1 w-5 h-5 bg-red-500/90 text-white rounded-full flex items-center justify-center text-sm leading-none hover:bg-red-600 transition-colors backdrop-blur-sm"
109
+ onClick={() => removeFile(index)}
110
+ >
111
+ ×
112
+ </button>
113
+ </div>
114
+ );
115
+ })}
72
116
  </div>
73
117
  </div>
74
118
  );
@@ -1,85 +1,81 @@
1
1
  import React from "react";
2
2
  import { useChat } from "../context/ChatContext";
3
- import { getHistoryContent } from "@langgraph-js/sdk";
3
+ import { formatTime, getHistoryContent } from "@langgraph-js/sdk";
4
4
  import { RefreshCw, X, RotateCcw, Trash2 } from "lucide-react";
5
5
 
6
6
  interface HistoryListProps {
7
7
  onClose: () => void;
8
- formatTime: (date: Date) => string;
9
8
  }
10
9
 
11
- const HistoryList: React.FC<HistoryListProps> = ({ onClose, formatTime }) => {
10
+ const HistoryList: React.FC<HistoryListProps> = ({ onClose }) => {
12
11
  const { historyList, currentChatId, refreshHistoryList, createNewChat, deleteHistoryChat, toHistoryChat } = useChat();
13
12
  return (
14
- <div className=" bg-white rounded-lg shadow-md h-full flex flex-col border-r">
15
- <div className="p-4 border-b border-gray-200 flex justify-between items-center h-16">
13
+ <section className="bg-white h-full flex flex-col rounded-2xl shadow-lg shadow-gray-200">
14
+ <div className="px-5 flex justify-between items-center py-6">
16
15
  <div className="flex items-center gap-3">
17
- <h3 className="m-0 text-lg text-gray-800">历史记录</h3>
18
- <button
19
- className="p-1.5 text-base rounded bg-blue-100 hover:bg-blue-200 transition-all duration-200 flex items-center justify-center hover:scale-110 group"
20
- onClick={refreshHistoryList}
21
- title="刷新列表"
22
- >
23
- <RefreshCw className="w-4 h-4 text-blue-600 group-hover:text-blue-700 group-hover:rotate-180 transition-all duration-300" />
24
- </button>
16
+ <h3 className="m-0 text-base font-semibold text-gray-700">历史记录</h3>
25
17
  </div>
26
- <button
27
- className="p-1.5 text-base rounded bg-red-100 hover:bg-red-200 transition-all duration-200 flex items-center justify-center hover:scale-110 group"
28
- onClick={onClose}
29
- title="关闭"
30
- >
31
- <X className="w-4 h-4 text-red-600 group-hover:text-red-700" />
18
+ <button className="p-2 rounded-lg bg-gray-100 hover:bg-gray-300 transition-colors flex items-center justify-center group" onClick={refreshHistoryList} title="刷新列表">
19
+ <RefreshCw className="w-4 h-4 text-gray-600 group-hover:rotate-180 transition-transform duration-300" />
20
+ </button>
21
+ <button className="p-2 rounded-lg bg-gray-100 hover:bg-gray-300 transition-colors flex items-center justify-center group" onClick={onClose} title="关闭">
22
+ <X className="w-4 h-4 text-red-500" />
32
23
  </button>
33
24
  </div>
34
- <div className="flex-1 overflow-y-auto p-4">
25
+ <div className="flex-1 overflow-y-auto px-4 py-2">
35
26
  <div
36
- className="flex flex-col gap-3 cursor-pointer"
27
+ className="cursor-pointer mb-3"
37
28
  onClick={() => {
38
29
  createNewChat();
39
30
  }}
40
31
  >
41
- <div className="flex justify-between items-center p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors duration-200">
42
- <div className="text-sm text-gray-800 truncate">New Chat</div>
32
+ <div className="flex justify-between items-center px-4 py-3 rounded-xl bg-blue-50/80 hover:bg-blue-200/80 transition-colors">
33
+ <div className="text-sm text-gray-700 font-medium truncate">New Chat</div>
43
34
  </div>
44
35
  </div>
45
36
  {historyList.length === 0 ? (
46
- <div className="text-center text-gray-500 py-8">暂无历史记录</div>
37
+ <div className="text-center text-gray-400 py-10 text-sm">暂无历史记录</div>
47
38
  ) : (
48
- <div className="flex flex-col gap-3 mt-3">
39
+ <div className="flex flex-col gap-2">
49
40
  {historyList
50
41
  .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
51
42
  .map((thread) => (
52
43
  <div
53
- className={`flex justify-between items-center p-3 rounded-lg transition-colors duration-200 ${
54
- thread.thread_id === currentChatId ? "bg-blue-50 border border-blue-200" : "bg-gray-50 hover:bg-gray-100"
44
+ className={`flex justify-between items-center px-4 py-3 rounded-xl transition-colors ${
45
+ thread.thread_id === currentChatId ? "bg-blue-50/80" : "bg-gray-50/60 hover:bg-gray-200/80"
55
46
  }`}
56
47
  key={thread.thread_id}
57
48
  >
58
- <div className="flex-1 min-w-0 mr-2">
59
- <div className="text-sm text-gray-800 mb-1 truncate max-w-[180px]">{getHistoryContent(thread)}</div>
60
- <div className="flex gap-3 text-xs text-gray-500">
49
+ <div
50
+ className="flex-1 min-w-0 mr-2"
51
+ onClick={() => {
52
+ toHistoryChat(thread);
53
+ }}
54
+ >
55
+ <div className="text-sm text-gray-700 mb-1 truncate max-w-[180px]">{getHistoryContent(thread)}</div>
56
+ <div className="flex gap-3 text-xs text-gray-400">
61
57
  <span className="truncate max-w-[100px]">{formatTime(new Date(thread.created_at))}</span>
62
58
  <span className="truncate max-w-[60px]">{thread.status}</span>
63
59
  </div>
64
60
  </div>
65
- <div className="flex gap-2 shrink-0">
61
+ <div className="flex gap-1.5 shrink-0">
66
62
  <button
67
- className="p-1.5 text-base rounded bg-green-100 hover:bg-green-200 transition-all duration-200 flex items-center justify-center hover:scale-110 group"
63
+ className="p-2 rounded-lg bg-white/60 hover:bg-gray-200 transition-colors flex items-center justify-center group"
68
64
  onClick={() => {
69
65
  toHistoryChat(thread);
70
66
  }}
71
67
  title="恢复对话"
72
68
  >
73
- <RotateCcw className="w-4 h-4 text-green-600 group-hover:text-green-700 group-hover:-rotate-180 transition-all duration-300" />
69
+ <RotateCcw className="w-3.5 h-3.5 text-gray-600 group-hover:-rotate-180 transition-transform duration-300" />
74
70
  </button>
75
71
  <button
76
- className="p-1.5 text-base rounded bg-red-100 hover:bg-red-200 transition-all duration-200 flex items-center justify-center hover:scale-110 group"
72
+ className="p-2 rounded-lg bg-white/60 hover:bg-gray-200 transition-colors flex items-center justify-center group"
77
73
  onClick={async () => {
78
74
  await deleteHistoryChat(thread);
79
75
  }}
80
76
  title="删除对话"
81
77
  >
82
- <Trash2 className="w-4 h-4 text-red-600 group-hover:text-red-700" />
78
+ <Trash2 className="w-3.5 h-3.5 text-red-500" />
83
79
  </button>
84
80
  </div>
85
81
  </div>
@@ -87,7 +83,7 @@ const HistoryList: React.FC<HistoryListProps> = ({ onClose, formatTime }) => {
87
83
  </div>
88
84
  )}
89
85
  </div>
90
- </div>
86
+ </section>
91
87
  );
92
88
  };
93
89
 
@@ -10,12 +10,14 @@ interface JsonEditorPopupProps {
10
10
  initialJson: object;
11
11
  onClose: () => void;
12
12
  onSave: (jsonData: object) => void;
13
+ title: string;
14
+ description: string;
13
15
  }
14
16
 
15
17
  const LOCAL_STORAGE_KEY = "json-editor-presets";
16
18
  const ACTIVE_TAB_STORAGE_KEY = "json-editor-active-tab";
17
19
 
18
- const JsonEditorPopup: React.FC<JsonEditorPopupProps> = ({ isOpen, initialJson, onClose, onSave }) => {
20
+ const JsonEditorPopup: React.FC<JsonEditorPopupProps> = ({ isOpen, initialJson, onClose, onSave, title, description }) => {
19
21
  const [presets, setPresets] = useState<JsonPreset[]>([]);
20
22
  const [activeTab, setActiveTab] = useState(0);
21
23
  const [jsonString, setJsonString] = useState("");
@@ -125,18 +127,19 @@ const JsonEditorPopup: React.FC<JsonEditorPopupProps> = ({ isOpen, initialJson,
125
127
  };
126
128
 
127
129
  return (
128
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
129
- <div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 flex flex-col" style={{ height: "80vh" }}>
130
- <div className="p-6 border-b border-gray-200">
131
- <h2 className="text-xl font-semibold text-gray-900">编辑 Extra Parameters</h2>
130
+ <div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50">
131
+ <div className="bg-white rounded-2xl w-full max-w-2xl mx-4 flex flex-col overflow-hidden" style={{ height: "80vh" }}>
132
+ <div className="px-6 py-5 bg-white/80 backdrop-blur-sm">
133
+ <h2 className="text-lg font-semibold text-gray-800">{title}</h2>
132
134
  </div>
133
- <div className="flex-grow p-6 overflow-y-auto">
134
- <div className="flex items-center border-b border-gray-200 mb-4">
135
+ <div className="px-6 py-4 text-sm text-gray-500">{description}</div>
136
+ <nav className="flex-grow px-6 py-4 overflow-y-auto">
137
+ <div className="flex items-center mb-4 gap-2">
135
138
  {presets.map((preset, index) => (
136
139
  <div
137
140
  key={index}
138
- className={`flex items-center px-4 py-2 border-b-2 cursor-pointer text-sm font-medium ${
139
- activeTab === index ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
141
+ className={`flex items-center px-4 py-2 rounded-xl cursor-pointer text-sm font-medium transition-colors ${
142
+ activeTab === index ? "bg-blue-50 text-blue-600" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
140
143
  }`}
141
144
  onDoubleClick={() => {
142
145
  setEditingTab(index);
@@ -162,13 +165,13 @@ const JsonEditorPopup: React.FC<JsonEditorPopupProps> = ({ isOpen, initialJson,
162
165
  e.stopPropagation();
163
166
  handleDeleteTab(index);
164
167
  }}
165
- className="ml-2 text-gray-400 hover:text-gray-600 w-4 h-4 flex items-center justify-center"
168
+ className="ml-2 text-gray-400 hover:text-gray-600 w-4 h-4 flex items-center justify-center transition-colors"
166
169
  >
167
170
  &times;
168
171
  </button>
169
172
  </div>
170
173
  ))}
171
- <button onClick={handleAddTab} className="ml-2 px-3 py-1 text-sm text-gray-500 hover:text-gray-700">
174
+ <button onClick={handleAddTab} className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors">
172
175
  +
173
176
  </button>
174
177
  </div>
@@ -179,21 +182,15 @@ const JsonEditorPopup: React.FC<JsonEditorPopupProps> = ({ isOpen, initialJson,
179
182
  setJsonString(e.target.value);
180
183
  setError(null); // Clear error on edit
181
184
  }}
182
- className="w-full h-full p-3 border border-gray-300 rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
185
+ className="w-full h-full px-4 py-3 bg-gray-50 rounded-xl font-mono text-sm focus:outline-none focus:bg-gray-100 transition-colors"
183
186
  />
184
187
  {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
185
- </div>
186
- <div className="flex justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg">
187
- <button
188
- onClick={onClose}
189
- className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
190
- >
188
+ </nav>
189
+ <div className="flex justify-end gap-3 px-6 py-5 bg-gray-50/50">
190
+ <button onClick={onClose} className="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white rounded-xl hover:bg-gray-100 focus:outline-none transition-colors">
191
191
  取消
192
192
  </button>
193
- <button
194
- onClick={handleSave}
195
- className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
196
- >
193
+ <button onClick={handleSave} className="px-5 py-2.5 text-sm font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 focus:outline-none transition-colors">
197
194
  保存并使用
198
195
  </button>
199
196
  </div>
@@ -11,14 +11,12 @@ interface MessageAIProps {
11
11
 
12
12
  const MessageAI: React.FC<MessageAIProps> = ({ message }) => {
13
13
  return (
14
- <div className="flex flex-col w-[80%] bg-white rounded-lg shadow-sm border border-gray-200">
15
- <div className="flex flex-col p-4">
16
- <div className="text-sm font-medium text-gray-700 mb-2">{message.name}</div>
17
- <div className="markdown-body max-w-none">
18
- <Markdown remarkPlugins={[remarkGfm]}>{getMessageContent(message.content)}</Markdown>
19
- </div>
20
- <UsageMetadata response_metadata={message.response_metadata as any} usage_metadata={message.usage_metadata || {}} spend_time={message.spend_time} id={message.id} />
14
+ <div className="flex flex-col w-[80%] bg-white rounded-2xl px-5 py-4">
15
+ <div className="text-xs font-medium text-gray-500 mb-3">{message.name}</div>
16
+ <div className="markdown-body max-w-none">
17
+ <Markdown remarkPlugins={[remarkGfm]}>{getMessageContent(message.content)}</Markdown>
21
18
  </div>
19
+ <UsageMetadata response_metadata={message.response_metadata as any} usage_metadata={message.usage_metadata || {}} spend_time={message.spend_time} id={message.id} />
22
20
  </div>
23
21
  );
24
22
  };
@@ -4,6 +4,7 @@ import MessageAI from "./MessageAI";
4
4
  import MessageTool from "./MessageTool";
5
5
  import { formatTokens, getMessageContent, LangGraphClient, RenderMessage } from "@langgraph-js/sdk";
6
6
  import { JSONViewer } from "./JSONViewer";
7
+ import { useChat } from "../context/ChatContext";
7
8
 
8
9
  interface MessageState {
9
10
  showDetail: boolean;
@@ -22,6 +23,7 @@ export const MessagesBox = ({
22
23
  toggleToolCollapse: (id: string) => void;
23
24
  client: LangGraphClient;
24
25
  }) => {
26
+ const chat = useChat();
25
27
  // 使用 Map 来管理每个消息的状态
26
28
  const [messageStates, setMessageStates] = useState<Map<string, MessageState>>(new Map());
27
29
  const messageRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
@@ -109,7 +111,7 @@ export const MessagesBox = ({
109
111
  }, [messageStates, handleCloseContextMenu]);
110
112
 
111
113
  return (
112
- <div className="flex flex-col gap-4 w-full">
114
+ <div className="flex flex-col gap-5 w-full">
113
115
  {renderMessages.map((message, index) => {
114
116
  const messageId = message.unique_id || `message-${index}`;
115
117
  const messageState = messageStates.get(messageId) || {
@@ -145,16 +147,34 @@ export const MessagesBox = ({
145
147
  {messageState.showDetail && <JSONViewer data={message} />}
146
148
  {messageState.showContextMenu && (
147
149
  <div
148
- className="fixed bg-white border border-gray-200 rounded shadow-lg z-50 py-1 min-w-[150px]"
150
+ className="fixed bg-white/95 backdrop-blur-sm rounded-xl z-50 py-2 min-w-[150px] overflow-hidden"
149
151
  style={{ left: messageState.contextMenuPosition.x, top: messageState.contextMenuPosition.y }}
150
152
  onClick={(e) => e.stopPropagation()}
151
153
  >
152
- <button className="w-full bg-white px-3 py-2 text-left hover:bg-gray-50 text-sm" onClick={() => handleCopyMessage(messageId, message.content)}>
154
+ <button
155
+ className="w-full bg-transparent px-4 py-2.5 text-left hover:bg-gray-100/80 text-sm text-gray-700 transition-colors"
156
+ onClick={() => handleCopyMessage(messageId, message.content)}
157
+ >
153
158
  复制消息内容
154
159
  </button>
155
- <button className="w-full bg-white px-3 py-2 text-left hover:bg-gray-50 text-sm" onClick={() => handleToggleDetail(messageId)}>
160
+ <button
161
+ className="w-full bg-transparent px-4 py-2.5 text-left hover:bg-gray-100/80 text-sm text-gray-700 transition-colors"
162
+ onClick={() => handleToggleDetail(messageId)}
163
+ >
156
164
  {messageState.showDetail ? "隐藏详情" : "显示详情"}
157
165
  </button>
166
+ <button
167
+ className="w-full bg-transparent px-4 py-2.5 text-left hover:bg-gray-100/80 text-sm text-gray-700 transition-colors"
168
+ onClick={() => chat.revertChatTo(messageId)}
169
+ >
170
+ 回滚到消息
171
+ </button>
172
+ <button
173
+ className="w-full bg-transparent px-4 py-2.5 text-left hover:bg-gray-100/80 text-sm text-gray-700 transition-colors"
174
+ onClick={() => chat.revertChatTo(messageId, true)}
175
+ >
176
+ 重发消息
177
+ </button>
158
178
  </div>
159
179
  )}
160
180
  </div>
@@ -4,6 +4,50 @@ interface MessageHumanProps {
4
4
  content: string | any[];
5
5
  }
6
6
 
7
+ // 解析文本内容中的文件标签,只有完全匹配时才返回解析结果
8
+ const parseFileTags = (text: string): any[] => {
9
+ // 检查文本是否完全由文件标签组成(去除空白字符后)
10
+ const trimmedText = text.trim();
11
+
12
+ // 如果文本完全匹配单个文件标签格式
13
+ const singleMatch = trimmedText.match(/^<file type="([^"]+)" url="([^"]+)"><\/file>$/);
14
+ if (singleMatch) {
15
+ const [, fileType, url] = singleMatch;
16
+ if (fileType === "image") {
17
+ return [
18
+ {
19
+ type: "image_url",
20
+ image_url: { url },
21
+ },
22
+ ];
23
+ } else if (fileType === "video") {
24
+ return [
25
+ {
26
+ type: "video_url",
27
+ video_url: { url },
28
+ },
29
+ ];
30
+ } else if (fileType === "audio") {
31
+ return [
32
+ {
33
+ type: "audio_url",
34
+ audio_url: { url },
35
+ },
36
+ ];
37
+ } else if (fileType === "other") {
38
+ return [
39
+ {
40
+ type: "file_url",
41
+ file_url: { url },
42
+ },
43
+ ];
44
+ }
45
+ }
46
+
47
+ // 如果不完全匹配,返回空数组
48
+ return [];
49
+ };
50
+
7
51
  const MessageHuman: React.FC<MessageHumanProps> = ({ content }) => {
8
52
  const renderContent = () => {
9
53
  if (typeof content === "string") {
@@ -11,36 +55,69 @@ const MessageHuman: React.FC<MessageHumanProps> = ({ content }) => {
11
55
  }
12
56
 
13
57
  if (Array.isArray(content)) {
14
- return content.map((item, index) => {
15
- switch (item.type) {
16
- case "text":
17
- return (
18
- <div key={index} className="text-white">
19
- {item.text}
20
- </div>
21
- );
22
- case "image_url":
23
- return (
24
- <div key={index} className="mt-2">
25
- <img src={item.image_url.url} alt="用户上传的图片" className="max-w-[200px] rounded" />
26
- </div>
27
- );
28
- case "audio":
29
- return (
30
- <div key={index} className="mt-2">
31
- <audio controls src={item.audio_url} className="w-full">
32
- 您的浏览器不支持音频播放
33
- </audio>
34
- </div>
35
- );
36
- default:
37
- return (
38
- <div key={index} className="text-white whitespace-pre-wrap">
39
- {JSON.stringify(item)}
40
- </div>
41
- );
42
- }
43
- });
58
+ return content
59
+ .flatMap((item) => {
60
+ if (item.type === "text" && typeof item.text === "string") {
61
+ // 检查文本是否包含文件标签
62
+ const parsedParts = parseFileTags(item.text);
63
+ if (parsedParts.length > 0) {
64
+ return parsedParts;
65
+ } else {
66
+ return [item];
67
+ }
68
+ } else {
69
+ return [item];
70
+ }
71
+ })
72
+ .map((item, index) => {
73
+ switch (item.type) {
74
+ case "text":
75
+ return (
76
+ <div key={index} className="text-white whitespace-pre-wrap">
77
+ {item.text}
78
+ </div>
79
+ );
80
+ case "image_url":
81
+ return (
82
+ <div key={index} className="mt-2">
83
+ <img src={item.image_url.url} alt={item.image_url.url} className="max-w-[200px] rounded" />
84
+ </div>
85
+ );
86
+ case "video_url":
87
+ return (
88
+ <div key={index} className="mt-2">
89
+ <video controls src={item.video_url.url} className="max-w-[200px] rounded">
90
+ 您的浏览器不支持视频播放
91
+ </video>
92
+ </div>
93
+ );
94
+ case "audio_url":
95
+ return (
96
+ <div key={index} className="mt-2">
97
+ <audio controls src={item.audio_url.url} className="w-full">
98
+ 您的浏览器不支持音频播放
99
+ </audio>
100
+ </div>
101
+ );
102
+ case "file_url":
103
+ return (
104
+ <div key={index} className="mt-2 p-2 bg-gray-100 rounded flex items-center gap-2">
105
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" className="text-gray-500">
106
+ <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
107
+ </svg>
108
+ <a href={item.file_url.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline text-sm">
109
+ 下载文件
110
+ </a>
111
+ </div>
112
+ );
113
+ default:
114
+ return (
115
+ <div key={index} className="text-white whitespace-pre-wrap">
116
+ {JSON.stringify(item)}
117
+ </div>
118
+ );
119
+ }
120
+ });
44
121
  }
45
122
 
46
123
  return <div className="text-white whitespace-pre-wrap">{JSON.stringify(content)}</div>;
@@ -48,9 +125,7 @@ const MessageHuman: React.FC<MessageHumanProps> = ({ content }) => {
48
125
 
49
126
  return (
50
127
  <div className="flex flex-row w-full justify-end">
51
- <div className="flex flex-col w-fit bg-blue-500 rounded-lg text-white border border-blue-100">
52
- <div className="flex flex-col p-4 ">{renderContent()}</div>
53
- </div>
128
+ <div className="flex flex-col w-fit bg-blue-500/90 rounded-2xl text-white px-4 py-3 max-w-[80%]">{renderContent()}</div>
54
129
  </div>
55
130
  );
56
131
  };