@langgraph-js/ui 1.6.0 → 1.7.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/index.html CHANGED
@@ -12,8 +12,8 @@
12
12
  padding: 0;
13
13
  }
14
14
  </style>
15
- <script type="module" crossorigin src="/assets/index-CZ6k2dGe.js"></script>
16
- <link rel="stylesheet" crossorigin href="/assets/index-7vem5Peg.css">
15
+ <script type="module" crossorigin src="/assets/index-JlVlMqZ-.js"></script>
16
+ <link rel="stylesheet" crossorigin href="/assets/index-C0dczJ0v.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root" class="h-screen w-screen"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langgraph-js/ui",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
@@ -25,13 +25,14 @@
25
25
  "minisearch": "^7.1.2",
26
26
  "motion": "^12.16.0",
27
27
  "nanostores": "^1.0.1",
28
+ "prism-react-renderer": "^2.4.1",
28
29
  "react": "^19.0.0",
29
30
  "react-dom": "^19.0.0",
30
31
  "react-markdown": "^10.1.0",
31
32
  "remark-gfm": "^4.0.1",
32
33
  "unocss": "^66.1.3",
33
34
  "vite": "^6.2.0",
34
- "@langgraph-js/sdk": "1.7.6"
35
+ "@langgraph-js/sdk": "1.10.4"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@types/react": "^19.0.10",
package/src/chat/Chat.tsx CHANGED
@@ -14,6 +14,7 @@ import { History, Network, LogOut, FileJson, Code } from "lucide-react";
14
14
  import { ArtifactViewer } from "../artifacts/ArtifactViewer";
15
15
  import "github-markdown-css/github-markdown.css";
16
16
  import { ArtifactsProvider, useArtifacts } from "../artifacts/ArtifactsContext";
17
+ import "./index.css";
17
18
 
18
19
  const ChatMessages: React.FC = () => {
19
20
  const { renderMessages, loading, inChatError, client, collapsedTools, toggleToolCollapse, isFELocking } = useChat();
@@ -28,12 +29,11 @@ const ChatMessages: React.FC = () => {
28
29
  const scrollPosition = container.scrollTop + container.clientHeight;
29
30
  const scrollHeight = container.scrollHeight;
30
31
 
31
- // 当距离底部不超过容器高度的 30% 时,认为足够接近底部
32
- return scrollHeight - scrollPosition <= container.clientHeight * 0.3;
32
+ return scrollHeight - scrollPosition <= 50;
33
33
  };
34
34
 
35
35
  const scrollToBottom = () => {
36
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
36
+ messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
37
37
  };
38
38
 
39
39
  useEffect(() => {
@@ -66,7 +66,7 @@ const ChatMessages: React.FC = () => {
66
66
  };
67
67
 
68
68
  const ChatInput: React.FC = () => {
69
- const { userInput, setUserInput, loading, sendMessage, stopGeneration, currentAgent, setCurrentAgent, client } = useChat();
69
+ const { userInput, setUserInput, loading, sendMessage, stopGeneration, currentAgent, setCurrentAgent, client, currentChatId } = useChat();
70
70
  const { extraParams } = useExtraParams();
71
71
  const [imageUrls, setImageUrls] = useState<string[]>([]);
72
72
 
@@ -148,8 +148,9 @@ const ChatInput: React.FC = () => {
148
148
  {loading ? "中断" : "发送"}
149
149
  </button>
150
150
  </div>
151
- <div className="flex border-b border-gray-200 mt-4">
151
+ <div className="flex border-b border-gray-200 mt-4 gap-2 justify-between">
152
152
  <UsageMetadata usage_metadata={client?.tokenCounter || {}} />
153
+ <span className="text-sm text-gray-500">会话 ID: {currentChatId}</span>
153
154
  </div>
154
155
  </div>
155
156
  );
@@ -162,7 +163,7 @@ const Chat: React.FC = () => {
162
163
  const { showArtifact, setShowArtifact } = useArtifacts();
163
164
 
164
165
  return (
165
- <div className="flex h-full w-full overflow-hidden">
166
+ <div className="langgraph-chat-container flex h-full w-full overflow-hidden">
166
167
  {showHistory && <HistoryList onClose={() => toggleHistoryVisible()} formatTime={formatTime} />}
167
168
  <div className="flex-1 flex flex-col overflow-auto">
168
169
  <div className="flex items-center gap-2 p-4 border-b border-gray-200 justify-end h-16">
@@ -1,5 +1,10 @@
1
1
  import React, { useState, useEffect } from "react";
2
2
 
3
+ interface JsonPreset {
4
+ name: string;
5
+ data: object;
6
+ }
7
+
3
8
  interface JsonEditorPopupProps {
4
9
  isOpen: boolean;
5
10
  initialJson: object;
@@ -7,22 +12,102 @@ interface JsonEditorPopupProps {
7
12
  onSave: (jsonData: object) => void;
8
13
  }
9
14
 
15
+ const LOCAL_STORAGE_KEY = "json-editor-presets";
16
+ const ACTIVE_TAB_STORAGE_KEY = "json-editor-active-tab";
17
+
10
18
  const JsonEditorPopup: React.FC<JsonEditorPopupProps> = ({ isOpen, initialJson, onClose, onSave }) => {
19
+ const [presets, setPresets] = useState<JsonPreset[]>([]);
20
+ const [activeTab, setActiveTab] = useState(0);
11
21
  const [jsonString, setJsonString] = useState("");
12
22
  const [error, setError] = useState<string | null>(null);
23
+ const [editingTab, setEditingTab] = useState<number | null>(null);
24
+ const [editingName, setEditingName] = useState("");
25
+
26
+ useEffect(() => {
27
+ if (isOpen) {
28
+ let storedData: JsonPreset[] = [];
29
+ try {
30
+ const item = localStorage.getItem(LOCAL_STORAGE_KEY);
31
+ storedData = item ? JSON.parse(item) : [];
32
+ } catch (e) {
33
+ console.error("Failed to parse presets from localStorage", e);
34
+ storedData = [];
35
+ }
36
+
37
+ if (storedData.length === 0) {
38
+ storedData = [{ name: "Default", data: initialJson }];
39
+ }
40
+ setPresets(storedData);
41
+
42
+ let activeTabIndex = 0;
43
+ try {
44
+ const storedIndex = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
45
+ if (storedIndex) {
46
+ const parsedIndex = parseInt(storedIndex, 10);
47
+ if (parsedIndex >= 0 && parsedIndex < storedData.length) {
48
+ activeTabIndex = parsedIndex;
49
+ }
50
+ }
51
+ } catch (e) {
52
+ console.error("Failed to parse active tab from localStorage", e);
53
+ }
54
+ setActiveTab(activeTabIndex);
55
+ setError(null);
56
+ }
57
+ }, [isOpen, initialJson]);
13
58
 
14
59
  useEffect(() => {
15
- setJsonString(JSON.stringify(initialJson, null, 2));
16
- setError(null); // Reset error when initialJson changes or popup opens
17
- }, [initialJson, isOpen]);
60
+ if (isOpen && presets.length > 0 && presets[activeTab]) {
61
+ setJsonString(JSON.stringify(presets[activeTab].data, null, 2));
62
+ setError(null);
63
+ }
64
+ }, [activeTab, presets, isOpen]);
65
+
66
+ useEffect(() => {
67
+ if (isOpen) {
68
+ localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, activeTab.toString());
69
+ }
70
+ }, [activeTab, isOpen]);
18
71
 
19
72
  if (!isOpen) {
20
73
  return null;
21
74
  }
22
75
 
76
+ const updatePresetsInStorage = (newPresets: JsonPreset[]) => {
77
+ setPresets(newPresets);
78
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newPresets));
79
+ };
80
+
81
+ const handleAddTab = () => {
82
+ const newPreset: JsonPreset = { name: `Preset ${presets.length + 1}`, data: {} };
83
+ const newPresets = [...presets, newPreset];
84
+ updatePresetsInStorage(newPresets);
85
+ setActiveTab(newPresets.length - 1);
86
+ };
87
+
88
+ const handleDeleteTab = (indexToDelete: number) => {
89
+ let newPresets = presets.filter((_, index) => index !== indexToDelete);
90
+ if (newPresets.length === 0) {
91
+ newPresets = [{ name: "Default", data: initialJson }];
92
+ }
93
+ updatePresetsInStorage(newPresets);
94
+ if (activeTab >= indexToDelete && activeTab > 0) {
95
+ setActiveTab(activeTab - 1);
96
+ } else if (activeTab >= newPresets.length) {
97
+ setActiveTab(newPresets.length - 1);
98
+ }
99
+ };
100
+
23
101
  const handleSave = () => {
24
102
  try {
25
103
  const parsedJson = JSON.parse(jsonString);
104
+ const updatedPresets = presets.map((preset, index) => {
105
+ if (index === activeTab) {
106
+ return { ...preset, data: parsedJson };
107
+ }
108
+ return preset;
109
+ });
110
+ updatePresetsInStorage(updatedPresets);
26
111
  onSave(parsedJson);
27
112
  onClose();
28
113
  } catch (e) {
@@ -31,35 +116,86 @@ const JsonEditorPopup: React.FC<JsonEditorPopupProps> = ({ isOpen, initialJson,
31
116
  }
32
117
  };
33
118
 
119
+ const handleRename = (index: number) => {
120
+ if (editingName.trim() !== "") {
121
+ const newPresets = presets.map((p, i) => (i === index ? { ...p, name: editingName.trim() } : p));
122
+ updatePresetsInStorage(newPresets);
123
+ setEditingTab(null);
124
+ }
125
+ };
126
+
34
127
  return (
35
128
  <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
36
- <div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
37
- <div className="p-6">
38
- <h2 className="text-xl font-semibold text-gray-900 mb-4">编辑 Extra Parameters</h2>
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>
132
+ </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
+ {presets.map((preset, index) => (
136
+ <div
137
+ 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"
140
+ }`}
141
+ onDoubleClick={() => {
142
+ setEditingTab(index);
143
+ setEditingName(preset.name);
144
+ }}
145
+ onClick={() => setActiveTab(index)}
146
+ >
147
+ {editingTab === index ? (
148
+ <input
149
+ type="text"
150
+ value={editingName}
151
+ onChange={(e) => setEditingName(e.target.value)}
152
+ onBlur={() => handleRename(index)}
153
+ onKeyDown={(e) => e.key === "Enter" && handleRename(index)}
154
+ className="text-sm p-0 border-none focus:ring-0 bg-transparent"
155
+ autoFocus
156
+ />
157
+ ) : (
158
+ <span>{preset.name}</span>
159
+ )}
160
+ <button
161
+ onClick={(e) => {
162
+ e.stopPropagation();
163
+ handleDeleteTab(index);
164
+ }}
165
+ className="ml-2 text-gray-400 hover:text-gray-600 w-4 h-4 flex items-center justify-center"
166
+ >
167
+ &times;
168
+ </button>
169
+ </div>
170
+ ))}
171
+ <button onClick={handleAddTab} className="ml-2 px-3 py-1 text-sm text-gray-500 hover:text-gray-700">
172
+ +
173
+ </button>
174
+ </div>
175
+
39
176
  <textarea
40
177
  value={jsonString}
41
178
  onChange={(e) => {
42
179
  setJsonString(e.target.value);
43
180
  setError(null); // Clear error on edit
44
181
  }}
45
- rows={15}
46
- className="w-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"
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"
47
183
  />
48
184
  {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
49
- <div className="flex justify-end gap-3 mt-4">
50
- <button
51
- onClick={onClose}
52
- 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"
53
- >
54
- 取消
55
- </button>
56
- <button
57
- onClick={handleSave}
58
- 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"
59
- >
60
- 保存
61
- </button>
62
- </div>
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
+ >
191
+ 取消
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
+ >
197
+ 保存并使用
198
+ </button>
63
199
  </div>
64
200
  </div>
65
201
  </div>
@@ -3,7 +3,6 @@ import MessageHuman from "./MessageHuman";
3
3
  import MessageAI from "./MessageAI";
4
4
  import MessageTool from "./MessageTool";
5
5
  import { formatTokens, getMessageContent, LangGraphClient, RenderMessage } from "@langgraph-js/sdk";
6
- import { motion } from "motion/react";
7
6
 
8
7
  export const MessagesBox = ({
9
8
  renderMessages,
@@ -19,7 +18,7 @@ export const MessagesBox = ({
19
18
  return (
20
19
  <div className="flex flex-col gap-4 w-full">
21
20
  {renderMessages.map((message, index) => (
22
- <motion.div key={message.unique_id} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: index * 0.1 }}>
21
+ <div key={message.unique_id}>
23
22
  {message.type === "human" ? (
24
23
  <MessageHuman content={message.content} />
25
24
  ) : message.type === "tool" ? (
@@ -34,7 +33,7 @@ export const MessagesBox = ({
34
33
  ) : (
35
34
  <MessageAI message={message} />
36
35
  )}
37
- </motion.div>
36
+ </div>
38
37
  ))}
39
38
  </div>
40
39
  );
@@ -4,6 +4,9 @@ import { UsageMetadata } from "./UsageMetadata";
4
4
  import { useChat } from "../context/ChatContext";
5
5
  import Markdown from "react-markdown";
6
6
  import remarkGfm from "remark-gfm";
7
+ import { Highlight, themes } from "prism-react-renderer";
8
+
9
+ const TOOL_COLORS = ["border-red-400", "border-blue-400", "border-green-500", "border-yellow-400", "border-purple-400", "border-pink-400", "border-indigo-400"];
7
10
 
8
11
  interface MessageToolProps {
9
12
  message: ToolMessage & RenderMessage;
@@ -14,16 +17,26 @@ interface MessageToolProps {
14
17
  onToggleCollapse: () => void;
15
18
  }
16
19
 
20
+ const getToolColorClass = (tool_name: string) => {
21
+ let hash = 0;
22
+ for (let i = 0; i < tool_name.length; i++) {
23
+ hash = tool_name.charCodeAt(i) + ((hash << 5) - hash);
24
+ }
25
+ const index = Math.abs(hash % TOOL_COLORS.length);
26
+ return TOOL_COLORS[index];
27
+ };
28
+
17
29
  const MessageTool: React.FC<MessageToolProps> = ({ message, client, getMessageContent, formatTokens, isCollapsed, onToggleCollapse }) => {
18
30
  const { getToolUIRender } = useChat();
19
31
  const render = getToolUIRender(message.name!);
32
+ const borderColorClass = getToolColorClass(message.name!);
20
33
  return (
21
34
  <div className="flex flex-col w-full">
22
35
  {render ? (
23
36
  (render(message) as JSX.Element)
24
37
  ) : (
25
- <div className="flex flex-col w-full bg-white rounded-lg shadow-sm border border-gray-200">
26
- <div className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50 transition-colors" onClick={onToggleCollapse}>
38
+ <div className={`flex flex-col w-full bg-white rounded-lg shadow-sm border-2 ${borderColorClass} overflow-hidden`}>
39
+ <div className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-100 transition-colors" onClick={onToggleCollapse}>
27
40
  <div className="text-sm font-medium text-gray-700" onClick={() => console.log(message)}>
28
41
  {message.node_name} | {message.name}
29
42
  </div>
@@ -59,19 +72,24 @@ const Previewer = ({ content }: { content: string }) => {
59
72
  };
60
73
  const isJSON = content.startsWith("{") && content.endsWith("}") && validJSON();
61
74
  const isMarkdown = content.includes("#") || content.includes("```") || content.includes("*");
62
- const [jsonMode, setJsonMode] = useState(false);
75
+ const [jsonMode, setJsonMode] = useState(isJSON);
63
76
  const [markdownMode, setMarkdownMode] = useState(false);
64
-
77
+ const copyToClipboard = () => {
78
+ navigator.clipboard.writeText(content);
79
+ };
65
80
  return (
66
81
  <div className={`flex flex-col`}>
67
82
  <div className="flex gap-2 mb-2">
83
+ <button onClick={copyToClipboard} className="px-2 py-1 text-xs font-medium text-gray-600 bg-green-100 rounded hover:bg-green-200 transition-colors">
84
+ copy
85
+ </button>
68
86
  {isJSON && (
69
- <button onClick={() => setJsonMode(!jsonMode)} className="px-2 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 transition-colors">
87
+ <button onClick={() => setJsonMode(!jsonMode)} className="px-2 py-1 text-xs font-medium text-gray-600 bg-orange-100 rounded hover:bg-orange-200 transition-colors">
70
88
  json
71
89
  </button>
72
90
  )}
73
91
  {isMarkdown && (
74
- <button onClick={() => setMarkdownMode(!markdownMode)} className="px-2 py-1 text-xs font-medium text-gray-600 bg-gray-100 rounded hover:bg-gray-200 transition-colors">
92
+ <button onClick={() => setMarkdownMode(!markdownMode)} className="px-2 py-1 text-xs font-medium text-gray-600 bg-blue-100 rounded hover:bg-blue-200 transition-colors">
75
93
  markdown
76
94
  </button>
77
95
  )}
@@ -79,7 +97,19 @@ const Previewer = ({ content }: { content: string }) => {
79
97
 
80
98
  <div className="flex flex-col max-h-[300px] overflow-auto border border-gray-200 rounded p-2 w-full text-xs font-mono whitespace-pre-wrap">
81
99
  {jsonMode && isJSON ? (
82
- <pre className="whitespace-pre-wrap">{JSON.stringify(JSON.parse(content), null, 2)}</pre>
100
+ <Highlight code={JSON.stringify(JSON.parse(content), null, 2)} language="json" theme={themes.oneLight}>
101
+ {({ className, style, tokens, getLineProps, getTokenProps }) => (
102
+ <pre style={style}>
103
+ {tokens.map((line, i) => (
104
+ <div key={i} {...getLineProps({ line })}>
105
+ {line.map((token, key) => (
106
+ <span key={key} {...getTokenProps({ token })} />
107
+ ))}
108
+ </div>
109
+ ))}
110
+ </pre>
111
+ )}
112
+ </Highlight>
83
113
  ) : markdownMode && isMarkdown ? (
84
114
  <div className="markdown-body">
85
115
  <Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
@@ -0,0 +1,4 @@
1
+ .langgraph-chat-container .token-line {
2
+ white-space: pre-wrap;
3
+ overflow: hidden;
4
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import "virtual:uno.css";
2
+ import "./index.css";
2
3
  export { default as Chat } from "./chat/Chat";