import capitalize from "lodash/capitalize";
import { GripVertical, MinusCircleIcon } from "lucide-react";
import { memo, useState, useCallback } from "react";
import {
type ChatMessage,
ChatMessageRole,
ChatMessageType,
type ChatMessageWithId,
type LLMToolCall,
type PlaceholderMessage,
} from "@langfuse/shared";
import { Button } from "@/src/components/ui/button";
import { Card, CardContent } from "@/src/components/ui/card";
import { CodeMirrorEditor } from "@/src/components/editor";
import type { MessagesContext } from "./types";
import { useSortable } from "@dnd-kit/sortable";
import { cn } from "@/src/utils/tailwind";
import { CSS } from "@dnd-kit/utilities";
import { ToolCallCard } from "./ToolCallCard";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
type ChatMessageProps = Pick<
MessagesContext,
| "deleteMessage"
| "updateMessage"
| "availableRoles"
| "toolCallIds"
| "replaceMessage"
> & { message: ChatMessageWithId; index: number };
const ROLES: ChatMessageRole[] = [
ChatMessageRole.User,
ChatMessageRole.System,
ChatMessageRole.Developer,
ChatMessageRole.Assistant,
ChatMessageRole.Tool,
] as const;
const getRoleNamePlaceholder = (role: string) => {
switch (role) {
case ChatMessageRole.System:
return "a system message";
case ChatMessageRole.Developer:
return "a developer message";
case ChatMessageRole.Assistant:
return "an assistant message";
case ChatMessageRole.User:
return "a user message";
case ChatMessageRole.Tool:
return "a tool response message";
case "placeholder":
return "placeholder name (e.g. chat_history)";
default:
return `a ${role}`;
}
};
const ToolCalls: React.FC<{ toolCalls: LLMToolCall[] }> = ({ toolCalls }) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
{toolCalls.map((toolCall) => (
))}
);
};
export const ChatMessageComponent: React.FC = ({
message,
updateMessage,
deleteMessage,
replaceMessage,
availableRoles,
index: _index,
toolCallIds,
}) => {
const [roleIndex, setRoleIndex] = useState(1);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: message.id });
const toggleRole = () => {
// Only allow role toggling for messages that have a role property (not placeholder messages)
if (!("role" in message)) return;
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
if (!!availableRoles && Boolean(availableRoles.length)) {
let randomRole = availableRoles[roleIndex % availableRoles.length];
if (randomRole === message.role) {
randomRole = availableRoles[(roleIndex + 1) % availableRoles.length];
}
replaceMessage(message.id, {
content: message.content,
role: randomRole,
type: ChatMessageType.PublicAPICreated,
});
setRoleIndex(roleIndex + 1);
} else {
// if user has not set custom roles, we toggle through default roles (assistant, user)
// Allow all roles including system and developer at any position
const eligibleRoles = ROLES.filter(
(r) =>
r !== ChatMessageRole.Tool || (toolCallIds && toolCallIds.length > 0),
);
const currentIndex = eligibleRoles.indexOf(
("role" in message
? message.role
: ChatMessageRole.User) as ChatMessageRole,
);
const nextRole = eligibleRoles[(currentIndex + 1) % eligibleRoles.length];
if (nextRole === ChatMessageRole.User) {
replaceMessage(message.id, {
content: message.content,
role: nextRole,
type: ChatMessageType.User,
});
} else if (nextRole === ChatMessageRole.Assistant) {
replaceMessage(message.id, {
content: message.content,
role: nextRole,
type: ChatMessageType.AssistantText,
});
} else if (nextRole === ChatMessageRole.Tool) {
replaceMessage(message.id, {
content: message.content,
role: nextRole,
type: ChatMessageType.ToolResult,
toolCallId: toolCallIds?.[0] ?? "",
});
} else if (nextRole === ChatMessageRole.Developer) {
replaceMessage(message.id, {
content: message.content,
role: nextRole,
type: ChatMessageType.Developer,
});
} else if (nextRole === ChatMessageRole.System) {
replaceMessage(message.id, {
content: message.content,
role: nextRole,
type: ChatMessageType.System,
});
} else if (nextRole === ChatMessageRole.Model) {
replaceMessage(message.id, {
content: message.content,
role: nextRole,
type: ChatMessageType.ModelText,
});
} else {
const exhaustiveCheck: never = nextRole;
console.error(`Unhandled role: ${exhaustiveCheck}`);
}
}
};
const onValueChange = useCallback(
(value: string) => {
if (message.type === ChatMessageType.Placeholder) {
updateMessage(message.type, message.id, "name", value);
} else {
updateMessage(message.type, message.id, "content", value);
}
},
[message.id, message.type, updateMessage],
);
const onPlaceholderNameChange = useCallback(
(value: string) => {
if (message.type === ChatMessageType.Placeholder) {
updateMessage(message.type, message.id, "name", value);
}
},
[message.id, message.type, updateMessage],
);
const showToolCallSelect = message.type === ChatMessageType.ToolResult;
const isPlaceholder = message.type === ChatMessageType.Placeholder;
return (
{isPlaceholder ? (
placeholder
) : (
)}
{showToolCallSelect && (
)}
{isPlaceholder ? (
) : (
)}
{message.type === ChatMessageType.AssistantToolCall && (
)}
);
};
const MemoizedEditor = memo(function MemoizedEditor(props: {
value: string;
role: ChatMessage["role"];
onChange: (value: string) => void;
}) {
const { value, role, onChange } = props;
const placeholder = `Enter ${getRoleNamePlaceholder(role)} here.`;
return (
);
});