@langgraph-js/ui 2.1.3 → 2.2.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/dist/assets/index-DTkiHaQ2.js +275 -0
- package/dist/assets/index-nM8bt1K3.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/chat/Chat.tsx +135 -54
- package/src/chat/components/ErrorBoundary.tsx +20 -5
- package/src/chat/components/FileList.tsx +53 -21
- package/src/chat/components/HistoryList.tsx +32 -36
- package/src/chat/components/JsonEditorPopup.tsx +19 -22
- package/src/chat/components/MessageAI.tsx +5 -7
- package/src/chat/components/MessageBox.tsx +24 -4
- package/src/chat/components/MessageHuman.tsx +108 -33
- package/src/chat/components/MessageTool.tsx +11 -11
- package/src/chat/components/UsageMetadata.tsx +53 -22
- package/src/chat/context/ChatContext.tsx +18 -4
- package/src/chat/index.css +19 -0
- package/src/chat/store/index.ts +2 -1
- package/src/index.ts +1 -0
- package/src/settings/LoginSettings.tsx +234 -0
- package/src/settings/SettingFormBase.tsx +42 -0
- package/src/settings/SettingPanel.tsx +97 -0
- package/src/sonner/Toaster.tsx +117 -0
- package/src/sonner/index.ts +3 -0
- package/src/sonner/toast.ts +50 -0
- package/src/sonner/types.ts +21 -0
- package/test/App.tsx +2 -4
- package/tsconfig.json +1 -1
- package/vite.config.ts +5 -5
- package/dist/assets/index-DzEw-fFg.css +0 -1
- package/dist/assets/index-ULSvzqky.js +0 -268
- package/src/login/Login.tsx +0 -200
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import React, { useState, useCallback } from "react";
|
|
2
2
|
import { TmpFilesClient } from "../FileUpload";
|
|
3
3
|
|
|
4
|
+
export type SupportedFileType = "image" | "video" | "audio" | "other";
|
|
5
|
+
|
|
6
|
+
const getFileType = (file: File): SupportedFileType => {
|
|
7
|
+
if (file.type.startsWith("image/")) return "image";
|
|
8
|
+
if (file.type === "video/mp4") return "video";
|
|
9
|
+
if (file.type.startsWith("audio/")) return "audio";
|
|
10
|
+
return "other";
|
|
11
|
+
};
|
|
12
|
+
|
|
4
13
|
interface FileListProps {
|
|
5
|
-
onFileUploaded: (url: string) => void;
|
|
14
|
+
onFileUploaded: (url: string, type: SupportedFileType) => void;
|
|
6
15
|
}
|
|
7
16
|
|
|
8
17
|
const FileList: React.FC<FileListProps> = ({ onFileUploaded }) => {
|
|
@@ -13,22 +22,25 @@ const FileList: React.FC<FileListProps> = ({ onFileUploaded }) => {
|
|
|
13
22
|
const handleFileChange = useCallback(
|
|
14
23
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
15
24
|
const selectedFiles = Array.from(event.target.files || []);
|
|
16
|
-
const
|
|
25
|
+
const mediaFiles = selectedFiles;
|
|
17
26
|
|
|
18
27
|
// 检查是否超过最大数量限制
|
|
19
|
-
if (files.length +
|
|
20
|
-
alert(`最多只能上传${MAX_FILES}
|
|
28
|
+
if (files.length + mediaFiles.length > MAX_FILES) {
|
|
29
|
+
alert(`最多只能上传${MAX_FILES}个文件`);
|
|
21
30
|
event.target.value = "";
|
|
22
31
|
return;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
setFiles((prev) => [...prev, ...
|
|
34
|
+
setFiles((prev) => [...prev, ...mediaFiles]);
|
|
26
35
|
|
|
27
|
-
for (const file of
|
|
36
|
+
for (const file of mediaFiles) {
|
|
28
37
|
try {
|
|
29
38
|
const result = await client.upload(file);
|
|
30
39
|
if (result.data?.url) {
|
|
31
|
-
|
|
40
|
+
const fileType = getFileType(file);
|
|
41
|
+
if (fileType) {
|
|
42
|
+
onFileUploaded(result.data.url, fileType);
|
|
43
|
+
}
|
|
32
44
|
}
|
|
33
45
|
} catch (error) {
|
|
34
46
|
console.error("Upload failed:", error);
|
|
@@ -45,30 +57,50 @@ const FileList: React.FC<FileListProps> = ({ onFileUploaded }) => {
|
|
|
45
57
|
}, []);
|
|
46
58
|
|
|
47
59
|
return (
|
|
48
|
-
<div className="flex gap-2
|
|
60
|
+
<div className="flex gap-2 flex-1">
|
|
49
61
|
{files.length < MAX_FILES && (
|
|
50
62
|
<label
|
|
51
|
-
className={`inline-flex items-center justify-center
|
|
63
|
+
className={`inline-flex items-center justify-center text-gray-400 bg-gray-50 rounded-xl cursor-pointer transition-colors hover:bg-gray-100 ${files.length === 0 ? "w-10 h-10" : "w-20 h-20"}`}
|
|
52
64
|
>
|
|
53
65
|
<svg viewBox="0 0 24 24" width={files.length === 0 ? "20" : "32"} height={files.length === 0 ? "20" : "32"} fill="currentColor">
|
|
54
66
|
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z" />
|
|
55
67
|
<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
68
|
</svg>
|
|
57
|
-
<input type="file" accept="
|
|
69
|
+
<input type="file" accept="*" multiple onChange={handleFileChange} className="hidden" />
|
|
58
70
|
</label>
|
|
59
71
|
)}
|
|
60
72
|
<div className="flex flex-wrap gap-2">
|
|
61
|
-
{files.map((file, index) =>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
{files.map((file, index) => {
|
|
74
|
+
const fileType = getFileType(file);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div key={index} className="relative w-20 h-20 rounded-xl overflow-hidden">
|
|
78
|
+
{fileType === "image" ? (
|
|
79
|
+
<img src={URL.createObjectURL(file)} alt={file.name} className="w-full h-full object-cover" />
|
|
80
|
+
) : fileType === "video" ? (
|
|
81
|
+
<video src={URL.createObjectURL(file)} className="w-full h-full object-cover" />
|
|
82
|
+
) : fileType === "audio" ? (
|
|
83
|
+
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
|
84
|
+
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" className="text-gray-500">
|
|
85
|
+
<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" />
|
|
86
|
+
</svg>
|
|
87
|
+
</div>
|
|
88
|
+
) : fileType === "other" ? (
|
|
89
|
+
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
|
90
|
+
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" className="text-gray-500">
|
|
91
|
+
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
|
|
92
|
+
</svg>
|
|
93
|
+
</div>
|
|
94
|
+
) : null}
|
|
95
|
+
<button
|
|
96
|
+
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"
|
|
97
|
+
onClick={() => removeFile(index)}
|
|
98
|
+
>
|
|
99
|
+
×
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
72
104
|
</div>
|
|
73
105
|
</div>
|
|
74
106
|
);
|
|
@@ -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
|
|
10
|
+
const HistoryList: React.FC<HistoryListProps> = ({ onClose }) => {
|
|
12
11
|
const { historyList, currentChatId, refreshHistoryList, createNewChat, deleteHistoryChat, toHistoryChat } = useChat();
|
|
13
12
|
return (
|
|
14
|
-
<
|
|
15
|
-
<div className="
|
|
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-
|
|
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="
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
25
|
+
<div className="flex-1 overflow-y-auto px-4 py-2">
|
|
35
26
|
<div
|
|
36
|
-
className="
|
|
27
|
+
className="cursor-pointer mb-3"
|
|
37
28
|
onClick={() => {
|
|
38
29
|
createNewChat();
|
|
39
30
|
}}
|
|
40
31
|
>
|
|
41
|
-
<div className="flex justify-between items-center
|
|
42
|
-
<div className="text-sm text-gray-
|
|
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-
|
|
37
|
+
<div className="text-center text-gray-400 py-10 text-sm">暂无历史记录</div>
|
|
47
38
|
) : (
|
|
48
|
-
<div className="flex flex-col gap-
|
|
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
|
|
54
|
-
thread.thread_id === currentChatId ? "bg-blue-50
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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-
|
|
61
|
+
<div className="flex gap-1.5 shrink-0">
|
|
66
62
|
<button
|
|
67
|
-
className="p-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
</
|
|
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/
|
|
129
|
-
<div className="bg-white rounded-
|
|
130
|
-
<div className="
|
|
131
|
-
<h2 className="text-
|
|
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="
|
|
134
|
-
|
|
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
|
|
139
|
-
activeTab === index ? "
|
|
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
|
×
|
|
168
171
|
</button>
|
|
169
172
|
</div>
|
|
170
173
|
))}
|
|
171
|
-
<button onClick={handleAddTab} className="
|
|
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
|
|
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
|
-
</
|
|
186
|
-
<div className="flex justify-end gap-3
|
|
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-
|
|
15
|
-
<div className="
|
|
16
|
-
|
|
17
|
-
<
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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-
|
|
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
|
};
|