From d8f2c85dabaf71de31105fb00fcc0766ab84ef0e Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Thu, 4 Dec 2025 22:56:59 +0900 Subject: [PATCH] feat: link user feedback and diagram saves to chat traces in Langfuse - Update log-feedback API to find existing chat trace by sessionId and attach score to it - Update log-save API to create span on existing chat trace instead of standalone trace - Add thumbs up/down feedback buttons on assistant messages - Add message regeneration and edit functionality - Add save dialog with format selection (drawio, png, svg) - Pass sessionId through components for Langfuse linking --- app/api/log-feedback/route.ts | 95 +++++ app/api/log-save/route.ts | 86 ++++ components/chat-input.tsx | 4 +- components/chat-message-display.tsx | 275 ++++++++++--- components/chat-panel.tsx | 119 ++++++ components/save-dialog.tsx | 69 +++- components/ui/select.tsx | 187 +++++++++ contexts/diagram-context.tsx | 124 ++++-- lib/utils.ts | 38 ++ package-lock.json | 583 ++++++++++++++++++++++++++++ package.json | 1 + 11 files changed, 1484 insertions(+), 97 deletions(-) create mode 100644 app/api/log-feedback/route.ts create mode 100644 app/api/log-save/route.ts create mode 100644 components/ui/select.tsx diff --git a/app/api/log-feedback/route.ts b/app/api/log-feedback/route.ts new file mode 100644 index 0000000..0b93f02 --- /dev/null +++ b/app/api/log-feedback/route.ts @@ -0,0 +1,95 @@ +import { LangfuseClient } from '@langfuse/client'; +import { randomUUID } from 'crypto'; + +export async function POST(req: Request) { + // Check if Langfuse is configured + if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { + return Response.json({ success: true, logged: false }); + } + + const { messageId, feedback, sessionId } = await req.json(); + + // Get user IP for tracking + const forwardedFor = req.headers.get('x-forwarded-for'); + const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous'; + + try { + // Create Langfuse client + const langfuse = new LangfuseClient({ + publicKey: process.env.LANGFUSE_PUBLIC_KEY, + secretKey: process.env.LANGFUSE_SECRET_KEY, + baseUrl: process.env.LANGFUSE_BASEURL, + }); + + // Find the most recent chat trace for this session to attach the score to + const tracesResponse = await langfuse.api.trace.list({ + sessionId, + limit: 1, + }); + + const traces = tracesResponse.data || []; + const latestTrace = traces[0]; + + if (!latestTrace) { + // No trace found for this session - create a standalone feedback trace + const traceId = randomUUID(); + const timestamp = new Date().toISOString(); + + await langfuse.api.ingestion.batch({ + batch: [ + { + type: 'trace-create', + id: randomUUID(), + timestamp, + body: { + id: traceId, + name: 'user-feedback', + sessionId, + userId, + input: { messageId, feedback }, + metadata: { source: 'feedback-button', note: 'standalone - no chat trace found' }, + timestamp, + }, + }, + { + type: 'score-create', + id: randomUUID(), + timestamp, + body: { + id: randomUUID(), + traceId, + name: 'user-feedback', + value: feedback === 'good' ? 1 : 0, + comment: `User gave ${feedback} feedback`, + }, + }, + ], + }); + } else { + // Attach score to the existing chat trace + const timestamp = new Date().toISOString(); + + await langfuse.api.ingestion.batch({ + batch: [ + { + type: 'score-create', + id: randomUUID(), + timestamp, + body: { + id: randomUUID(), + traceId: latestTrace.id, + name: 'user-feedback', + value: feedback === 'good' ? 1 : 0, + comment: `User gave ${feedback} feedback`, + }, + }, + ], + }); + } + + return Response.json({ success: true, logged: true }); + } catch (error) { + console.error('Langfuse feedback error:', error); + return Response.json({ success: false, error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/log-save/route.ts b/app/api/log-save/route.ts new file mode 100644 index 0000000..0be7ff4 --- /dev/null +++ b/app/api/log-save/route.ts @@ -0,0 +1,86 @@ +import { LangfuseClient } from '@langfuse/client'; +import { randomUUID } from 'crypto'; + +export async function POST(req: Request) { + // Check if Langfuse is configured + if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { + return Response.json({ success: true, logged: false }); + } + + const { xml, filename, format, sessionId } = await req.json(); + + // Get user IP for tracking + const forwardedFor = req.headers.get('x-forwarded-for'); + const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous'; + + try { + // Create Langfuse client + const langfuse = new LangfuseClient({ + publicKey: process.env.LANGFUSE_PUBLIC_KEY, + secretKey: process.env.LANGFUSE_SECRET_KEY, + baseUrl: process.env.LANGFUSE_BASEURL, + }); + + const timestamp = new Date().toISOString(); + + // Find the most recent chat trace for this session to attach the save event to + const tracesResponse = await langfuse.api.trace.list({ + sessionId, + limit: 1, + }); + + const traces = tracesResponse.data || []; + const latestTrace = traces[0]; + + if (latestTrace) { + // Create a span on the existing chat trace for the save event + await langfuse.api.ingestion.batch({ + batch: [ + { + type: 'span-create', + id: randomUUID(), + timestamp, + body: { + id: randomUUID(), + traceId: latestTrace.id, + name: 'diagram-save', + input: { filename, format }, + output: { xmlPreview: xml?.substring(0, 500), contentLength: xml?.length || 0 }, + metadata: { source: 'save-button' }, + startTime: timestamp, + endTime: timestamp, + }, + }, + ], + }); + } else { + // No trace found - create a standalone trace + const traceId = randomUUID(); + + await langfuse.api.ingestion.batch({ + batch: [ + { + type: 'trace-create', + id: randomUUID(), + timestamp, + body: { + id: traceId, + name: 'diagram-save', + sessionId, + userId, + input: { filename, format }, + output: { xmlPreview: xml?.substring(0, 500), contentLength: xml?.length || 0 }, + metadata: { source: 'save-button', note: 'standalone - no chat trace found' }, + timestamp, + }, + }, + ], + }); + } + + return Response.json({ success: true, logged: true }); + } catch (error) { + console.error('Langfuse save error:', error); + return Response.json({ success: false, error: String(error) }, { status: 500 }); + } +} diff --git a/components/chat-input.tsx b/components/chat-input.tsx index e8cfd52..9f5350f 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -29,6 +29,7 @@ interface ChatInputProps { onFileChange?: (files: File[]) => void; showHistory?: boolean; onToggleHistory?: (show: boolean) => void; + sessionId?: string; } export function ChatInput({ @@ -41,6 +42,7 @@ export function ChatInput({ onFileChange = () => {}, showHistory = false, onToggleHistory = () => {}, + sessionId, }: ChatInputProps) { const { diagramHistory, saveDiagramToFile } = useDiagram(); const textareaRef = useRef(null); @@ -249,7 +251,7 @@ export function ChatInput({ saveDiagramToFile(filename, format, sessionId)} defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`} /> diff --git a/components/chat-message-display.tsx b/components/chat-message-display.tsx index e579794..089b80b 100644 --- a/components/chat-message-display.tsx +++ b/components/chat-message-display.tsx @@ -6,7 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import ExamplePanel from "./chat-example-panel"; import { UIMessage } from "ai"; import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils"; -import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus } from "lucide-react"; +import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react"; import { CodeBlock } from "./code-block"; interface EditPair { @@ -67,6 +67,9 @@ interface ChatMessageDisplayProps { error?: Error | null; setInput: (input: string) => void; setFiles: (files: File[]) => void; + sessionId?: string; + onRegenerate?: (messageIndex: number) => void; + onEditMessage?: (messageIndex: number, newText: string) => void; } export function ChatMessageDisplay({ @@ -74,6 +77,9 @@ export function ChatMessageDisplay({ error, setInput, setFiles, + sessionId, + onRegenerate, + onEditMessage, }: ChatMessageDisplayProps) { const { chartXML, loadDiagram: onDisplayChart } = useDiagram(); const messagesEndRef = useRef(null); @@ -84,6 +90,9 @@ export function ChatMessageDisplay({ ); const [copiedMessageId, setCopiedMessageId] = useState(null); const [copyFailedMessageId, setCopyFailedMessageId] = useState(null); + const [feedback, setFeedback] = useState>({}); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editText, setEditText] = useState(""); const copyMessageToClipboard = async (messageId: string, text: string) => { try { @@ -97,6 +106,34 @@ export function ChatMessageDisplay({ } }; + const submitFeedback = async (messageId: string, value: "good" | "bad") => { + // Toggle off if already selected + if (feedback[messageId] === value) { + setFeedback((prev) => { + const next = { ...prev }; + delete next[messageId]; + return next; + }); + return; + } + + setFeedback((prev) => ({ ...prev, [messageId]: value })); + + try { + await fetch("/api/log-feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messageId, + feedback: value, + sessionId, + }), + }); + } catch (error) { + console.warn("Failed to log feedback:", error); + } + }; + const handleDisplayChart = useCallback( (xml: string) => { const currentXml = xml || ""; @@ -253,65 +290,146 @@ export function ChatMessageDisplay({
{messages.map((message, messageIndex) => { const userMessageText = message.role === "user" ? getMessageTextContent(message) : ""; + const isLastAssistantMessage = message.role === "assistant" && ( + messageIndex === messages.length - 1 || + messages.slice(messageIndex + 1).every(m => m.role !== "assistant") + ); + const isLastUserMessage = message.role === "user" && ( + messageIndex === messages.length - 1 || + messages.slice(messageIndex + 1).every(m => m.role !== "user") + ); + const isEditing = editingMessageId === message.id; return (
- {message.role === "user" && userMessageText && ( - )} - + +
)}
- {/* Text content in bubble */} - {message.parts?.some((part: any) => part.type === "text" || part.type === "file") && ( -
- {message.parts?.map((part: any, index: number) => { - switch (part.type) { - case "text": - return ( -
- {part.text} -
- ); - case "file": - return ( -
- {`Uploaded -
- ); - default: - return null; - } - })} + {/* Edit mode for user messages */} + {isEditing && message.role === "user" ? ( +
+