diff --git a/web/app/dashboard/conversations/_components/agent-message-editor.tsx b/web/app/dashboard/conversations/_components/agent-message-editor.tsx new file mode 100644 index 0000000..454df78 --- /dev/null +++ b/web/app/dashboard/conversations/_components/agent-message-editor.tsx @@ -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 + onUploadImage: (file: File) => Promise + onSendAttachment: (file: File) => Promise +} + +export function AgentMessageEditor({ + disabled = false, + uploadingAsset = false, + onSend, + onUploadImage, + onSendAttachment, +}: AgentMessageEditorProps) { + const [quickReplies, setQuickReplies] = useState([]) + 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 ( + + ) +} diff --git a/web/app/dashboard/conversations/_components/chat-panel.tsx b/web/app/dashboard/conversations/_components/chat-panel.tsx index bad7ce6..c619d14 100644 --- a/web/app/dashboard/conversations/_components/chat-panel.tsx +++ b/web/app/dashboard/conversations/_components/chat-panel.tsx @@ -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() { ) : (
- void + } + onSend: (html: string) => Promise + onUploadImage: (file: File) => Promise + onSendAttachment: (file: File) => Promise +} + +export function SharedMessageEditor({ + variant, + disabled = false, + uploadingAsset = false, + manageLocalUploading = false, + quickReplies, + onSend, + onUploadImage, + onSendAttachment, +}: SharedMessageEditorProps) { + const [localUploading, setLocalUploading] = useState(false) + const imageInputRef = useRef(null) + const attachmentInputRef = useRef(null) + const onSendRef = useRef(onSend) + const onUploadImageRef = useRef(onUploadImage) + const onSendAttachmentRef = useRef(onSendAttachment) + const shouldRestoreFocusRef = useRef(false) + const objectUrlsRef = useRef>(new Set()) + const uploadedImagesRef = useRef(new Map()) + 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) { + 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) { + 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 = ( + <> + + + {isCustomer ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + + {quickReplies ? ( + + event.preventDefault()} + /> + } + > + + + + + + + 暂无快捷回复 + + {quickReplies.items.map((item) => ( + handleInsertQuickReply(item)} + > +
+ + {item.groupName + ? `${item.groupName} / ${item.title}` + : item.title} + + + {item.content} + +
+
+ ))} +
+
+
+
+
+ ) : null} +
+
+

+ Enter 发送 +

+ {isCustomer ? ( + + ) : ( + + )} +
+
+ + ) + + if (isCustomer) { + return ( +
+
+ {editorContent} +
+
+ ) + } + + return ( +
+
+ {editorContent} +
+
+ ) +} + +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>/g, "") + .replace(/


<\/p>/g, "") + .replace(/\s+/g, "") + if (//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 +} diff --git a/web/components/im-message-editor.tsx b/web/components/im-message-editor.tsx deleted file mode 100644 index 15f0541..0000000 --- a/web/components/im-message-editor.tsx +++ /dev/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 - onUploadImage: (file: File) => Promise - onSendAttachment: (file: File) => Promise -} - -export function ImMessageEditor({ - disabled = false, - uploadingAsset = false, - onSend, - onUploadImage, - onSendAttachment, -}: ImMessageEditorProps) { - const imageInputRef = useRef(null) - const attachmentInputRef = useRef(null) - const onSendRef = useRef(onSend) - const onUploadImageRef = useRef(onUploadImage) - const onSendAttachmentRef = useRef(onSendAttachment) - const shouldRestoreFocusRef = useRef(false) - const objectUrlsRef = useRef>(new Set()) - const uploadedImagesRef = useRef(new Map()) - const [quickReplies, setQuickReplies] = useState([]) - 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) => { - 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) => { - 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 ( -

- - -
-
- -
-
-
- - - - event.preventDefault()} - /> - } - > - - - - - - - 暂无快捷回复 - - {quickReplies.map((item) => ( - handleInsertQuickReply(item)} - > -
- - {item.groupName ? `${item.groupName} / ${item.title}` : item.title} - - - {item.content} - -
-
- ))} -
-
-
-
-
-
-
-

Enter 发送

- -
-
-
-
- ) -} - -function isMeaningfulHTML(html: string) { - const normalized = html - .replace(/

<\/p>/g, "") - .replace(/


<\/p>/g, "") - .replace(/\s+/g, "") - if (//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 -} diff --git a/web/components/kefu/chat-shell.tsx b/web/components/kefu/chat-shell.tsx index 8b723be..a88f8f7 100644 --- a/web/components/kefu/chat-shell.tsx +++ b/web/components/kefu/chat-shell.tsx @@ -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} /> - Promise + onUploadImage: (file: File) => Promise + onSendAttachment: (file: File) => Promise +} + +export function CustomerMessageEditor({ + disabled = false, + uploadingAsset = false, + onSend, + onUploadImage, + onSendAttachment, +}: CustomerMessageEditorProps) { + return ( + + ) +} diff --git a/web/components/kefu/message-editor.tsx b/web/components/kefu/message-editor.tsx deleted file mode 100644 index 6015473..0000000 --- a/web/components/kefu/message-editor.tsx +++ /dev/null @@ -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 - onUploadImage: (file: File) => Promise - onSendAttachment: (file: File) => Promise -} - -export function KefuMessageEditor({ - disabled = false, - uploadingAsset = false, - onSend, - onUploadImage, - onSendAttachment, -}: KefuMessageEditorProps) { - const [localUploading, setLocalUploading] = useState(false) - const imageInputRef = useRef(null) - const attachmentInputRef = useRef(null) - const onSendRef = useRef(onSend) - const onUploadImageRef = useRef(onUploadImage) - const onSendAttachmentRef = useRef(onSendAttachment) - const shouldRestoreFocusRef = useRef(false) - const objectUrlsRef = useRef>(new Set()) - const uploadedImagesRef = useRef(new Map()) - 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) { - 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 - ) { - 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 ( - //

-
-
- - -
- -
-
-
- - -
-
-

Enter 发送

- -
-
-
-
- ) -} - -function isMeaningfulHTML(html: string) { - const normalized = html - .replace(/

<\/p>/g, "") - .replace(/


<\/p>/g, "") - .replace(/\s+/g, "") - if (//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 -}