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:
mlogclub
2026-04-25 18:33:32 +08:00
parent 9a2ae5c505
commit 67900d9849
7 changed files with 594 additions and 745 deletions
@@ -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 { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { ImMessageEditor } from "@/components/im-message-editor";
import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -35,6 +34,7 @@ import {
type AgentConversationFilterKey,
} from "@/lib/stores/agent-conversations";
import { formatDateTime } from "@/lib/utils";
import { AgentMessageEditor } from "./agent-message-editor";
const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
@@ -410,7 +410,7 @@ export function ChatPanel() {
) : (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<ImMessageEditor
<AgentMessageEditor
disabled={!conversation || sending}
uploadingAsset={uploadingAsset}
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
}
-403
View File
@@ -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
}
+2 -2
View File
@@ -18,7 +18,7 @@ import {
import { useShallow } from "zustand/react/shallow"
import { KefuConnectionStatus } from "@/components/kefu/connection-status"
import { KefuMessageEditor } from "@/components/kefu/message-editor"
import { CustomerMessageEditor } from "@/components/kefu/customer-message-editor"
import {
KefuMessageList,
type KefuMessageListHandle,
@@ -319,7 +319,7 @@ export function KefuChatShell() {
loadingOlder={messagesLoadingMore}
onLoadOlder={loadOlderMessages}
/>
<KefuMessageEditor
<CustomerMessageEditor
disabled={!conversation}
onSend={handleSend}
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}
/>
)
}
-338
View File
@@ -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
}