@langgraph-js/ui 1.0.0 → 1.1.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
@@ -14,8 +14,8 @@
14
14
  height: 100%;
15
15
  }
16
16
  </style>
17
- <script type="module" crossorigin src="/assets/index-B6G4BLix.js"></script>
18
- <link rel="stylesheet" crossorigin href="/assets/index-BAcH-2-3.css">
17
+ <script type="module" crossorigin src="/assets/index-CCaE6qp1.js"></script>
18
+ <link rel="stylesheet" crossorigin href="/assets/index-CBfok6qC.css">
19
19
  </head>
20
20
  <body>
21
21
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langgraph-js/ui",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
@@ -19,7 +19,9 @@
19
19
  "react": "^19.0.0",
20
20
  "react-dom": "^19.0.0",
21
21
  "vite": "^6.2.0",
22
- "@langgraph-js/sdk": "1.1.5"
22
+ "react-markdown": "^10.1.0",
23
+ "remark-gfm": "^4.0.1",
24
+ "@langgraph-js/sdk": "1.1.6"
23
25
  },
24
26
  "devDependencies": {
25
27
  "@types/react": "^19.0.10",
package/src/chat/Chat.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { useState } from "react";
2
2
  import "./chat.css";
3
3
  import MessageHuman from "./components/MessageHuman";
4
4
  import MessageAI from "./components/MessageAI";
@@ -6,7 +6,8 @@ import MessageTool from "./components/MessageTool";
6
6
  import HistoryList from "./components/HistoryList";
7
7
  import { ChatProvider, useChat } from "./context/ChatContext";
8
8
  import { UsageMetadata } from "./components/UsageMetadata";
9
- import { formatTime, formatTokens, getMessageContent } from "@langgraph-js/sdk";
9
+ import { formatTime, formatTokens, getMessageContent, Message } from "@langgraph-js/sdk";
10
+ import FileList from "./components/FileList";
10
11
 
11
12
  const ChatMessages: React.FC = () => {
12
13
  const { renderMessages, loading, inChatError, client, collapsedTools, toggleToolCollapse } = useChat();
@@ -38,15 +39,48 @@ const ChatMessages: React.FC = () => {
38
39
 
39
40
  const ChatInput: React.FC = () => {
40
41
  const { userInput, setUserInput, loading, sendMessage, stopGeneration, currentAgent, setCurrentAgent, client } = useChat();
42
+ const [extraParams, setExtraParams] = useState({});
43
+ const [imageUrls, setImageUrls] = useState<string[]>([]);
44
+
45
+ const handleFileUploaded = (url: string) => {
46
+ setImageUrls((prev) => [...prev, url]);
47
+ };
48
+
49
+ const sendMultiModalMessage = () => {
50
+ const content: Message[] = [
51
+ {
52
+ type: "human",
53
+ content: [
54
+ {
55
+ type: "text",
56
+ text: userInput,
57
+ },
58
+ ...imageUrls.map((url) => ({
59
+ type: "image_url" as const,
60
+ image_url: { url },
61
+ })),
62
+ ],
63
+ },
64
+ ];
65
+
66
+ sendMessage(content, {
67
+ extraParams,
68
+ });
69
+
70
+ // 清空图片列表
71
+ setImageUrls([]);
72
+ };
73
+
41
74
  const handleKeyPress = (event: React.KeyboardEvent) => {
42
75
  if (event.key === "Enter" && !event.shiftKey) {
43
76
  event.preventDefault();
44
- sendMessage();
77
+ sendMultiModalMessage();
45
78
  }
46
79
  };
47
80
 
48
81
  return (
49
82
  <div className="chat-input">
83
+ <FileList onFileUploaded={handleFileUploaded} />
50
84
  <div className="chat-input-header">
51
85
  <select value={currentAgent} onChange={(e) => setCurrentAgent(e.target.value)}>
52
86
  {client?.availableAssistants.map((i) => {
@@ -65,7 +99,11 @@ const ChatInput: React.FC = () => {
65
99
  placeholder="输入消息..."
66
100
  disabled={loading}
67
101
  />
68
- <button className={`send-button ${loading ? "interrupt" : ""}`} onClick={() => (loading ? stopGeneration() : sendMessage())} disabled={!loading && !userInput.trim()}>
102
+ <button
103
+ className={`send-button ${loading ? "interrupt" : ""}`}
104
+ onClick={() => (loading ? stopGeneration() : sendMultiModalMessage())}
105
+ disabled={!loading && !userInput.trim() && imageUrls.length === 0}
106
+ >
69
107
  {loading ? "中断" : "发送"}
70
108
  </button>
71
109
  </div>
@@ -0,0 +1,105 @@
1
+ /**
2
+ * File Upload SDK - Base client for file upload services
3
+ */
4
+
5
+ // Base interfaces
6
+ interface FileUploadClientOptions {
7
+ apiUrl?: string;
8
+ }
9
+
10
+ interface FileUploadOptions {
11
+ filename?: string;
12
+ signal?: AbortSignal;
13
+ }
14
+
15
+ interface FileUploadResponse {
16
+ status: string;
17
+ data?: {
18
+ url: string;
19
+ delete_url?: string;
20
+ expires_at?: string;
21
+ size?: number;
22
+ [key: string]: any;
23
+ };
24
+ [key: string]: any;
25
+ }
26
+
27
+ // Abstract base class for file upload clients
28
+ abstract class FileUploadClient {
29
+ protected apiUrl: string;
30
+
31
+ constructor(options: FileUploadClientOptions = {}) {
32
+ this.apiUrl = options.apiUrl || "";
33
+ }
34
+
35
+ protected abstract getUploadEndpoint(): string;
36
+ protected abstract processResponse(response: FileUploadResponse): FileUploadResponse;
37
+
38
+ protected createFormData(file: File | Blob | string, filename?: string): FormData {
39
+ const formData = new FormData();
40
+
41
+ if (typeof file === "string") {
42
+ const blob = new Blob([file], { type: "text/plain" });
43
+ formData.append("file", blob, filename || "file.txt");
44
+ } else {
45
+ formData.append("file", file, filename || (file instanceof File ? file.name : "file"));
46
+ }
47
+
48
+ return formData;
49
+ }
50
+
51
+ public async upload(file: File | Blob | string, options: FileUploadOptions = {}): Promise<FileUploadResponse> {
52
+ const formData = this.createFormData(file, options.filename);
53
+
54
+ const fetchOptions: RequestInit = {
55
+ method: "POST",
56
+ body: formData,
57
+ };
58
+
59
+ if (options.signal) {
60
+ fetchOptions.signal = options.signal;
61
+ }
62
+
63
+ try {
64
+ const response = await fetch(`${this.apiUrl}${this.getUploadEndpoint()}`, fetchOptions);
65
+
66
+ if (!response.ok) {
67
+ throw new Error(`Upload failed with status: ${response.status}`);
68
+ }
69
+
70
+ const result = (await response.json()) as FileUploadResponse;
71
+ return this.processResponse(result);
72
+ } catch (error) {
73
+ throw new Error(`File upload failed: ${error instanceof Error ? error.message : String(error)}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * TmpFiles SDK - A client for uploading files to tmpfiles.org
80
+ */
81
+ export class TmpFilesClient extends FileUploadClient {
82
+ constructor(options: FileUploadClientOptions = {}) {
83
+ super({
84
+ apiUrl: options.apiUrl || "https://tmpfiles.org/api/v1"
85
+ });
86
+ }
87
+
88
+ protected getUploadEndpoint(): string {
89
+ return "/upload";
90
+ }
91
+
92
+ protected processResponse(response: FileUploadResponse): FileUploadResponse {
93
+ if (response.data?.url) {
94
+ response.data.url = response.data.url.replace("https://tmpfiles.org/", "https://tmpfiles.org/dl/");
95
+ }
96
+ return response;
97
+ }
98
+ }
99
+
100
+ // Export types for external use
101
+ export type {
102
+ FileUploadClientOptions,
103
+ FileUploadOptions,
104
+ FileUploadResponse
105
+ };
package/src/chat/chat.css CHANGED
@@ -1,3 +1,5 @@
1
+ @import url("https://unpkg.com/github-markdown-css/github-markdown.css");
2
+
1
3
  .chat-container {
2
4
  display: flex;
3
5
  height: 100vh;
@@ -128,10 +130,31 @@
128
130
  display: flex;
129
131
  flex-direction: column;
130
132
  gap: 0.5rem;
133
+ max-width: 100%;
131
134
  }
132
135
 
133
136
  .message-text {
134
137
  word-break: break-word;
138
+ line-height: 1.5;
139
+ }
140
+
141
+ .message-image {
142
+ margin: 0.5rem 0;
143
+ }
144
+
145
+ .message-image img {
146
+ max-width: 100%;
147
+ border-radius: 4px;
148
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
149
+ }
150
+
151
+ .message-audio {
152
+ margin: 0.5rem 0;
153
+ }
154
+
155
+ .message-audio audio {
156
+ width: 100%;
157
+ max-width: 300px;
135
158
  }
136
159
 
137
160
  .message-meta {
@@ -178,7 +201,6 @@
178
201
  }
179
202
 
180
203
  .message.ai .message-content {
181
- background-color: #f3f4f6;
182
204
  color: #1f2937;
183
205
  }
184
206
 
@@ -359,3 +381,8 @@
359
381
  font-size: 14px;
360
382
  text-align: center;
361
383
  }
384
+
385
+ .markdown-body p {
386
+ text-align: left;
387
+ }
388
+
@@ -0,0 +1,116 @@
1
+ .file-list {
2
+ padding: 0.5rem;
3
+ background-color: #f9fafb;
4
+ border-radius: 8px;
5
+ margin-bottom: 1rem;
6
+ }
7
+
8
+ .file-list-header {
9
+ margin-bottom: 1rem;
10
+ }
11
+
12
+ .file-upload-button {
13
+ display: inline-flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ width: 80px;
17
+ height: 80px;
18
+ background-color: #3b82f6;
19
+ color: white;
20
+ border-radius: 6px;
21
+ cursor: pointer;
22
+ font-size: 2rem;
23
+ transition: background-color 0.2s;
24
+ }
25
+
26
+ .file-upload-button:hover {
27
+ background-color: #2563eb;
28
+ }
29
+
30
+ .file-list-content {
31
+ display: flex;
32
+ flex-wrap: wrap;
33
+ gap: 0.5rem;
34
+ }
35
+
36
+ .file-item {
37
+ position: relative;
38
+ width: 80px;
39
+ height: 80px;
40
+ border-radius: 6px;
41
+ overflow: hidden;
42
+ }
43
+
44
+ .file-preview {
45
+ width: 100%;
46
+ height: 100%;
47
+ object-fit: cover;
48
+ }
49
+
50
+ .file-info {
51
+ padding: 0.75rem;
52
+ }
53
+
54
+ .file-name {
55
+ display: block;
56
+ font-size: 0.875rem;
57
+ color: #374151;
58
+ margin-bottom: 0.5rem;
59
+ white-space: nowrap;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ }
63
+
64
+ .file-actions {
65
+ display: flex;
66
+ gap: 0.5rem;
67
+ }
68
+
69
+ .upload-button,
70
+ .remove-button {
71
+ flex: 1;
72
+ padding: 0.375rem 0.75rem;
73
+ border: none;
74
+ border-radius: 4px;
75
+ font-size: 0.75rem;
76
+ cursor: pointer;
77
+ transition: all 0.2s;
78
+ }
79
+
80
+ .upload-button {
81
+ background-color: #3b82f6;
82
+ color: white;
83
+ }
84
+
85
+ .upload-button:hover {
86
+ background-color: #2563eb;
87
+ }
88
+
89
+ .remove-button {
90
+ position: absolute;
91
+ top: 2px;
92
+ right: 2px;
93
+ width: 20px;
94
+ height: 20px;
95
+ background-color: rgba(0, 0, 0, 0.5);
96
+ color: white;
97
+ border: none;
98
+ border-radius: 50%;
99
+ cursor: pointer;
100
+ font-size: 16px;
101
+ line-height: 1;
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ transition: background-color 0.2s;
106
+ }
107
+
108
+ .remove-button:hover {
109
+ background-color: rgba(0, 0, 0, 0.7);
110
+ }
111
+
112
+ .upload-button:disabled,
113
+ .remove-button:disabled {
114
+ opacity: 0.5;
115
+ cursor: not-allowed;
116
+ }
@@ -0,0 +1,70 @@
1
+ import React, { useState, useCallback } from 'react';
2
+ import { TmpFilesClient } from '../FileUpload';
3
+ import './FileList.css';
4
+
5
+ interface FileListProps {
6
+ onFileUploaded: (url: string) => void;
7
+ }
8
+
9
+ const FileList: React.FC<FileListProps> = ({ onFileUploaded }) => {
10
+ const [files, setFiles] = useState<File[]>([]);
11
+ const client = new TmpFilesClient();
12
+
13
+ const handleFileChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
14
+ const selectedFiles = Array.from(event.target.files || []);
15
+ const imageFiles = selectedFiles.filter(file => file.type.startsWith('image/'));
16
+ setFiles(prev => [...prev, ...imageFiles]);
17
+
18
+ for (const file of imageFiles) {
19
+ try {
20
+ const result = await client.upload(file);
21
+ if (result.data?.url) {
22
+ onFileUploaded(result.data.url);
23
+ }
24
+ } catch (error) {
25
+ console.error('Upload failed:', error);
26
+ }
27
+ }
28
+
29
+ // 清空 input 值,允许重复选择相同文件
30
+ event.target.value = '';
31
+ }, [onFileUploaded]);
32
+
33
+ const removeFile = useCallback((index: number) => {
34
+ setFiles(prev => prev.filter((_, i) => i !== index));
35
+ }, []);
36
+
37
+ return (
38
+ <div className="file-list">
39
+ <div className="file-list-content">
40
+ <label className="file-upload-button">
41
+ <span>+</span>
42
+ <input
43
+ type="file"
44
+ accept="image/*"
45
+ multiple
46
+ onChange={handleFileChange}
47
+ style={{ display: 'none' }}
48
+ />
49
+ </label>
50
+ {files.map((file, index) => (
51
+ <div key={index} className="file-item">
52
+ <img
53
+ src={URL.createObjectURL(file)}
54
+ alt={file.name}
55
+ className="file-preview"
56
+ />
57
+ <button
58
+ className="remove-button"
59
+ onClick={() => removeFile(index)}
60
+ >
61
+ ×
62
+ </button>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </div>
67
+ );
68
+ };
69
+
70
+ export default FileList;
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { useChat } from "../context/ChatContext";
3
+ import { getHistoryContent } from "@langgraph-js/sdk";
3
4
 
4
5
  interface HistoryListProps {
5
6
  onClose: () => void;
@@ -8,7 +9,6 @@ interface HistoryListProps {
8
9
 
9
10
  const HistoryList: React.FC<HistoryListProps> = ({ onClose, formatTime }) => {
10
11
  const { historyList, currentChatId, refreshHistoryList, createNewChat, deleteHistoryChat, toHistoryChat } = useChat();
11
-
12
12
  return (
13
13
  <div className="history-list">
14
14
  <div className="history-header">
@@ -42,7 +42,7 @@ const HistoryList: React.FC<HistoryListProps> = ({ onClose, formatTime }) => {
42
42
  .map((thread) => (
43
43
  <div className={`history-item ${thread.thread_id === currentChatId ? "active" : ""}`} key={thread.thread_id}>
44
44
  <div className="history-info">
45
- <div className="history-title">{thread?.values?.messages?.[0]?.content as string}</div>
45
+ <div className="history-title">{getHistoryContent(thread)}</div>
46
46
  <div className="history-meta">
47
47
  <span className="history-time">{formatTime(new Date(thread.created_at))}</span>
48
48
  <span className="history-status">{thread.status}</span>
@@ -2,6 +2,8 @@ import React from "react";
2
2
  import { RenderMessage } from "@langgraph-js/sdk";
3
3
  import { UsageMetadata } from "./UsageMetadata";
4
4
  import { getMessageContent } from "@langgraph-js/sdk";
5
+ import Markdown from 'react-markdown'
6
+ import remarkGfm from 'remark-gfm'
5
7
  interface MessageAIProps {
6
8
  message: RenderMessage;
7
9
  }
@@ -10,8 +12,10 @@ const MessageAI: React.FC<MessageAIProps> = ({ message }) => {
10
12
  return (
11
13
  <div className="message ai">
12
14
  <div className="message-content">
13
- <div className="message-text">{getMessageContent(message.content)}</div>
14
- {message.usage_metadata && <UsageMetadata usage_metadata={message.usage_metadata} spend_time={message.spend_time} />}
15
+ <div className="message-text markdown-body">
16
+ <Markdown remarkPlugins={[remarkGfm]}>{getMessageContent(message.content)}</Markdown>
17
+ </div>
18
+ <UsageMetadata response_metadata={message.response_metadata as any} usage_metadata={message.usage_metadata||{}} spend_time={message.spend_time} />
15
19
  </div>
16
20
  </div>
17
21
  );
@@ -1,13 +1,53 @@
1
1
  import React from "react";
2
-
3
2
  interface MessageHumanProps {
4
- content: any;
3
+ content: string | any[];
5
4
  }
6
5
 
7
6
  const MessageHuman: React.FC<MessageHumanProps> = ({ content }) => {
7
+ const renderContent = () => {
8
+ if (typeof content === "string") {
9
+ return <div className="message-text">{content}</div>;
10
+ }
11
+
12
+ if (Array.isArray(content)) {
13
+ return content.map((item, index) => {
14
+ switch (item.type) {
15
+ case "text":
16
+ return (
17
+ <div key={index} className="message-text">
18
+ {item.text}
19
+ </div>
20
+ );
21
+ case "image_url":
22
+ return (
23
+ <div key={index} className="message-image">
24
+ <img src={item.image_url.url} alt="用户上传的图片" style={{ maxWidth: "200px", borderRadius: "4px" }} />
25
+ </div>
26
+ );
27
+ case "audio":
28
+ return (
29
+ <div key={index} className="message-audio">
30
+ <audio controls src={item.audio_url}>
31
+ 您的浏览器不支持音频播放
32
+ </audio>
33
+ </div>
34
+ );
35
+ default:
36
+ return (
37
+ <div key={index} className="message-text">
38
+ {JSON.stringify(item)}
39
+ </div>
40
+ );
41
+ }
42
+ });
43
+ }
44
+
45
+ return <div className="message-text">{JSON.stringify(content)}</div>;
46
+ };
47
+
8
48
  return (
9
49
  <div className="message human">
10
- <div className="message-content">{typeof content === "string" ? content : JSON.stringify(content)}</div>
50
+ <div className="message-content">{renderContent()}</div>
11
51
  </div>
12
52
  );
13
53
  };
@@ -11,7 +11,6 @@ interface MessageToolProps {
11
11
  }
12
12
 
13
13
  const MessageTool: React.FC<MessageToolProps> = ({ message, client, getMessageContent, formatTokens, isCollapsed, onToggleCollapse }) => {
14
- console.log(message)
15
14
  return (
16
15
  <div className="message tool">
17
16
  {message.name === "ask_user" && !message.additional_kwargs?.done && (
@@ -36,7 +35,7 @@ const MessageTool: React.FC<MessageToolProps> = ({ message, client, getMessageCo
36
35
  <div className="tool-content">
37
36
  <div className="tool-input">{message.tool_input}</div>
38
37
  <div className="tool-output">{getMessageContent(message.content)}</div>
39
- {message.usage_metadata && <UsageMetadata usage_metadata={message.usage_metadata} spend_time={message.spend_time} />}
38
+ <UsageMetadata response_metadata={message.response_metadata as any} usage_metadata={message.usage_metadata || {}} spend_time={message.spend_time} />
40
39
  </div>
41
40
  )}
42
41
  </div>
@@ -4,10 +4,13 @@ interface UsageMetadataProps {
4
4
  output_tokens: number;
5
5
  total_tokens: number;
6
6
  }>;
7
+ response_metadata?:{
8
+ model_name?: string;
9
+ }
7
10
  spend_time?: number;
8
11
  }
9
12
 
10
- export const UsageMetadata: React.FC<UsageMetadataProps> = ({ usage_metadata, spend_time }) => {
13
+ export const UsageMetadata: React.FC<UsageMetadataProps> = ({ usage_metadata, spend_time ,response_metadata}) => {
11
14
  const formatTokens = (tokens: number) => {
12
15
  return tokens.toString();
13
16
  };
@@ -28,6 +31,9 @@ export const UsageMetadata: React.FC<UsageMetadataProps> = ({ usage_metadata, sp
28
31
  {formatTokens(usage_metadata.total_tokens || 0)}
29
32
  </span>
30
33
  </div>
34
+ <div>
35
+ {response_metadata?.model_name}
36
+ </div>
31
37
  <span className="message-time">{spend_time ? `${(spend_time / 1000).toFixed(2)}s` : ""}</span>
32
38
  </div>
33
39
  );