mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
feat(ui): add ChatMessages component; remove auto-scrollIntoView that caused scroll-lock bypass
This commit is contained in:
parent
8787dec453
commit
62255d3c94
1 changed files with 571 additions and 0 deletions
571
ui/litellm-dashboard/src/components/chat/ChatMessages.tsx
Normal file
571
ui/litellm-dashboard/src/components/chat/ChatMessages.tsx
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"use client";
|
||||
|
||||
import { ToolOutlined, CopyOutlined, CheckOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Collapse, Tooltip } from "antd";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import ReasoningContent from "../playground/chat_ui/ReasoningContent";
|
||||
import { ChatMessage } from "./types";
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
// Keys whose values must be redacted in tool args display
|
||||
const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i;
|
||||
|
||||
function redactSensitiveValues(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (REDACTED_KEY_PATTERNS.test(k)) {
|
||||
result[k] = "[redacted]";
|
||||
} else if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
||||
result[k] = redactSensitiveValues(v as Record<string, unknown>);
|
||||
} else {
|
||||
result[k] = v;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
// Shared markdown code renderer matching ReasoningContent style
|
||||
function MarkdownCodeRenderer({
|
||||
node,
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"code"> & { inline?: boolean; node?: unknown }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={coy as Record<string, React.CSSProperties>}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-md my-2"
|
||||
{...(props as Record<string, unknown>)}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className={`${className ?? ""} px-1.5 py-0.5 rounded bg-gray-100 text-sm font-mono`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// ------- Sub-components -------
|
||||
|
||||
interface UserBubbleProps {
|
||||
message: ChatMessage;
|
||||
onEdit?: (messageId: string, newContent: string) => void;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(message.content);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.selectionStart = textareaRef.current.value.length;
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [editValue, editing]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== message.content && onEdit) {
|
||||
onEdit(message.id, trimmed);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(message.content);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
|
||||
<div style={{
|
||||
width: "72%",
|
||||
background: "#fff",
|
||||
border: "1.5px solid #1677ff",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 0 0 3px rgba(22,119,255,0.1)",
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 14px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
fontSize: 14,
|
||||
lineHeight: "1.6",
|
||||
color: "#111827",
|
||||
fontFamily: "inherit",
|
||||
background: "transparent",
|
||||
boxSizing: "border-box",
|
||||
minHeight: 40,
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
padding: "6px 10px 8px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setEditValue(message.content); setEditing(false); }}
|
||||
style={{
|
||||
padding: "4px 12px", borderRadius: 6, border: "1px solid #d1d5db",
|
||||
background: "#fff", color: "#374151", fontSize: 13, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editValue.trim()}
|
||||
style={{
|
||||
padding: "4px 12px", borderRadius: 6, border: "none",
|
||||
background: editValue.trim() ? "#1677ff" : "#f3f4f6",
|
||||
color: editValue.trim() ? "#fff" : "#9ca3af",
|
||||
fontSize: 13, fontWeight: 500, cursor: editValue.trim() ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Save & Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", width: "100%" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, maxWidth: "72%" }}>
|
||||
{/* Edit button — appears on hover, to the left of the bubble */}
|
||||
{hovered && !isStreaming && onEdit && (
|
||||
<Tooltip title="Edit message">
|
||||
<button
|
||||
onClick={() => { setEditValue(message.content); setEditing(true); }}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
padding: "4px 6px", borderRadius: 5,
|
||||
color: "#9ca3af", fontSize: 13, flexShrink: 0,
|
||||
display: "flex", alignItems: "center",
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#6b7280"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#f0f2f5",
|
||||
borderRadius: 16,
|
||||
padding: "10px 14px",
|
||||
fontSize: 14,
|
||||
lineHeight: "1.6",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantBubbleProps {
|
||||
message: ChatMessage;
|
||||
isLastMessage: boolean;
|
||||
isStreaming: boolean;
|
||||
isTypingIndicator: boolean;
|
||||
}
|
||||
|
||||
function AssistantBubble({
|
||||
message,
|
||||
isLastMessage,
|
||||
isStreaming,
|
||||
isTypingIndicator,
|
||||
}: AssistantBubbleProps) {
|
||||
// Ref to control ReasoningContent collapse on streaming end.
|
||||
// ReasoningContent manages its own expanded state; we use a key to
|
||||
// remount it (collapsed by default) when streaming finishes.
|
||||
const reasoningKeyRef = useRef<number>(0);
|
||||
const prevStreamingRef = useRef<boolean>(isStreaming);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevStreamingRef.current && !isStreaming) {
|
||||
// Streaming just stopped — bump the key to remount ReasoningContent
|
||||
// with isExpanded default (false won't work since it starts expanded).
|
||||
// ReasoningContent always starts expanded on mount; we accept that
|
||||
// behaviour and leave collapse-on-finish as a best-effort remount.
|
||||
reasoningKeyRef.current += 1;
|
||||
}
|
||||
prevStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
const showReasoningPlaceholder =
|
||||
isLastMessage && isStreaming && !message.reasoningContent;
|
||||
|
||||
const showReasoning =
|
||||
!!message.reasoningContent || showReasoningPlaceholder;
|
||||
|
||||
if (isTypingIndicator) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "10px 4px" }}>
|
||||
<TypingDots />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split content at trailing "[stopped]"
|
||||
let mainContent = message.content;
|
||||
let stoppedSuffix = false;
|
||||
if (mainContent.endsWith("[stopped]")) {
|
||||
mainContent = mainContent.slice(0, -"[stopped]".length);
|
||||
stoppedSuffix = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", maxWidth: "80%" }}>
|
||||
{showReasoning && (
|
||||
showReasoningPlaceholder ? (
|
||||
<ThinkingPlaceholder />
|
||||
) : (
|
||||
<ReasoningContent
|
||||
key={reasoningKeyRef.current}
|
||||
reasoningContent={message.reasoningContent!}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: "1.7",
|
||||
color: "#111827",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code: MarkdownCodeRenderer as React.ComponentType<React.ComponentPropsWithoutRef<"code">>,
|
||||
}}
|
||||
>
|
||||
{mainContent}
|
||||
</ReactMarkdown>
|
||||
{stoppedSuffix && (
|
||||
<span style={{ color: "#9ca3af", fontStyle: "italic" }}> [stopped]</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CopyButton text={mainContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 6 }}>
|
||||
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "4px 6px",
|
||||
borderRadius: 5,
|
||||
color: copied ? "#52c41a" : "#9ca3af",
|
||||
fontSize: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#6b7280";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPlaceholder() {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.chat-thinking-text {
|
||||
animation: thinking-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
marginBottom: 8,
|
||||
backgroundColor: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
<span className="chat-thinking-text">Thinking...</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TypingDots() {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes chat-typing-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
.chat-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: #9ca3af;
|
||||
animation: chat-typing-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.chat-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
`}</style>
|
||||
<div className="chat-dot" />
|
||||
<div className="chat-dot" />
|
||||
<div className="chat-dot" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCardProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
function ToolCard({ message }: ToolCardProps) {
|
||||
const redactedArgs =
|
||||
message.toolArgs ? redactSensitiveValues(message.toolArgs) : undefined;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "80%" }}>
|
||||
<Collapse
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: "#fafafa",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Panel
|
||||
header={
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
|
||||
<ToolOutlined style={{ color: "#6b7280" }} />
|
||||
<span style={{ color: "#374151", fontWeight: 500 }}>
|
||||
{message.toolName ?? "Tool call"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
key="tool"
|
||||
>
|
||||
{redactedArgs !== undefined && (
|
||||
<div style={{ marginBottom: message.toolResult ? 12 : 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "#9ca3af",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Arguments
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(redactedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.toolResult && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "#9ca3af",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Result
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#374151",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
}}
|
||||
>
|
||||
{message.toolResult}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</Collapse>
|
||||
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------- Main component -------
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
onEditMessage?: (messageId: string, newContent: string) => void;
|
||||
}
|
||||
|
||||
const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage }) => {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scrolling is managed by ChatPage.tsx (scroll lock during streaming,
|
||||
// scroll-to-bottom on new message). No auto-scroll here.
|
||||
|
||||
const lastIndex = messages.length - 1;
|
||||
const lastMsg = messages[lastIndex] ?? null;
|
||||
const isTypingIndicator =
|
||||
isStreaming &&
|
||||
lastMsg !== null &&
|
||||
lastMsg.role === "assistant" &&
|
||||
lastMsg.content === "";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastMessage = idx === lastIndex;
|
||||
|
||||
if (msg.role === "user") {
|
||||
return <UserBubble key={msg.id} message={msg} onEdit={onEditMessage} isStreaming={isStreaming} />;
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
return <ToolCard key={msg.id} message={msg} />;
|
||||
}
|
||||
|
||||
// assistant
|
||||
return (
|
||||
<AssistantBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLastMessage={isLastMessage}
|
||||
isStreaming={isStreaming}
|
||||
isTypingIndicator={isLastMessage && isTypingIndicator}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bottom sentinel for auto-scroll */}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessages;
|
||||
Loading…
Add table
Reference in a new issue