better-chatbot / src /components /chat-mention-input.tsx
Bot
Initial commit for HF Spaces
05c5ed5
Raw
History Blame Contribute Delete
28 kB
"use client";
import React, {
RefObject,
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { CheckIcon, HammerIcon, SearchIcon } from "lucide-react";
import { MCPIcon } from "ui/mcp-icon";
import { ChatMention } from "app-types/chat";
import MentionInput from "./mention-input";
import { useTranslations } from "next-intl";
import { Popover, PopoverContent, PopoverTrigger } from "ui/popover";
import { appStore } from "@/app/store";
import { cn, toAny } from "lib/utils";
import { useShallow } from "zustand/shallow";
import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar";
import { Editor } from "@tiptap/react";
import { DefaultToolName } from "lib/ai/tools";
import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip";
import { DefaultToolIcon } from "./default-tool-icon";
import equal from "lib/equal";
import { EMOJI_DATA } from "lib/const";
import { useIsMobile } from "@/hooks/use-mobile";
type MentionItemType = {
id: string;
type: string;
label: string;
onSelect: () => void;
icon: React.ReactNode;
suffix?: React.ReactNode;
};
interface ChatMentionInputProps {
onChange: (text: string) => void;
onChangeMention: (mentions: ChatMention[]) => void;
onEnter?: () => void;
placeholder?: string;
input: string;
disabledMention?: boolean;
ref?: RefObject<Editor | null>;
onFocus?: () => void;
onBlur?: () => void;
}
export default function ChatMentionInput({
onChange,
onChangeMention,
onEnter,
placeholder,
ref,
input,
disabledMention,
onFocus,
onBlur,
}: ChatMentionInputProps) {
const latestMentions = useRef<string[]>([]);
const handleChange = useCallback(
({
text,
mentions,
}: { text: string; mentions: { label: string; id: string }[] }) => {
onChange(text);
const mentionsIds = mentions.map((mention) => mention.id);
const parsedMentions = mentionsIds.map(
(id) => JSON.parse(id) as ChatMention,
);
if (equal(latestMentions.current, mentionsIds)) return;
latestMentions.current = mentionsIds;
onChangeMention(parsedMentions);
},
[onChange, onChangeMention],
);
return (
<MentionInput
content={input}
onEnter={onEnter}
placeholder={placeholder}
suggestionChar="@"
disabledMention={disabledMention}
onChange={handleChange}
MentionItem={ChatMentionInputMentionItem}
Suggestion={ChatMentionInputSuggestion}
editorRef={ref}
onFocus={onFocus}
onBlur={onBlur}
fullWidthSuggestion={true}
/>
);
}
export function ChatMentionInputMentionItem({
id,
className,
}: {
id: string;
className?: string;
}) {
const item = useMemo(() => JSON.parse(id) as ChatMention, [id]);
const label = useMemo(() => {
return (
<div
className={cn(
"flex items-center text-sm px-2 py-0.5 rounded-sm font-semibold transition-colors",
"text-primary font-bold bg-primary/5",
className,
)}
>
{toAny(item).label || item.name}
</div>
);
}, [item]);
return (
<Tooltip>
<TooltipTrigger asChild>{label}</TooltipTrigger>
<TooltipContent className="p-4 whitespace-pre-wrap max-w-xs">
{item.description || "mention"}
</TooltipContent>
</Tooltip>
);
}
export function ChatMentionInputSuggestion({
onSelectMention,
onClose,
top,
left,
selectedIds,
className,
open,
onOpenChange,
children,
style,
disabledType,
}: {
onClose: () => void;
onSelectMention: (item: { label: string; id: string }) => void;
top: number;
left: number;
className?: string;
selectedIds?: string[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
style?: React.CSSProperties;
disabledType?: ("mcp" | "workflow" | "defaultTool" | "agent")[];
}) {
const t = useTranslations("Common");
const [mcpList, workflowList, agentList] = appStore(
useShallow((state) => [
state.mcpList,
state.workflowToolList,
state.agentList,
]),
);
const [searchValue, setSearchValue] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({});
const isMobile = useIsMobile();
const mcpMentions = useMemo(() => {
if (disabledType?.includes("mcp")) return [];
const filtered = mcpList
?.filter((mcp) => mcp.toolInfo?.length)
.filter((mcp) => {
if (!searchValue) return true;
const search = searchValue.toLowerCase();
return (
mcp.name.toLowerCase().includes(search) ||
mcp.toolInfo?.some((tool) => tool.name.toLowerCase().includes(search))
);
});
return (
filtered?.flatMap((mcp) => {
const mcpId = JSON.stringify({
type: "mcpServer",
name: mcp.name,
serverId: mcp.id,
description: `${mcp.name} is an MCP server that includes ${mcp.toolInfo?.length ?? 0} tool(s).`,
toolCount: mcp.toolInfo?.length ?? 0,
});
const items: MentionItemType[] = [];
// Add MCP server item
if (
!searchValue ||
mcp.name.toLowerCase().includes(searchValue.toLowerCase())
) {
items.push({
id: `${mcp.id}-mcp`,
type: "mcp",
label: mcp.name,
onSelect: () =>
onSelectMention({
label: `mcp("${mcp.name}")`,
id: mcpId,
}),
icon: <MCPIcon className="size-3.5 text-foreground" />,
suffix: selectedIds?.includes(mcpId) ? (
<CheckIcon className="size-3 ml-auto" />
) : (
<span className="ml-auto text-xs text-muted-foreground">
{mcp.toolInfo?.length} tools
</span>
),
});
}
// Add tool items
const toolItems =
mcp.toolInfo
?.filter(
(tool) =>
!searchValue ||
tool.name.toLowerCase().includes(searchValue.toLowerCase()),
)
.map((tool) => {
const toolId = JSON.stringify({
type: "mcpTool",
name: tool.name,
serverId: mcp.id,
description: tool.description,
serverName: mcp.name,
});
return {
id: `${mcp.id}-${tool.name}`,
type: "mcpTool",
label: tool.name,
onSelect: () =>
onSelectMention({
label: `tool("${tool.name}") `,
id: toolId,
}),
icon: <HammerIcon className="size-3.5" />,
suffix: selectedIds?.includes(toolId) && (
<CheckIcon className="size-3 ml-auto" />
),
};
}) || [];
return [...items, ...toolItems];
}) || []
);
}, [mcpList, selectedIds, disabledType, searchValue]);
const agentMentions = useMemo(() => {
if (disabledType?.includes("agent")) return [];
if (!agentList.length) return [];
return agentList
.filter(
(agent) =>
!searchValue ||
agent.name.toLowerCase().includes(searchValue.toLowerCase()),
)
.map((agent, i) => {
const id = JSON.stringify({
type: "agent",
name: agent.name,
agentId: agent.id,
description: agent.description,
icon: agent.icon,
});
return {
id: agent.id,
type: "agent",
label: agent.name,
onSelect: () =>
onSelectMention({
label: `agent("${agent.name}")`,
id,
}),
icon: (
<Avatar
style={agent.icon?.style}
className="size-3.5 ring-[1px] ring-input rounded-full"
>
<AvatarImage
src={agent.icon?.value || EMOJI_DATA[i % EMOJI_DATA.length]}
/>
<AvatarFallback>{agent.name.slice(0, 1)}</AvatarFallback>
</Avatar>
),
suffix: selectedIds?.includes(id) && (
<CheckIcon className="size-3 ml-auto" />
),
};
});
}, [agentList, selectedIds, disabledType, searchValue]);
const workflowMentions = useMemo(() => {
if (disabledType?.includes("workflow")) return [];
if (!workflowList.length) return [];
return workflowList
.filter(
(workflow) =>
!searchValue ||
workflow.name.toLowerCase().includes(searchValue.toLowerCase()),
)
.map((workflow) => {
const id = JSON.stringify({
type: "workflow",
name: workflow.name,
workflowId: workflow.id,
icon: workflow.icon,
description: workflow.description,
});
return {
id: workflow.id,
type: "workflow",
label: workflow.name,
onSelect: () =>
onSelectMention({
label: `tool("${workflow.name}")`,
id,
}),
icon: (
<Avatar
style={workflow.icon?.style}
className="size-3.5 ring-[1px] ring-input rounded-full"
>
<AvatarImage src={workflow.icon?.value} />
<AvatarFallback>{workflow.name.slice(0, 1)}</AvatarFallback>
</Avatar>
),
suffix: selectedIds?.includes(id) && (
<CheckIcon className="size-3 ml-auto" />
),
};
});
}, [workflowList, selectedIds, disabledType, searchValue]);
const defaultToolMentions = useMemo(() => {
if (disabledType?.includes("defaultTool")) return [];
const items = Object.values(DefaultToolName).map((toolName) => {
let label = toolName as string;
const icon = <DefaultToolIcon name={toolName} />;
let description = "";
switch (toolName) {
case DefaultToolName.CreatePieChart:
label = "pie-chart";
description = "Create a pie chart";
break;
case DefaultToolName.CreateBarChart:
label = "bar-chart";
description = "Create a bar chart";
break;
case DefaultToolName.CreateLineChart:
label = "line-chart";
description = "Create a line chart";
break;
case DefaultToolName.CreateTable:
label = "table";
description = "Create a table";
break;
case DefaultToolName.WebSearch:
label = "web-search";
description = "Search the web";
break;
case DefaultToolName.WebContent:
label = "web-content";
description = "Get the content of a web page";
break;
case DefaultToolName.Http:
label = "HTTP";
description = "Send an http request";
break;
case DefaultToolName.JavascriptExecution:
label = "js-execution";
description = "Execute simple javascript code";
break;
case DefaultToolName.PythonExecution:
label = "python-execution";
description = "Execute simple python code";
break;
}
return {
id: toolName,
label,
icon,
description,
};
});
return items
.filter(
(item) =>
!searchValue ||
item.label.toLowerCase().includes(searchValue.toLowerCase()),
)
.map((item) => {
const id = JSON.stringify({
type: "defaultTool",
name: item.id,
label: item.label,
description: item.description,
});
return {
id: item.id,
type: "defaultTool",
label: item.label,
onSelect: () =>
onSelectMention({
label: `tool('${item.label}')`,
id,
}),
icon: item.icon,
suffix: selectedIds?.includes(id) && (
<CheckIcon className="size-3 ml-auto" />
),
};
});
}, [selectedIds, disabledType, searchValue]);
const trigger = useMemo(() => {
if (children) return children;
return (
<span
className="fixed z-50"
style={{
top,
left,
}}
></span>
);
}, [children, top, left]);
// Combine all mentions
const allMentions = useMemo(() => {
return [
...agentMentions,
...workflowMentions,
...defaultToolMentions,
...mcpMentions,
];
}, [agentMentions, workflowMentions, defaultToolMentions, mcpMentions]);
// Reset selected index when mentions change
useEffect(() => {
setSelectedIndex(0);
}, [allMentions.length]);
// Scroll selected item into view
useEffect(() => {
const selectedItem = allMentions[selectedIndex];
if (selectedItem && itemRefs.current[selectedItem.id]) {
itemRefs.current[selectedItem.id]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex, allMentions]);
// Group mentions by type
const groupedMentions = useMemo(() => {
const groups = {
agent: { title: "Agents", items: [] as MentionItemType[] },
workflow: { title: "Workflows", items: [] as MentionItemType[] },
defaultTool: { title: "App Tools", items: [] as MentionItemType[] },
mcp: { title: "MCP Tools", items: [] as MentionItemType[] },
mcpTool: { title: "MCP Tools", items: [] as MentionItemType[] },
};
allMentions.forEach((mention) => {
if (mention.type === "mcpTool") {
groups.mcp.items.push(mention);
} else if (groups[mention.type as keyof typeof groups]) {
groups[mention.type as keyof typeof groups].items.push(mention);
}
});
return groups;
}, [allMentions]);
return (
<Popover
open={open ?? true}
onOpenChange={(f) => {
!f && onClose();
onOpenChange?.(f);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className={cn("p-0", className)}
align="start"
side="top"
style={{
...style,
width: style?.width || (isMobile ? "100%" : "auto"),
minWidth: isMobile ? undefined : "600px",
maxWidth: isMobile ? undefined : "800px",
}}
>
<div className="flex flex-col">
<div className="flex items-center gap-2 px-3 py-2 border-b">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<input
className="flex h-8 w-full rounded-md bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t("search")}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Backspace" && !e.currentTarget.value) {
onClose();
}
if (e.key === "Enter" && allMentions.length > 0) {
e.preventDefault();
allMentions[selectedIndex].onSelect();
}
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) =>
prev < allMentions.length - 1 ? prev + 1 : 0,
);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : allMentions.length - 1,
);
}
if (
!isMobile &&
(e.key === "ArrowLeft" || e.key === "ArrowRight")
) {
e.preventDefault();
// Calculate column navigation
const currentItem = allMentions[selectedIndex];
const currentType =
currentItem.type === "mcpTool" ? "mcp" : currentItem.type;
const typeOrder = ["agent", "workflow", "mcp", "defaultTool"];
const currentTypeIndex = typeOrder.indexOf(currentType);
if (e.key === "ArrowLeft" && currentTypeIndex > 0) {
const prevType = typeOrder[currentTypeIndex - 1];
const prevTypeItems = allMentions.filter(
(item) =>
item.type === prevType ||
(prevType === "mcp" && item.type === "mcpTool"),
);
if (prevTypeItems.length > 0) {
setSelectedIndex(allMentions.indexOf(prevTypeItems[0]));
}
} else if (
e.key === "ArrowRight" &&
currentTypeIndex < typeOrder.length - 1
) {
const nextType = typeOrder[currentTypeIndex + 1];
const nextTypeItems = allMentions.filter(
(item) =>
item.type === nextType ||
(nextType === "mcp" && item.type === "mcpTool"),
);
if (nextTypeItems.length > 0) {
setSelectedIndex(allMentions.indexOf(nextTypeItems[0]));
}
}
}
}}
autoFocus
/>
</div>
<div
className={cn(
"overflow-hidden",
isMobile ? "max-h-[50vh]" : "h-[300px]",
)}
>
{allMentions.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground p-8">
<div className="text-center">
<div className="mb-2">
{searchValue
? t("noResults")
: "Type @ to see available mentions"}
</div>
{searchValue && (
<div className="text-xs opacity-60">
No results found for &quot;{searchValue}&quot;
</div>
)}
</div>
</div>
) : isMobile ? (
// Mobile vertical layout
<div className="overflow-y-auto max-h-[50vh]">
{groupedMentions.agent.items.length > 0 && (
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.agent.title}
</div>
<div className="space-y-1">
{groupedMentions.agent.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))}
</div>
</div>
)}
{groupedMentions.workflow.items.length > 0 && (
<div className="p-2 border-t">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.workflow.title}
</div>
<div className="space-y-1">
{groupedMentions.workflow.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))}
</div>
</div>
)}
{groupedMentions.defaultTool.items.length > 0 && (
<div className="p-2 border-t">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.defaultTool.title}
</div>
<div className="space-y-1">
{groupedMentions.defaultTool.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))}
</div>
</div>
)}
{groupedMentions.mcp.items.length > 0 && (
<div className="p-2 border-t">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.mcp.title}
</div>
<div className="space-y-1">
{groupedMentions.mcp.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))}
</div>
</div>
)}
</div>
) : (
// Desktop horizontal layout
<div className="flex flex-1 h-[300px]">
{/* Agents & Workflows Column */}
<div className="flex-1 border-r overflow-y-auto">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.agent.title}
</div>
<div className="space-y-1">
{groupedMentions.agent.items.length > 0 ? (
groupedMentions.agent.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))
) : (
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
No agents found
</div>
)}
</div>
</div>
<div className="p-2 border-t">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.workflow.title}
</div>
<div className="space-y-1">
{groupedMentions.workflow.items.length > 0 ? (
groupedMentions.workflow.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))
) : (
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
No workflows found
</div>
)}
</div>
</div>
</div>
{/* MCP Tools Column */}
<div className="flex-1 border-r overflow-y-auto">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.mcp.title}
</div>
<div className="space-y-1">
{groupedMentions.mcp.items.length > 0 ? (
groupedMentions.mcp.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))
) : (
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
No MCP tools found
</div>
)}
</div>
</div>
</div>
{/* Default Tools Column */}
<div className="flex-1 overflow-y-auto">
<div className="p-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
{groupedMentions.defaultTool.title}
</div>
<div className="space-y-1">
{groupedMentions.defaultTool.items.length > 0 ? (
groupedMentions.defaultTool.items.map((item) => (
<MentionItem
key={item.id}
item={item}
isSelected={
allMentions[selectedIndex]?.id === item.id
}
ref={(el) => {
itemRefs.current[item.id] = el;
}}
/>
))
) : (
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
No app tools found
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
const MentionItem = React.forwardRef<
HTMLButtonElement,
{ item: MentionItemType; isSelected: boolean }
>(({ item, isSelected }, ref) => {
return (
<button
ref={ref}
className={cn(
"flex items-center gap-2 w-full rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground cursor-pointer",
isSelected && "bg-accent text-accent-foreground",
)}
onClick={() => item.onSelect()}
>
{item.icon}
<span className="truncate min-w-0">{item.label}</span>
{item.suffix}
</button>
);
});
MentionItem.displayName = "MentionItem";