chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
54
web/components/chat/agent-activity-bubble.tsx
Normal file
54
web/components/chat/agent-activity-bubble.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { BotIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { ConversationMessageRow } from "@/components/chat/conversation-message-row"
|
||||
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { getAgentActivityLabel } from "@/lib/agent-activity-label"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type AgentActivityBubbleProps = {
|
||||
stepCode: string
|
||||
toolName?: string
|
||||
className?: string
|
||||
isCustomer?: boolean
|
||||
}
|
||||
|
||||
export const AgentActivityBubble = memo(function AgentActivityBubble({
|
||||
stepCode,
|
||||
toolName,
|
||||
className,
|
||||
isCustomer = true,
|
||||
}: AgentActivityBubbleProps) {
|
||||
const t = useI18n()
|
||||
|
||||
const label = getAgentActivityLabel({ stepCode, toolName, isCustomer }, t)
|
||||
|
||||
return (
|
||||
<ConversationMessageRow
|
||||
align="start"
|
||||
avatar={
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
<BotIcon aria-hidden="true" className="size-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
}
|
||||
contentClassName="max-w-[86%] gap-1.5"
|
||||
className={cn("my-1 animate-in fade-in-50 duration-200", className)}
|
||||
>
|
||||
<ConversationMessageBubble
|
||||
variant="system"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-center gap-2 rounded-lg border border-solid border-border/60 bg-muted/50 px-3 py-2 text-xs text-muted-foreground shadow-sm"
|
||||
>
|
||||
<Loader2Icon aria-hidden="true" className="size-3.5 shrink-0 animate-spin text-primary" />
|
||||
<span className="font-medium text-foreground/80">{label}</span>
|
||||
</ConversationMessageBubble>
|
||||
</ConversationMessageRow>
|
||||
)
|
||||
})
|
||||
64
web/components/chat/conversation-message-bubble.tsx
Normal file
64
web/components/chat/conversation-message-bubble.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Bubble, BubbleContent } from "@/components/ui/bubble"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ConversationMessageVariant =
|
||||
| "customer"
|
||||
| "agent"
|
||||
| "ai"
|
||||
| "system"
|
||||
| "recalled"
|
||||
|
||||
type ConversationMessageBubbleProps = React.ComponentProps<"div"> & {
|
||||
variant: ConversationMessageVariant
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function getBubbleVariant(variant: ConversationMessageVariant) {
|
||||
switch (variant) {
|
||||
case "customer":
|
||||
return "secondary" as const
|
||||
case "system":
|
||||
return "muted" as const
|
||||
case "ai":
|
||||
return "tinted" as const
|
||||
case "agent":
|
||||
return "default" as const
|
||||
case "recalled":
|
||||
return "outline" as const
|
||||
default:
|
||||
return "secondary" as const
|
||||
}
|
||||
}
|
||||
|
||||
function getContentClassName(variant: ConversationMessageVariant) {
|
||||
switch (variant) {
|
||||
case "agent":
|
||||
return "bg-emerald-600 text-white"
|
||||
case "system":
|
||||
return "border-dashed text-muted-foreground"
|
||||
case "recalled":
|
||||
return "border-dashed bg-muted/40 text-muted-foreground"
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function ConversationMessageBubble({
|
||||
variant,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ConversationMessageBubbleProps) {
|
||||
return (
|
||||
<Bubble variant={getBubbleVariant(variant)} {...props}>
|
||||
<BubbleContent className={cn("rounded-2xl px-4 py-3 leading-6", getContentClassName(variant), className)}>
|
||||
{children}
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
)
|
||||
}
|
||||
109
web/components/chat/conversation-message-row.tsx
Normal file
109
web/components/chat/conversation-message-row.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import {
|
||||
Message,
|
||||
MessageAvatar,
|
||||
MessageContent,
|
||||
MessageFooter,
|
||||
MessageHeader,
|
||||
} from "@/components/ui/message"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ConversationMessageRowProps = {
|
||||
align: "start" | "end"
|
||||
centered?: boolean
|
||||
header?: ReactNode
|
||||
footer?: ReactNode
|
||||
avatar?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
avatarClassName?: string
|
||||
contentClassName?: string
|
||||
headerClassName?: string
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
export function ConversationMessageRow({
|
||||
align,
|
||||
centered = false,
|
||||
header,
|
||||
footer,
|
||||
avatar,
|
||||
children,
|
||||
className,
|
||||
avatarClassName,
|
||||
contentClassName,
|
||||
headerClassName,
|
||||
footerClassName,
|
||||
}: ConversationMessageRowProps) {
|
||||
if (centered) {
|
||||
return (
|
||||
<Message
|
||||
align="start"
|
||||
className={cn("justify-center", className)}
|
||||
>
|
||||
<MessageContent className={cn("w-fit max-w-[85%] items-center", contentClassName)}>
|
||||
{header ? (
|
||||
<MessageHeader className={cn("justify-center text-center", headerClassName)}>
|
||||
{header}
|
||||
</MessageHeader>
|
||||
) : null}
|
||||
{children}
|
||||
{footer ? (
|
||||
<MessageFooter className={cn("justify-center text-center", footerClassName)}>
|
||||
{footer}
|
||||
</MessageFooter>
|
||||
) : null}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Message align={align} className={className}>
|
||||
{avatar ? (
|
||||
<MessageAvatar
|
||||
className={cn(
|
||||
"self-start group-has-data-[slot=message-footer]/message:translate-y-0 bg-transparent",
|
||||
avatarClassName,
|
||||
)}
|
||||
>
|
||||
{avatar}
|
||||
</MessageAvatar>
|
||||
) : null}
|
||||
<MessageContent
|
||||
className={cn(
|
||||
"max-w-[85%]",
|
||||
align === "end" ? "items-end" : "items-start",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{header ? (
|
||||
<MessageHeader
|
||||
className={cn(
|
||||
"gap-2 px-0",
|
||||
align === "end" ? "justify-end text-right" : "justify-start text-left",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</MessageHeader>
|
||||
) : null}
|
||||
{children}
|
||||
{footer ? (
|
||||
<MessageFooter
|
||||
className={cn(
|
||||
"gap-2 px-0",
|
||||
align === "end" ? "justify-end text-right" : "justify-start text-left",
|
||||
footerClassName,
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</MessageFooter>
|
||||
) : null}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
151
web/components/chat/conversation-message-scroller.tsx
Normal file
151
web/components/chat/conversation-message-scroller.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
type UIEvent,
|
||||
} from "react"
|
||||
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
} from "@/components/ui/message-scroller"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ConversationMessageScrollerHandle = {
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
type ConversationMessageScrollerProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
viewportClassName?: string
|
||||
contentClassName?: string
|
||||
hasMoreOlder?: boolean
|
||||
loadingOlder?: boolean
|
||||
onLoadOlder?: () => void | Promise<void>
|
||||
onNearBottomChange?: (nearBottom: boolean) => void
|
||||
onNearBottomVisible?: () => void
|
||||
topSlot?: ReactNode
|
||||
scrollThreshold?: number
|
||||
}
|
||||
|
||||
const ConversationMessageScrollerInner = forwardRef<
|
||||
ConversationMessageScrollerHandle,
|
||||
ConversationMessageScrollerProps
|
||||
>(function ConversationMessageScrollerInner(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
viewportClassName,
|
||||
contentClassName,
|
||||
hasMoreOlder = false,
|
||||
loadingOlder = false,
|
||||
onLoadOlder,
|
||||
onNearBottomChange,
|
||||
onNearBottomVisible,
|
||||
topSlot,
|
||||
scrollThreshold = 120,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { scrollToEnd } = useMessageScroller()
|
||||
const scrollable = useMessageScrollerScrollable()
|
||||
const loadingRef = useRef(false)
|
||||
const nearBottomRef = useRef(false)
|
||||
const onLoadOlderRef = useRef(onLoadOlder)
|
||||
const onNearBottomChangeRef = useRef(onNearBottomChange)
|
||||
const onNearBottomVisibleRef = useRef(onNearBottomVisible)
|
||||
|
||||
useEffect(() => {
|
||||
onLoadOlderRef.current = onLoadOlder
|
||||
}, [onLoadOlder])
|
||||
|
||||
useEffect(() => {
|
||||
onNearBottomChangeRef.current = onNearBottomChange
|
||||
}, [onNearBottomChange])
|
||||
|
||||
useEffect(() => {
|
||||
onNearBottomVisibleRef.current = onNearBottomVisible
|
||||
}, [onNearBottomVisible])
|
||||
|
||||
useEffect(() => {
|
||||
loadingRef.current = loadingOlder
|
||||
}, [loadingOlder])
|
||||
|
||||
useEffect(() => {
|
||||
const nearBottom = !scrollable.end
|
||||
nearBottomRef.current = nearBottom
|
||||
onNearBottomChangeRef.current?.(nearBottom)
|
||||
if (nearBottom) {
|
||||
onNearBottomVisibleRef.current?.()
|
||||
}
|
||||
}, [scrollable.end])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom: () => {
|
||||
scrollToEnd({ behavior: "auto" })
|
||||
},
|
||||
}), [scrollToEnd])
|
||||
|
||||
const maybeLoadOlder = useCallback((viewport: HTMLElement) => {
|
||||
if (!hasMoreOlder || loadingRef.current || !onLoadOlderRef.current) {
|
||||
return
|
||||
}
|
||||
if (viewport.scrollTop > scrollThreshold) {
|
||||
return
|
||||
}
|
||||
loadingRef.current = true
|
||||
void Promise.resolve(onLoadOlderRef.current()).finally(() => {
|
||||
loadingRef.current = false
|
||||
})
|
||||
}, [hasMoreOlder, scrollThreshold])
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: UIEvent<HTMLDivElement>) => {
|
||||
maybeLoadOlder(event.currentTarget)
|
||||
},
|
||||
[maybeLoadOlder],
|
||||
)
|
||||
|
||||
return (
|
||||
<MessageScroller className={className}>
|
||||
<MessageScrollerViewport
|
||||
className={cn("agent-desk-scrollbar bg-muted/10", viewportClassName)}
|
||||
onScroll={handleScroll}
|
||||
preserveScrollOnPrepend
|
||||
>
|
||||
<MessageScrollerContent
|
||||
className={cn("gap-4 px-6 py-5", contentClassName)}
|
||||
>
|
||||
{topSlot}
|
||||
{children}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
)
|
||||
})
|
||||
|
||||
export const ConversationMessageScroller = forwardRef<
|
||||
ConversationMessageScrollerHandle,
|
||||
ConversationMessageScrollerProps
|
||||
>(function ConversationMessageScroller(props, ref) {
|
||||
return (
|
||||
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
|
||||
<ConversationMessageScrollerInner {...props} ref={ref} />
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
})
|
||||
|
||||
export { MessageScrollerItem as ConversationMessageScrollerItem }
|
||||
729
web/components/chat/shared-message-editor.tsx
Normal file
729
web/components/chat/shared-message-editor.tsx
Normal file
@@ -0,0 +1,729 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { EditorContent, useEditor } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import {
|
||||
ImageIcon,
|
||||
Loader2Icon,
|
||||
MessageSquareTextIcon,
|
||||
PaperclipIcon,
|
||||
SendHorizonalIcon,
|
||||
SendIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import {
|
||||
buildSendableEditorHTML,
|
||||
hasUploadingEditorImages,
|
||||
markEditorImageUploadedByTitle,
|
||||
MessageImageExtension,
|
||||
removeEditorImageByTitle,
|
||||
revokeEditorObjectUrl,
|
||||
revokeEditorObjectUrls,
|
||||
setEditorImageUploadingByTitle,
|
||||
type UploadedEditorImage,
|
||||
} from "@/lib/im-editor-image"
|
||||
import { generateUUID } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export type UploadedMessageEditorImage = UploadedEditorImage & {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type MessageEditorQuickReply = {
|
||||
id: number
|
||||
groupName?: string
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
type SharedMessageEditorVariant = "customer" | "agent"
|
||||
|
||||
export type SharedMessageEditorAICopilot = {
|
||||
onSuggest: () => Promise<string | null>
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export type SharedMessageEditorProps = {
|
||||
variant: SharedMessageEditorVariant
|
||||
conversationId?: number | null
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
manageLocalUploading?: boolean
|
||||
quickReplies?: {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
items: MessageEditorQuickReply[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
aiCopilot?: SharedMessageEditorAICopilot
|
||||
onSend: (html: string) => Promise<void>
|
||||
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
onTyping?: () => void
|
||||
}
|
||||
|
||||
export function SharedMessageEditor({
|
||||
variant,
|
||||
conversationId,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
manageLocalUploading = false,
|
||||
quickReplies,
|
||||
aiCopilot,
|
||||
onSend,
|
||||
onUploadImage,
|
||||
onSendAttachment,
|
||||
onTyping,
|
||||
}: SharedMessageEditorProps) {
|
||||
const t = useI18n()
|
||||
const [localUploading, setLocalUploading] = useState(false)
|
||||
const [quickReplySearch, setQuickReplySearch] = useState("")
|
||||
const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
|
||||
const quickReplySearchRef = useRef(quickReplySearch)
|
||||
quickReplySearchRef.current = quickReplySearch
|
||||
const isSlashTriggeredRef = useRef(false)
|
||||
const quickRepliesRef = useRef(quickReplies)
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const onSendRef = useRef(onSend)
|
||||
const onUploadImageRef = useRef(onUploadImage)
|
||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||
const onTypingRef = useRef(onTyping)
|
||||
const shouldRestoreFocusRef = useRef(false)
|
||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
|
||||
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
|
||||
const isCustomer = variant === "customer"
|
||||
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
|
||||
|
||||
placeholderRef.current = t("conversation.editorPlaceholder")
|
||||
|
||||
useEffect(() => {
|
||||
quickRepliesRef.current = quickReplies
|
||||
}, [quickReplies])
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current
|
||||
return () => {
|
||||
revokeEditorObjectUrls(objectUrls)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
onSendRef.current = onSend
|
||||
}, [onSend])
|
||||
|
||||
useEffect(() => {
|
||||
onUploadImageRef.current = onUploadImage
|
||||
}, [onUploadImage])
|
||||
|
||||
useEffect(() => {
|
||||
onSendAttachmentRef.current = onSendAttachment
|
||||
}, [onSendAttachment])
|
||||
|
||||
useEffect(() => {
|
||||
onTypingRef.current = onTyping
|
||||
}, [onTyping])
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestedReply(null)
|
||||
}, [conversationId])
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
blockquote: false,
|
||||
codeBlock: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
MessageImageExtension,
|
||||
Placeholder.configure({
|
||||
placeholder: () => placeholderRef.current,
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
onUpdate: ({ editor }) => {
|
||||
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
|
||||
onTypingRef.current?.()
|
||||
}
|
||||
if (!quickRepliesRef.current || quickRepliesRef.current.loading) {
|
||||
return
|
||||
}
|
||||
const { $from } = editor.state.selection
|
||||
const blockText = $from.parent.textContent
|
||||
const trimmed = blockText.trimStart()
|
||||
const match = trimmed.match(/^\/([^\s]*)$/)
|
||||
if (match) {
|
||||
isSlashTriggeredRef.current = true
|
||||
const query = match[1]
|
||||
setQuickReplySearch(query)
|
||||
if (!quickRepliesRef.current.open) {
|
||||
quickRepliesRef.current.onOpenChange(true)
|
||||
}
|
||||
} else if (isSlashTriggeredRef.current && quickRepliesRef.current.open) {
|
||||
isSlashTriggeredRef.current = false
|
||||
setQuickReplySearch("")
|
||||
quickRepliesRef.current.onOpenChange(false)
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: getEditorClassName(variant),
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
if (event.key === "Escape" && quickRepliesRef.current?.open) {
|
||||
event.preventDefault()
|
||||
quickRepliesRef.current.onOpenChange(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
return true
|
||||
}
|
||||
if (
|
||||
(event.key === "Enter" || event.key === "Tab") &&
|
||||
!event.shiftKey &&
|
||||
quickRepliesRef.current?.open
|
||||
) {
|
||||
const query = quickReplySearchRef.current.toLowerCase().trim()
|
||||
const items = quickRepliesRef.current.items
|
||||
const matching = items.filter((item) => {
|
||||
if (!query) return true
|
||||
const target = `${item.groupName ?? ""} ${item.title} ${item.content}`.toLowerCase()
|
||||
return target.includes(query)
|
||||
})
|
||||
if (matching.length > 0) {
|
||||
event.preventDefault()
|
||||
handleInsertQuickReply(matching[0])
|
||||
return true
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (disabled || isUploading) {
|
||||
return false
|
||||
}
|
||||
const imageFile = getClipboardImageFile(event.clipboardData)
|
||||
if (!imageFile) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
void insertUploadedImage(imageFile)
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
editor.setEditable(!disabled && !isUploading)
|
||||
}, [disabled, editor, isUploading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
})
|
||||
}, [disabled, editor, isUploading])
|
||||
|
||||
async function handleSend() {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
const rawHTML = editor.getHTML()
|
||||
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
|
||||
return
|
||||
}
|
||||
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
|
||||
if (!isMeaningfulHTML(html)) {
|
||||
return
|
||||
}
|
||||
await onSendRef.current(html)
|
||||
editor.commands.clearContent(true)
|
||||
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||
uploadedImagesRef.current.clear()
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setSuggestedReply(null)
|
||||
if (!isCustomer) {
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || !editor || disabled || isUploading) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
await insertUploadedImage(file)
|
||||
}
|
||||
|
||||
async function insertUploadedImage(file: File) {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
|
||||
shouldRestoreFocusRef.current = true
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrlsRef.current.add(objectUrl)
|
||||
const placeholderId = `uploading-${generateUUID()}`
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
})
|
||||
.run()
|
||||
setEditorImageUploadingByTitle(editor, placeholderId)
|
||||
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
const uploaded = await onUploadImageRef.current(file)
|
||||
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
|
||||
removeEditorImageByTitle(editor, placeholderId)
|
||||
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||
return
|
||||
}
|
||||
markEditorImageUploadedByTitle(
|
||||
editor,
|
||||
placeholderId,
|
||||
uploaded,
|
||||
uploadedImagesRef.current
|
||||
)
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (!disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || disabled || isUploading) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
await onSendAttachmentRef.current(file)
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (editor && !disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleInsertQuickReply(item: MessageEditorQuickReply) {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
if (!item.content.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { $from } = editor.state.selection
|
||||
const blockText = $from.parent.textContent
|
||||
const trimmed = blockText.trimStart()
|
||||
const match = trimmed.match(/^\/([^\s]*)$/)
|
||||
|
||||
if (match) {
|
||||
const slashIndex = blockText.indexOf("/")
|
||||
const blockStart = $from.start() + slashIndex
|
||||
const matchLen = match[0].length
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: blockStart, to: blockStart + matchLen })
|
||||
.insertContent(item.content)
|
||||
.run()
|
||||
} else {
|
||||
const fullText = editor.getText().trim()
|
||||
const fullMatch = fullText.match(/^\/([^\s]*)$/)
|
||||
if (fullMatch) {
|
||||
editor.chain().focus().setContent(item.content).run()
|
||||
} else {
|
||||
editor.chain().focus().insertContent(item.content).run()
|
||||
}
|
||||
}
|
||||
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
}
|
||||
|
||||
function restoreFocusIfNeeded() {
|
||||
if (editor && shouldRestoreFocusRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
|
||||
|
||||
async function handleTriggerSuggest() {
|
||||
if (!aiCopilot || disabled || isUploading || isGenerating) {
|
||||
return
|
||||
}
|
||||
setIsGeneratingSuggestion(true)
|
||||
try {
|
||||
const text = await aiCopilot.onSuggest()
|
||||
if (text && text.trim()) {
|
||||
setSuggestedReply(text.trim())
|
||||
}
|
||||
} finally {
|
||||
setIsGeneratingSuggestion(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleInsertSuggestedReply() {
|
||||
if (!editor || !suggestedReply) {
|
||||
return
|
||||
}
|
||||
editor.chain().focus().insertContent(suggestedReply).run()
|
||||
setSuggestedReply(null)
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
}
|
||||
|
||||
function handleDismissSuggestedReply() {
|
||||
setSuggestedReply(null)
|
||||
}
|
||||
|
||||
const editorContent = (
|
||||
<>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleSelectImage}
|
||||
/>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleSelectAttachment}
|
||||
/>
|
||||
{!isCustomer && suggestedReply ? (
|
||||
<div className="m-2 rounded-md border border-primary/20 bg-primary/5 p-2.5">
|
||||
<div className="flex items-center justify-between gap-2 pb-1.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-primary">
|
||||
<SparklesIcon className="size-3.5" />
|
||||
<span>{t("aiCopilot.suggestReply")}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleDismissSuggestedReply}
|
||||
aria-label={t("aiCopilot.discardReply")}
|
||||
title={t("aiCopilot.discardReply")}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="line-clamp-3 select-text text-xs leading-5 text-foreground/90 whitespace-pre-wrap">
|
||||
{suggestedReply}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleDismissSuggestedReply}
|
||||
>
|
||||
{t("aiCopilot.discardReply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={handleInsertSuggestedReply}
|
||||
>
|
||||
{t("aiCopilot.insertReply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isCustomer ? (
|
||||
<div className="min-h-10">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-2 py-1">
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
<div className={getToolbarClassName(variant)}>
|
||||
<div className={isCustomer ? "flex items-center gap-1.5" : "flex items-center gap-1"}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={getIconButtonClassName(variant)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
imageInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
>
|
||||
<ImageIcon className={isCustomer ? undefined : "size-4"} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={getIconButtonClassName(variant)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
attachmentInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
>
|
||||
<PaperclipIcon className={isCustomer ? undefined : "size-4"} />
|
||||
</Button>
|
||||
{quickReplies ? (
|
||||
<Popover
|
||||
open={quickReplies.open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
}
|
||||
quickReplies.onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
disabled={disabled || isUploading || quickReplies.loading}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
title={t("conversation.quickReplySlashHint")}
|
||||
aria-label={t("conversation.quickReplySlashHint")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MessageSquareTextIcon className="size-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[30rem] p-0"
|
||||
align="start"
|
||||
initialFocus={!isSlashTriggeredRef.current}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
value={quickReplySearch}
|
||||
onValueChange={setQuickReplySearch}
|
||||
placeholder={t("conversation.searchQuickReplies")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("conversation.emptyQuickReplies")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{quickReplies.items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.groupName ?? ""} ${item.title} ${item.content}`}
|
||||
onSelect={() => handleInsertQuickReply(item)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5 py-0.5">
|
||||
<span className="line-clamp-1 text-sm">
|
||||
{item.groupName
|
||||
? `${item.groupName} / ${item.title}`
|
||||
: item.title}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{item.content}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
{!isCustomer && aiCopilot ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-primary hover:bg-primary/10"
|
||||
disabled={disabled || isUploading || isGenerating || aiCopilot.loading}
|
||||
onClick={handleTriggerSuggest}
|
||||
title={isGenerating ? t("aiCopilot.generating") : t("aiCopilot.suggestReply")}
|
||||
aria-label={t("aiCopilot.suggestReply")}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{!isCustomer && quickReplies ? (
|
||||
<span className="hidden select-none text-[11px] text-muted-foreground/70 sm:inline-block pl-1">
|
||||
{t("conversation.quickReplySlashHint")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={isCustomer ? "hidden text-[10px] text-muted-foreground sm:block" : "text-xs text-muted-foreground"}>
|
||||
{t("conversation.enterToSend")}
|
||||
</p>
|
||||
{isCustomer ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-label={t("conversation.send")}
|
||||
title={t("conversation.send")}
|
||||
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
|
||||
>
|
||||
<SendHorizonalIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
>
|
||||
<SendIcon className="mr-1 size-4" />
|
||||
{isUploading ? t("conversation.uploading") : t("conversation.send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isCustomer) {
|
||||
return (
|
||||
<div className="px-3 pt-2 pb-3">
|
||||
<div className="rounded-xl border border-border bg-background p-2 shadow-[0_8px_24px_rgba(15,23,42,0.05)] dark:shadow-none">
|
||||
{editorContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col p-2">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-border bg-card">
|
||||
{editorContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getEditorClassName(variant: SharedMessageEditorVariant) {
|
||||
if (variant === "customer") {
|
||||
return "agent-desk-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-lg [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55"
|
||||
}
|
||||
return "h-full min-h-12 max-h-[20vh] overflow-y-auto px-1.5 py-1 text-sm leading-6 text-foreground outline-none sm:max-h-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p+.agent-desk-editor-image-wrap]:mt-2 [&_.agent-desk-editor-image-wrap]:my-2 [&_.agent-desk-editor-image]:max-h-64 [&_.agent-desk-editor-image]:max-w-full [&_.agent-desk-editor-image]:rounded-md [&_.agent-desk-editor-image]:object-contain [&_.agent-desk-editor-image-wrap-uploading_.agent-desk-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground"
|
||||
}
|
||||
|
||||
function getToolbarClassName(variant: SharedMessageEditorVariant) {
|
||||
if (variant === "customer") {
|
||||
return "mt-2 flex items-center justify-between"
|
||||
}
|
||||
return "flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2"
|
||||
}
|
||||
|
||||
function getIconButtonClassName(variant: SharedMessageEditorVariant) {
|
||||
if (variant === "customer") {
|
||||
return "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}
|
||||
return "size-8"
|
||||
}
|
||||
|
||||
function isMeaningfulHTML(html: string) {
|
||||
const normalized = html
|
||||
.replace(/<p><\/p>/g, "")
|
||||
.replace(/<p><br><\/p>/g, "")
|
||||
.replace(/\s+/g, "")
|
||||
if (/<img[\s\S]*?>/i.test(normalized)) {
|
||||
return true
|
||||
}
|
||||
const plainText = normalized.replace(/<[^>]+>/g, "").trim()
|
||||
return plainText !== ""
|
||||
}
|
||||
|
||||
function getClipboardImageFile(data: DataTransfer | null) {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const item of Array.from(data.items)) {
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
return item.getAsFile()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user