From 3f9c85b106b9826b1d8846cb4b871145918cd2ba Mon Sep 17 00:00:00 2001 From: bracesproul Date: Tue, 4 Mar 2025 13:37:42 -0800 Subject: [PATCH 1/2] feat: Add default tool call renderer, and error toasts --- package.json | 2 + pnpm-lock.yaml | 34 ++++++++++++ src/components/thread/history/index.tsx | 2 +- src/components/thread/index.tsx | 33 +++++++++++- src/components/thread/markdown-text.tsx | 5 +- src/components/thread/messages/ai.tsx | 7 +++ src/components/thread/messages/tool-calls.tsx | 53 +++++++++++++++++++ src/components/ui/sonner.tsx | 27 ++++++++++ src/main.tsx | 2 + src/providers/Stream.tsx | 10 +++- 10 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 src/components/thread/messages/tool-calls.tsx create mode 100644 src/components/ui/sonner.tsx diff --git a/package.json b/package.json index e078c61..9573c9b 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,14 @@ "esbuild-plugin-tailwindcss": "^2.0.1", "framer-motion": "^12.4.9", "lucide-react": "^0.476.0", + "next-themes": "^0.4.4", "prettier": "^3.5.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.0.1", "react-router-dom": "^6.17.0", "remark-gfm": "^4.0.1", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", "use-query-params": "^2.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f3b32f..33630c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: lucide-react: specifier: ^0.476.0 version: 0.476.0(react@19.0.0) + next-themes: + specifier: ^0.4.4 + version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) prettier: specifier: ^3.5.2 version: 3.5.2 @@ -96,6 +99,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + sonner: + specifier: ^2.0.1 + version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) tailwind-merge: specifier: ^3.0.2 version: 3.0.2 @@ -3891,6 +3897,15 @@ packages: integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, } + next-themes@0.4.4: + resolution: + { + integrity: sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==, + } + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-domexception@1.0.0: resolution: { @@ -4490,6 +4505,15 @@ packages: integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==, } + sonner@2.0.1: + resolution: + { + integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==, + } + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: { @@ -7508,6 +7532,11 @@ snapshots: natural-compare@1.4.0: {} + next-themes@0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + node-domexception@1.0.0: {} node-fetch@2.7.0: @@ -7899,6 +7928,11 @@ snapshots: simple-wcswidth@1.0.1: {} + sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} diff --git a/src/components/thread/history/index.tsx b/src/components/thread/history/index.tsx index af545fb..6bd8b9d 100644 --- a/src/components/thread/history/index.tsx +++ b/src/components/thread/history/index.tsx @@ -21,7 +21,7 @@ function ThreadList({ const [threadId, setThreadId] = useQueryParam("threadId", StringParam); return ( -
+
{threads.map((t) => { let itemText = t.thread_id; if ( diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index 2855368..0acbc07 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -23,6 +23,7 @@ import { import { BooleanParam, StringParam, useQueryParam } from "use-query-params"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; import ThreadHistory from "./history"; +import { toast } from "sonner"; function StickyToBottomContent(props: { content: ReactNode; @@ -75,6 +76,36 @@ export function Thread() { const messages = stream.messages; const isLoading = stream.isLoading; + const lastError = useRef(undefined); + + useEffect(() => { + if (!stream.error) { + lastError.current = undefined; + return; + } + try { + const message = (stream.error as any).message; + if (!message || lastError.current === message) { + // Message has already been logged. do not modify ref, return early. + return; + } + + // Message is defined, and it has not been logged yet. Save it, and send the error + lastError.current = message; + toast.error("An error occurred. Please try again.", { + description: ( +

+ Error: {message} +

+ ), + richColors: true, + closeButton: true, + }); + } catch { + // no-op + } + }, [stream.error]); + // TODO: this should be part of the useStream hook const prevMessageLength = useRef(0); useEffect(() => { @@ -178,7 +209,7 @@ export function Thread() { ); diff --git a/src/components/thread/messages/ai.tsx b/src/components/thread/messages/ai.tsx index 70579d4..e269a63 100644 --- a/src/components/thread/messages/ai.tsx +++ b/src/components/thread/messages/ai.tsx @@ -6,6 +6,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { MarkdownText } from "../markdown-text"; import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui/client"; import { cn } from "@/lib/utils"; +import { ToolCalls } from "./tool-calls"; function CustomComponent({ message, @@ -56,12 +57,18 @@ export function AssistantMessage({ const meta = thread.getMessagesMetadata(message); const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint; + const hasToolCalls = + "tool_calls" in message && + message.tool_calls && + message.tool_calls.length > 0; + return (
A
+ {hasToolCalls && } {contentString.length > 0 && (
diff --git a/src/components/thread/messages/tool-calls.tsx b/src/components/thread/messages/tool-calls.tsx new file mode 100644 index 0000000..741b297 --- /dev/null +++ b/src/components/thread/messages/tool-calls.tsx @@ -0,0 +1,53 @@ +import { AIMessage } from "@langchain/langgraph-sdk"; + +function isComplexValue(value: any): boolean { + return Array.isArray(value) || (typeof value === "object" && value !== null); +} + +export function ToolCalls({ + toolCalls, +}: { + toolCalls: AIMessage["tool_calls"]; +}) { + if (!toolCalls || toolCalls.length === 0) return null; + + return ( +
+ {toolCalls.map((tc, idx) => { + const args = tc.args as Record; + if (!tc.args || Object.keys(args).length === 0) return null; + + return ( +
+
+

{tc.name}

+
+ + + {Object.entries(args).map(([key, value], argIdx) => ( + + + + + ))} + +
+ {key} + + {isComplexValue(value) ? ( + + {JSON.stringify(value, null, 2)} + + ) : ( + String(value) + )} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..787ca96 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,27 @@ +import { useTheme } from "next-themes"; +import { Toaster as Sonner, ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/src/main.tsx b/src/main.tsx index ce2eeaf..d81f850 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { StreamProvider } from "./providers/Stream.tsx"; import { QueryParamProvider } from "use-query-params"; import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6"; import { BrowserRouter } from "react-router-dom"; +import { Toaster } from "@/components/ui/sonner"; createRoot(document.getElementById("root")!).render( @@ -13,5 +14,6 @@ createRoot(document.getElementById("root")!).render( + , ); diff --git a/src/providers/Stream.tsx b/src/providers/Stream.tsx index b896d40..21bd3e7 100644 --- a/src/providers/Stream.tsx +++ b/src/providers/Stream.tsx @@ -14,8 +14,10 @@ import { ArrowRight } from "lucide-react"; import { PasswordInput } from "@/components/ui/password-input"; import { getApiKey } from "@/lib/api-key"; +export type StateType = { messages: Message[]; ui?: UIMessage[] }; + const useTypedStream = useStream< - { messages: Message[]; ui?: UIMessage[] }, + StateType, { UpdateType: { messages?: Message[] | Message | string; @@ -48,6 +50,12 @@ const StreamSession = ({ onThreadId: setThreadId, }); + if (streamValue.error) { + if (typeof streamValue.error === "object") { + console.log((streamValue.error as any)?.["message"]); + } + } + return ( {children} From b4b2efce12329a7e785ed0f06163b12bd34a2b42 Mon Sep 17 00:00:00 2001 From: bracesproul Date: Tue, 4 Mar 2025 14:25:32 -0800 Subject: [PATCH 2/2] fix --- src/providers/Stream.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/providers/Stream.tsx b/src/providers/Stream.tsx index 21bd3e7..df45fb4 100644 --- a/src/providers/Stream.tsx +++ b/src/providers/Stream.tsx @@ -50,12 +50,6 @@ const StreamSession = ({ onThreadId: setThreadId, }); - if (streamValue.error) { - if (typeof streamValue.error === "object") { - console.log((streamValue.error as any)?.["message"]); - } - } - return ( {children}