"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; onFocus?: () => void; onBlur?: () => void; } export default function ChatMentionInput({ onChange, onChangeMention, onEnter, placeholder, ref, input, disabledMention, onFocus, onBlur, }: ChatMentionInputProps) { const latestMentions = useRef([]); 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 ( ); } export function ChatMentionInputMentionItem({ id, className, }: { id: string; className?: string; }) { const item = useMemo(() => JSON.parse(id) as ChatMention, [id]); const label = useMemo(() => { return (
{toAny(item).label || item.name}
); }, [item]); return ( {label} {item.description || "mention"} ); } 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: , suffix: selectedIds?.includes(mcpId) ? ( ) : ( {mcp.toolInfo?.length} tools ), }); } // 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: , suffix: selectedIds?.includes(toolId) && ( ), }; }) || []; 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: ( {agent.name.slice(0, 1)} ), suffix: selectedIds?.includes(id) && ( ), }; }); }, [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: ( {workflow.name.slice(0, 1)} ), suffix: selectedIds?.includes(id) && ( ), }; }); }, [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 = ; 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) && ( ), }; }); }, [selectedIds, disabledType, searchValue]); const trigger = useMemo(() => { if (children) return children; return ( ); }, [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 ( { !f && onClose(); onOpenChange?.(f); }} > {trigger}
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 />
{allMentions.length === 0 ? (
{searchValue ? t("noResults") : "Type @ to see available mentions"}
{searchValue && (
No results found for "{searchValue}"
)}
) : isMobile ? ( // Mobile vertical layout
{groupedMentions.agent.items.length > 0 && (
{groupedMentions.agent.title}
{groupedMentions.agent.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> ))}
)} {groupedMentions.workflow.items.length > 0 && (
{groupedMentions.workflow.title}
{groupedMentions.workflow.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> ))}
)} {groupedMentions.defaultTool.items.length > 0 && (
{groupedMentions.defaultTool.title}
{groupedMentions.defaultTool.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> ))}
)} {groupedMentions.mcp.items.length > 0 && (
{groupedMentions.mcp.title}
{groupedMentions.mcp.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> ))}
)}
) : ( // Desktop horizontal layout
{/* Agents & Workflows Column */}
{groupedMentions.agent.title}
{groupedMentions.agent.items.length > 0 ? ( groupedMentions.agent.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> )) ) : (
No agents found
)}
{groupedMentions.workflow.title}
{groupedMentions.workflow.items.length > 0 ? ( groupedMentions.workflow.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> )) ) : (
No workflows found
)}
{/* MCP Tools Column */}
{groupedMentions.mcp.title}
{groupedMentions.mcp.items.length > 0 ? ( groupedMentions.mcp.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> )) ) : (
No MCP tools found
)}
{/* Default Tools Column */}
{groupedMentions.defaultTool.title}
{groupedMentions.defaultTool.items.length > 0 ? ( groupedMentions.defaultTool.items.map((item) => ( { itemRefs.current[item.id] = el; }} /> )) ) : (
No app tools found
)}
)}
); } const MentionItem = React.forwardRef< HTMLButtonElement, { item: MentionItemType; isSelected: boolean } >(({ item, isSelected }, ref) => { return ( ); }); MentionItem.displayName = "MentionItem";