diff --git a/src/app/api/runs/feedback/route.ts b/src/app/api/runs/feedback/route.ts new file mode 100644 index 00000000..7f8076ad --- /dev/null +++ b/src/app/api/runs/feedback/route.ts @@ -0,0 +1,88 @@ +import { Client, Feedback } from "langsmith"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { runId, feedbackKey, score, comment } = body; + + if (!runId || !feedbackKey) { + return NextResponse.json( + { error: "`runId` and `feedbackKey` are required." }, + { status: 400 } + ); + } + + const lsClient = new Client({ + apiKey: process.env.LANGCHAIN_API_KEY, + }); + + const feedback = await lsClient.createFeedback(runId, feedbackKey, { + score, + comment, + }); + + return NextResponse.json( + { success: true, feedback: feedback }, + { status: 200 } + ); + } catch (error) { + console.error("Failed to process feedback request:", error); + + return NextResponse.json( + { error: "Failed to submit feedback." }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + try { + const searchParams = req.nextUrl.searchParams; + const runId = searchParams.get("runId"); + const feedbackKey = searchParams.get("feedbackKey"); + + if (!runId || !feedbackKey) { + return new NextResponse( + JSON.stringify({ + error: "`runId` and `feedbackKey` are required.", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const lsClient = new Client({ + apiKey: process.env.LANGCHAIN_API_KEY, + }); + + const runFeedback: Feedback[] = []; + + const run_feedback = await lsClient.listFeedback({ + runIds: [runId], + feedbackKeys: [feedbackKey], + }); + + for await (const feedback of run_feedback) { + runFeedback.push(feedback); + } + + return new NextResponse( + JSON.stringify({ + feedback: runFeedback, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error) { + console.error("Failed to fetch feedback:", error); + return NextResponse.json( + { error: "Failed to fetch feedback." }, + { status: 500 } + ); + } +} diff --git a/src/components/chat-interface/feedback.tsx b/src/components/chat-interface/feedback.tsx new file mode 100644 index 00000000..0b06ad67 --- /dev/null +++ b/src/components/chat-interface/feedback.tsx @@ -0,0 +1,73 @@ +import { useToast } from "@/hooks/use-toast"; +import { FeedbackResponse } from "@/hooks/useFeedback"; +import { ThumbsUpIcon, ThumbsDownIcon } from "lucide-react"; +import { Dispatch, FC, SetStateAction } from "react"; +import { cn } from "@/lib/utils"; +import { TooltipIconButton } from "../ui/assistant-ui/tooltip-icon-button"; + +interface FeedbackButtonProps { + runId: string; + setFeedbackSubmitted: Dispatch>; + sendFeedback: ( + runId: string, + feedbackKey: string, + score: number, + comment?: string + ) => Promise; + feedbackValue: number; + icon: "thumbs-up" | "thumbs-down"; + isLoading: boolean; +} + +export const FeedbackButton: FC = ({ + runId, + setFeedbackSubmitted, + sendFeedback, + isLoading, + feedbackValue, + icon, +}) => { + const { toast } = useToast(); + + const handleClick = async () => { + try { + const res = await sendFeedback(runId, "feedback", feedbackValue); + if (res?.success) { + setFeedbackSubmitted(true); + } else { + toast({ + title: "Failed to submit feedback", + description: "Please try again later.", + variant: "destructive", + }); + } + } catch (_) { + toast({ + title: "Failed to submit feedback", + description: "Please try again later.", + variant: "destructive", + }); + } + }; + + const tooltip = `Give ${icon === "thumbs-up" ? "positive" : "negative"} feedback on this run`; + + return ( + + {icon === "thumbs-up" ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/chat-interface/messages.tsx b/src/components/chat-interface/messages.tsx index e64a9d02..342d0c18 100644 --- a/src/components/chat-interface/messages.tsx +++ b/src/components/chat-interface/messages.tsx @@ -1,12 +1,30 @@ "use client"; -import { MessagePrimitive } from "@assistant-ui/react"; -import { type FC } from "react"; +import { + ActionBarPrimitive, + MessagePrimitive, + useMessage, +} from "@assistant-ui/react"; +import React, { Dispatch, SetStateAction, type FC } from "react"; import { MarkdownText } from "@/components/ui/assistant-ui/markdown-text"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { FeedbackButton } from "./feedback"; +import { TighterText } from "../ui/header"; +import { useFeedback } from "@/hooks/useFeedback"; -export const AssistantMessage: FC = () => { +interface AssistantMessageProps { + runId: string | undefined; + feedbackSubmitted: boolean; + setFeedbackSubmitted: Dispatch>; +} + +export const AssistantMessage: FC = ({ + runId, + feedbackSubmitted, + setFeedbackSubmitted, +}) => { + const isLast = useMessage().isLast; return ( @@ -15,6 +33,15 @@ export const AssistantMessage: FC = () => {
+ {isLast && runId && ( + + + + )}
); @@ -29,3 +56,55 @@ export const UserMessage: FC = () => { ); }; + +interface AssistantMessageBarProps { + runId: string; + feedbackSubmitted: boolean; + setFeedbackSubmitted: Dispatch>; +} + +const AssistantMessageBarComponent = ({ + runId, + feedbackSubmitted, + setFeedbackSubmitted, +}: AssistantMessageBarProps) => { + const { isLoading, sendFeedback } = useFeedback(); + return ( + + {feedbackSubmitted ? ( + + Feedback received! Thank you! + + ) : ( + <> + + + + + + + + )} + + ); +}; + +const AssistantMessageBar = React.memo(AssistantMessageBarComponent); diff --git a/src/components/chat-interface/thread.tsx b/src/components/chat-interface/thread.tsx index eb5bba55..9376032c 100644 --- a/src/components/chat-interface/thread.tsx +++ b/src/components/chat-interface/thread.tsx @@ -50,7 +50,7 @@ export const Thread: FC = (props: ThreadProps) => { const { userData: { user }, threadData: { createThread, modelName, setModelName, assistantId }, - graphData: { clearState }, + graphData: { clearState, runId, feedbackSubmitted, setFeedbackSubmitted }, } = useGraphContext(); useLangSmithLinkToolUI(); @@ -116,7 +116,14 @@ export const Thread: FC = (props: ThreadProps) => { ( + + ), }} /> diff --git a/src/constants.ts b/src/constants.ts index 27513f5a..6fbf260c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,5 @@ export const LANGGRAPH_API_URL = - process.env.LANGGRAPH_API_URL ?? "http://localhost:56339"; + process.env.LANGGRAPH_API_URL ?? "http://localhost:50944"; // v2 is tied to the 'open-canvas-prod' deployment. export const ASSISTANT_ID_COOKIE = "oc_assistant_id_v2"; // export const ASSISTANT_ID_COOKIE = "oc_assistant_id"; diff --git a/src/contexts/GraphContext.tsx b/src/contexts/GraphContext.tsx index 18fb6f9d..a522e800 100644 --- a/src/contexts/GraphContext.tsx +++ b/src/contexts/GraphContext.tsx @@ -54,6 +54,7 @@ import { reverseCleanContent } from "@/lib/normalize_string"; import { setCookie } from "@/lib/cookies"; interface GraphData { + runId: string | undefined; isStreaming: boolean; selectedBlocks: TextHighlight | undefined; messages: BaseMessage[]; @@ -61,6 +62,8 @@ interface GraphData { updateRenderedArtifactRequired: boolean; isArtifactSaved: boolean; firstTokenReceived: boolean; + feedbackSubmitted: boolean; + setFeedbackSubmitted: Dispatch>; setArtifact: Dispatch>; setSelectedBlocks: Dispatch>; setSelectedArtifact: (index: number) => void; @@ -126,6 +129,8 @@ export function GraphProvider({ children }: { children: ReactNode }) { const [isArtifactSaved, setIsArtifactSaved] = useState(true); const [threadSwitched, setThreadSwitched] = useState(false); const [firstTokenReceived, setFirstTokenReceived] = useState(false); + const [runId, setRunId] = useState(); + const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); useEffect(() => { if (userData.user) return; @@ -289,10 +294,13 @@ export function GraphProvider({ children }: { children: ReactNode }) { } setIsStreaming(true); + setRunId(undefined); + setFeedbackSubmitted(false); // The root level run ID of this stream let runId = ""; let followupMessageId = ""; // let lastMessage: AIMessage | undefined = undefined; + try { const stream = client.runs.stream( threadData.threadId, @@ -344,6 +352,7 @@ export function GraphProvider({ children }: { children: ReactNode }) { try { if (!runId && chunk.data?.metadata?.run_id) { runId = chunk.data.metadata.run_id; + setRunId(runId); } if (chunk.data.event === "on_chain_start") { if ( @@ -783,6 +792,7 @@ export function GraphProvider({ children }: { children: ReactNode }) { }); return newMessageWithToolCall; } + return msg; }); return newMsgs; @@ -920,6 +930,7 @@ export function GraphProvider({ children }: { children: ReactNode }) { userData, threadData, graphData: { + runId, isStreaming, selectedBlocks, messages, @@ -927,6 +938,8 @@ export function GraphProvider({ children }: { children: ReactNode }) { updateRenderedArtifactRequired, isArtifactSaved, firstTokenReceived, + feedbackSubmitted, + setFeedbackSubmitted, setArtifact, setSelectedBlocks, setSelectedArtifact, diff --git a/src/hooks/useFeedback.ts b/src/hooks/useFeedback.ts new file mode 100644 index 00000000..b70fe99a --- /dev/null +++ b/src/hooks/useFeedback.ts @@ -0,0 +1,101 @@ +import { Feedback } from "langsmith"; +import { useCallback, useState } from "react"; + +export interface FeedbackResponse { + success: boolean; + feedback: Feedback; +} + +interface UseFeedbackResult { + isLoading: boolean; + error: string | null; + sendFeedback: ( + runId: string, + feedbackKey: string, + score: number, + comment?: string + ) => Promise; + getFeedback: ( + runId: string, + feedbackKey: string + ) => Promise; +} + +export function useFeedback(): UseFeedbackResult { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const sendFeedback = useCallback( + async ( + runId: string, + feedbackKey: string, + score: number, + comment?: string + ): Promise => { + setIsLoading(true); + setError(null); + + try { + const res = await fetch("/api/runs/feedback", { + method: "POST", + body: JSON.stringify({ runId, feedbackKey, score, comment }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + return; + } + + return (await res.json()) as FeedbackResponse; + } catch (error) { + console.error("Error sending feedback:", error); + setError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + return; + } finally { + setIsLoading(false); + } + }, + [] + ); + + const getFeedback = useCallback( + async ( + runId: string, + feedbackKey: string + ): Promise => { + setIsLoading(true); + setError(null); + try { + const res = await fetch( + `/api/runs/feedback?runId=${encodeURIComponent(runId)}&feedbackKey=${encodeURIComponent(feedbackKey)}` + ); + + if (!res.ok) { + return; + } + + return await res.json(); + } catch (error) { + console.error("Error getting feedback:", error); + setError( + error instanceof Error ? error.message : "An unknown error occurred" + ); + return; + } finally { + setIsLoading(false); + } + }, + [] + ); + + return { + isLoading, + sendFeedback, + getFeedback, + error, + }; +}