feat: replace ImMessageEditor with AgentMessageEditor and CustomerMessageEditor, refactor shared message editor logic
- Introduced AgentMessageEditor component for agent message handling. - Created CustomerMessageEditor component to handle customer messages. - Refactored shared message editor logic into SharedMessageEditor. - Removed the old KefuMessageEditor component. - Updated chat panel to use the new AgentMessageEditor. - Updated KefuChatShell to use CustomerMessageEditor instead of KefuMessageEditor. - Added quick reply fetching and handling in AgentMessageEditor.
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
SharedMessageEditor,
|
||||||
|
type UploadedMessageEditorImage,
|
||||||
|
} from "@/components/chat/shared-message-editor"
|
||||||
|
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
|
||||||
|
|
||||||
|
type AgentMessageEditorProps = {
|
||||||
|
disabled?: boolean
|
||||||
|
uploadingAsset?: boolean
|
||||||
|
onSend: (html: string) => Promise<void>
|
||||||
|
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
||||||
|
onSendAttachment: (file: File) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentMessageEditor({
|
||||||
|
disabled = false,
|
||||||
|
uploadingAsset = false,
|
||||||
|
onSend,
|
||||||
|
onUploadImage,
|
||||||
|
onSendAttachment,
|
||||||
|
}: AgentMessageEditorProps) {
|
||||||
|
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
|
||||||
|
const [loadingQuickReplies, setLoadingQuickReplies] = useState(true)
|
||||||
|
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void fetchQuickReplyListAll()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setQuickReplies(list)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "加载快捷回复失败")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingQuickReplies(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedMessageEditor
|
||||||
|
variant="agent"
|
||||||
|
disabled={disabled}
|
||||||
|
uploadingAsset={uploadingAsset}
|
||||||
|
quickReplies={{
|
||||||
|
open: quickReplyPickerOpen,
|
||||||
|
loading: loadingQuickReplies,
|
||||||
|
items: quickReplies,
|
||||||
|
onOpenChange: setQuickReplyPickerOpen,
|
||||||
|
}}
|
||||||
|
onSend={onSend}
|
||||||
|
onUploadImage={onUploadImage}
|
||||||
|
onSendAttachment={onSendAttachment}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
|
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
|
||||||
import { ImMessageEditor } from "@/components/im-message-editor";
|
|
||||||
import { ImMessageHTML } from "@/components/im-message-html";
|
import { ImMessageHTML } from "@/components/im-message-html";
|
||||||
import { useImageLightbox } from "@/components/image-lightbox";
|
import { useImageLightbox } from "@/components/image-lightbox";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
@@ -35,6 +34,7 @@ import {
|
|||||||
type AgentConversationFilterKey,
|
type AgentConversationFilterKey,
|
||||||
} from "@/lib/stores/agent-conversations";
|
} from "@/lib/stores/agent-conversations";
|
||||||
import { formatDateTime } from "@/lib/utils";
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import { AgentMessageEditor } from "./agent-message-editor";
|
||||||
|
|
||||||
const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
|
const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
|
||||||
|
|
||||||
@@ -410,7 +410,7 @@ export function ChatPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<ImMessageEditor
|
<AgentMessageEditor
|
||||||
disabled={!conversation || sending}
|
disabled={!conversation || sending}
|
||||||
uploadingAsset={uploadingAsset}
|
uploadingAsset={uploadingAsset}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"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,
|
||||||
|
MessageSquareTextIcon,
|
||||||
|
PaperclipIcon,
|
||||||
|
SendHorizonalIcon,
|
||||||
|
SendIcon,
|
||||||
|
} 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"
|
||||||
|
|
||||||
|
export type UploadedMessageEditorImage = UploadedEditorImage & {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageEditorQuickReply = {
|
||||||
|
id: number
|
||||||
|
groupName?: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedMessageEditorVariant = "customer" | "agent"
|
||||||
|
|
||||||
|
type SharedMessageEditorProps = {
|
||||||
|
variant: SharedMessageEditorVariant
|
||||||
|
disabled?: boolean
|
||||||
|
uploadingAsset?: boolean
|
||||||
|
manageLocalUploading?: boolean
|
||||||
|
quickReplies?: {
|
||||||
|
open: boolean
|
||||||
|
loading: boolean
|
||||||
|
items: MessageEditorQuickReply[]
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
onSend: (html: string) => Promise<void>
|
||||||
|
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
||||||
|
onSendAttachment: (file: File) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharedMessageEditor({
|
||||||
|
variant,
|
||||||
|
disabled = false,
|
||||||
|
uploadingAsset = false,
|
||||||
|
manageLocalUploading = false,
|
||||||
|
quickReplies,
|
||||||
|
onSend,
|
||||||
|
onUploadImage,
|
||||||
|
onSendAttachment,
|
||||||
|
}: SharedMessageEditorProps) {
|
||||||
|
const [localUploading, setLocalUploading] = useState(false)
|
||||||
|
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 shouldRestoreFocusRef = useRef(false)
|
||||||
|
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||||
|
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
|
||||||
|
const isCustomer = variant === "customer"
|
||||||
|
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const objectUrls = objectUrlsRef.current
|
||||||
|
return () => {
|
||||||
|
revokeEditorObjectUrls(objectUrls)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSendRef.current = onSend
|
||||||
|
}, [onSend])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUploadImageRef.current = onUploadImage
|
||||||
|
}, [onUploadImage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSendAttachmentRef.current = onSendAttachment
|
||||||
|
}, [onSendAttachment])
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: false,
|
||||||
|
blockquote: false,
|
||||||
|
codeBlock: false,
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
}),
|
||||||
|
MessageImageExtension,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: "",
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: getEditorClassName(variant),
|
||||||
|
},
|
||||||
|
handleKeyDown: (_view, event) => {
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
editor.chain().focus().insertContent(item.content).run()
|
||||||
|
quickReplies?.onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFocusIfNeeded() {
|
||||||
|
if (editor && shouldRestoreFocusRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editor.commands.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorContent = (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleSelectImage}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={attachmentInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleSelectAttachment}
|
||||||
|
/>
|
||||||
|
{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 ? "图片上传中" : "发送图片"}
|
||||||
|
title={isUploading ? "图片上传中" : "发送图片"}
|
||||||
|
>
|
||||||
|
<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 ? "附件上传中" : "发送附件"}
|
||||||
|
title={isUploading ? "附件上传中" : "发送附件"}
|
||||||
|
>
|
||||||
|
<PaperclipIcon className={isCustomer ? undefined : "size-4"} />
|
||||||
|
</Button>
|
||||||
|
{quickReplies ? (
|
||||||
|
<Popover open={quickReplies.open} onOpenChange={quickReplies.onOpenChange}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
disabled={disabled || isUploading || quickReplies.loading}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MessageSquareTextIcon className="size-4" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[30rem] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="搜索快捷回复" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>暂无快捷回复</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}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className={isCustomer ? "text-[10px] text-muted-foreground" : "text-xs text-muted-foreground"}>
|
||||||
|
Enter 发送
|
||||||
|
</p>
|
||||||
|
{isCustomer ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
disabled={disabled || isUploading}
|
||||||
|
aria-label="发送"
|
||||||
|
title="发送"
|
||||||
|
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 ? "上传中..." : "发送"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isCustomer) {
|
||||||
|
return (
|
||||||
|
<div className="p-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 "cs-agent-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 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-lg [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-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+.cs-agent-editor-image-wrap]:mt-2 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-md [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-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
|
||||||
|
}
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { EditorContent, useEditor } from "@tiptap/react"
|
|
||||||
import StarterKit from "@tiptap/starter-kit"
|
|
||||||
import Placeholder from "@tiptap/extension-placeholder"
|
|
||||||
import { ImageIcon, MessageSquareTextIcon, PaperclipIcon, SendIcon } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
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 { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
|
|
||||||
import {
|
|
||||||
buildSendableEditorHTML,
|
|
||||||
hasUploadingEditorImages,
|
|
||||||
markEditorImageUploadedByTitle,
|
|
||||||
MessageImageExtension,
|
|
||||||
removeEditorImageByTitle,
|
|
||||||
revokeEditorObjectUrl,
|
|
||||||
revokeEditorObjectUrls,
|
|
||||||
setEditorImageUploadingByTitle,
|
|
||||||
} from "@/lib/im-editor-image"
|
|
||||||
import { generateUUID } from "@/lib/utils"
|
|
||||||
|
|
||||||
type UploadedImage = {
|
|
||||||
assetId: string
|
|
||||||
provider: string
|
|
||||||
storageKey: string
|
|
||||||
url: string
|
|
||||||
filename?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImMessageEditorProps = {
|
|
||||||
disabled?: boolean
|
|
||||||
uploadingAsset?: boolean
|
|
||||||
onSend: (html: string) => Promise<void>
|
|
||||||
onUploadImage: (file: File) => Promise<UploadedImage | null>
|
|
||||||
onSendAttachment: (file: File) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImMessageEditor({
|
|
||||||
disabled = false,
|
|
||||||
uploadingAsset = false,
|
|
||||||
onSend,
|
|
||||||
onUploadImage,
|
|
||||||
onSendAttachment,
|
|
||||||
}: ImMessageEditorProps) {
|
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const attachmentInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const onSendRef = useRef(onSend)
|
|
||||||
const onUploadImageRef = useRef(onUploadImage)
|
|
||||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
|
||||||
const shouldRestoreFocusRef = useRef(false)
|
|
||||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
|
||||||
const uploadedImagesRef = useRef(new Map<string, UploadedImage>())
|
|
||||||
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
|
|
||||||
const [loadingQuickReplies, setLoadingQuickReplies] = useState(false)
|
|
||||||
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSendRef.current = onSend
|
|
||||||
}, [onSend])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onUploadImageRef.current = onUploadImage
|
|
||||||
}, [onUploadImage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSendAttachmentRef.current = onSendAttachment
|
|
||||||
}, [onSendAttachment])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const objectUrls = objectUrlsRef.current
|
|
||||||
return () => {
|
|
||||||
revokeEditorObjectUrls(objectUrls)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
setLoadingQuickReplies(true)
|
|
||||||
void fetchQuickReplyListAll()
|
|
||||||
.then((list) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setQuickReplies(list)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载快捷回复失败")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoadingQuickReplies(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
immediatelyRender: false,
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
heading: false,
|
|
||||||
blockquote: false,
|
|
||||||
codeBlock: false,
|
|
||||||
bulletList: false,
|
|
||||||
orderedList: false,
|
|
||||||
horizontalRule: false,
|
|
||||||
}),
|
|
||||||
MessageImageExtension,
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content: "",
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class:
|
|
||||||
"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+.cs-agent-editor-image-wrap]:mt-2 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-md [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-editor-image]:opacity-55 [&_p.is-editor-empty:first-child]:before:text-muted-foreground",
|
|
||||||
},
|
|
||||||
handleKeyDown: (_view, event) => {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
|
||||||
event.preventDefault()
|
|
||||||
void handleSend()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
handlePaste: (_view, event) => {
|
|
||||||
if (disabled || uploadingAsset) {
|
|
||||||
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 && !uploadingAsset)
|
|
||||||
}, [disabled, editor, uploadingAsset])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || disabled || uploadingAsset || !shouldRestoreFocusRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
editor.commands.focus()
|
|
||||||
})
|
|
||||||
}, [disabled, editor, uploadingAsset])
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!editor || disabled || uploadingAsset) {
|
|
||||||
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()
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
editor.commands.focus("end")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0]
|
|
||||||
event.target.value = ""
|
|
||||||
if (!file || !editor || disabled || uploadingAsset) {
|
|
||||||
if (editor && shouldRestoreFocusRef.current) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
editor.commands.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await insertUploadedImage(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertUploadedImage = async (file: File) => {
|
|
||||||
if (!editor || disabled || uploadingAsset) {
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!disabled && shouldRestoreFocusRef.current) {
|
|
||||||
editor.commands.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectAttachment = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0]
|
|
||||||
event.target.value = ""
|
|
||||||
if (!file || disabled || uploadingAsset) {
|
|
||||||
if (editor && shouldRestoreFocusRef.current) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
editor.commands.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
||||||
await onSendAttachmentRef.current(file)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (editor && !disabled && shouldRestoreFocusRef.current) {
|
|
||||||
editor.commands.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInsertQuickReply = (item: AdminQuickReply) => {
|
|
||||||
if (!editor || disabled || uploadingAsset) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!item.content.trim()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
editor.chain().focus().insertContent(item.content).run()
|
|
||||||
setQuickReplyPickerOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-col p-2">
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleSelectImage}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={attachmentInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleSelectAttachment}
|
|
||||||
/>
|
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-border bg-card">
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden px-2 py-1">
|
|
||||||
<EditorContent editor={editor} className="h-full" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between rounded-b-sm border-t border-border bg-card px-2 pt-1 pb-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
||||||
imageInputRef.current?.click()
|
|
||||||
}}
|
|
||||||
disabled={disabled || uploadingAsset}
|
|
||||||
>
|
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
||||||
attachmentInputRef.current?.click()
|
|
||||||
}}
|
|
||||||
disabled={disabled || uploadingAsset}
|
|
||||||
>
|
|
||||||
<PaperclipIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Popover open={quickReplyPickerOpen} onOpenChange={setQuickReplyPickerOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8"
|
|
||||||
disabled={disabled || uploadingAsset || loadingQuickReplies}
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MessageSquareTextIcon className="size-4" />
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[30rem] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="搜索快捷回复" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>暂无快捷回复</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{quickReplies.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>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-xs text-muted-foreground">Enter 发送</p>
|
|
||||||
<Button size="sm" onClick={() => void handleSend()} disabled={disabled || uploadingAsset}>
|
|
||||||
<SendIcon className="mr-1 size-4" />
|
|
||||||
{uploadingAsset ? "上传中..." : "发送"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(clipboardData: DataTransfer | null) {
|
|
||||||
if (!clipboardData) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
for (const item of Array.from(clipboardData.items)) {
|
|
||||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
|
||||||
return item.getAsFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { useShallow } from "zustand/react/shallow"
|
import { useShallow } from "zustand/react/shallow"
|
||||||
|
|
||||||
import { KefuConnectionStatus } from "@/components/kefu/connection-status"
|
import { KefuConnectionStatus } from "@/components/kefu/connection-status"
|
||||||
import { KefuMessageEditor } from "@/components/kefu/message-editor"
|
import { CustomerMessageEditor } from "@/components/kefu/customer-message-editor"
|
||||||
import {
|
import {
|
||||||
KefuMessageList,
|
KefuMessageList,
|
||||||
type KefuMessageListHandle,
|
type KefuMessageListHandle,
|
||||||
@@ -319,7 +319,7 @@ export function KefuChatShell() {
|
|||||||
loadingOlder={messagesLoadingMore}
|
loadingOlder={messagesLoadingMore}
|
||||||
onLoadOlder={loadOlderMessages}
|
onLoadOlder={loadOlderMessages}
|
||||||
/>
|
/>
|
||||||
<KefuMessageEditor
|
<CustomerMessageEditor
|
||||||
disabled={!conversation}
|
disabled={!conversation}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onUploadImage={uploadMessageImage}
|
onUploadImage={uploadMessageImage}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
SharedMessageEditor,
|
||||||
|
type UploadedMessageEditorImage,
|
||||||
|
} from "@/components/chat/shared-message-editor"
|
||||||
|
|
||||||
|
type CustomerMessageEditorProps = {
|
||||||
|
disabled?: boolean
|
||||||
|
uploadingAsset?: boolean
|
||||||
|
onSend: (html: string) => Promise<void>
|
||||||
|
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
||||||
|
onSendAttachment: (file: File) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerMessageEditor({
|
||||||
|
disabled = false,
|
||||||
|
uploadingAsset = false,
|
||||||
|
onSend,
|
||||||
|
onUploadImage,
|
||||||
|
onSendAttachment,
|
||||||
|
}: CustomerMessageEditorProps) {
|
||||||
|
return (
|
||||||
|
<SharedMessageEditor
|
||||||
|
variant="customer"
|
||||||
|
disabled={disabled}
|
||||||
|
uploadingAsset={uploadingAsset}
|
||||||
|
manageLocalUploading
|
||||||
|
onSend={onSend}
|
||||||
|
onUploadImage={onUploadImage}
|
||||||
|
onSendAttachment={onSendAttachment}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { EditorContent, useEditor } from "@tiptap/react"
|
|
||||||
import Placeholder from "@tiptap/extension-placeholder"
|
|
||||||
import StarterKit from "@tiptap/starter-kit"
|
|
||||||
import { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
buildSendableEditorHTML,
|
|
||||||
hasUploadingEditorImages,
|
|
||||||
markEditorImageUploadedByTitle,
|
|
||||||
MessageImageExtension,
|
|
||||||
removeEditorImageByTitle,
|
|
||||||
revokeEditorObjectUrl,
|
|
||||||
revokeEditorObjectUrls,
|
|
||||||
setEditorImageUploadingByTitle,
|
|
||||||
} from "@/lib/im-editor-image"
|
|
||||||
import { generateUUID } from "@/lib/utils"
|
|
||||||
|
|
||||||
type UploadedImage = {
|
|
||||||
assetId: string
|
|
||||||
provider: string
|
|
||||||
storageKey: string
|
|
||||||
url: string
|
|
||||||
filename?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type KefuMessageEditorProps = {
|
|
||||||
disabled?: boolean
|
|
||||||
uploadingAsset?: boolean
|
|
||||||
onSend: (html: string) => Promise<void>
|
|
||||||
onUploadImage: (file: File) => Promise<UploadedImage | null>
|
|
||||||
onSendAttachment: (file: File) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KefuMessageEditor({
|
|
||||||
disabled = false,
|
|
||||||
uploadingAsset = false,
|
|
||||||
onSend,
|
|
||||||
onUploadImage,
|
|
||||||
onSendAttachment,
|
|
||||||
}: KefuMessageEditorProps) {
|
|
||||||
const [localUploading, setLocalUploading] = useState(false)
|
|
||||||
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 shouldRestoreFocusRef = useRef(false)
|
|
||||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
|
||||||
const uploadedImagesRef = useRef(new Map<string, UploadedImage>())
|
|
||||||
const isUploading = uploadingAsset || localUploading
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const objectUrls = objectUrlsRef.current
|
|
||||||
return () => {
|
|
||||||
revokeEditorObjectUrls(objectUrls)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSendRef.current = onSend
|
|
||||||
}, [onSend])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onUploadImageRef.current = onUploadImage
|
|
||||||
}, [onUploadImage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSendAttachmentRef.current = onSendAttachment
|
|
||||||
}, [onSendAttachment])
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
immediatelyRender: false,
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
heading: false,
|
|
||||||
blockquote: false,
|
|
||||||
codeBlock: false,
|
|
||||||
bulletList: false,
|
|
||||||
orderedList: false,
|
|
||||||
horizontalRule: false,
|
|
||||||
}),
|
|
||||||
MessageImageExtension,
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content: "",
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class:
|
|
||||||
"cs-agent-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 [&_.cs-agent-editor-image-wrap]:my-2 [&_.cs-agent-editor-image]:max-h-64 [&_.cs-agent-editor-image]:max-w-full [&_.cs-agent-editor-image]:rounded-lg [&_.cs-agent-editor-image]:object-contain [&_.cs-agent-editor-image-wrap-uploading_.cs-agent-editor-image]:opacity-55",
|
|
||||||
},
|
|
||||||
handleKeyDown: (_view, event) => {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = event.target.files?.[0]
|
|
||||||
event.target.value = ""
|
|
||||||
if (!file || !editor || disabled || isUploading) {
|
|
||||||
if (editor && shouldRestoreFocusRef.current) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
editor.commands.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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: React.ChangeEvent<HTMLInputElement>
|
|
||||||
) {
|
|
||||||
const file = event.target.files?.[0]
|
|
||||||
event.target.value = ""
|
|
||||||
if (!file || disabled || isUploading) {
|
|
||||||
if (editor && shouldRestoreFocusRef.current) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
editor.commands.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
||||||
setLocalUploading(true)
|
|
||||||
try {
|
|
||||||
await onSendAttachmentRef.current(file)
|
|
||||||
} finally {
|
|
||||||
setLocalUploading(false)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (editor && !disabled && shouldRestoreFocusRef.current) {
|
|
||||||
editor.commands.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// <div className="border-t border-border bg-card px-3 pb-3 pt-2">
|
|
||||||
<div className="p-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">
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleSelectImage}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={attachmentInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleSelectAttachment}
|
|
||||||
/>
|
|
||||||
<div className="min-h-10">
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
||||||
imageInputRef.current?.click()
|
|
||||||
}}
|
|
||||||
disabled={disabled || isUploading}
|
|
||||||
aria-label={isUploading ? "图片上传中" : "发送图片"}
|
|
||||||
title={isUploading ? "图片上传中" : "发送图片"}
|
|
||||||
className="text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ImageIcon />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
|
||||||
attachmentInputRef.current?.click()
|
|
||||||
}}
|
|
||||||
disabled={disabled || isUploading}
|
|
||||||
aria-label={isUploading ? "附件上传中" : "发送附件"}
|
|
||||||
title={isUploading ? "附件上传中" : "发送附件"}
|
|
||||||
className="text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<PaperclipIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-[10px] text-muted-foreground">Enter 发送</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => void handleSend()}
|
|
||||||
disabled={disabled || isUploading}
|
|
||||||
aria-label="发送"
|
|
||||||
title="发送"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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