diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 96afbe9..56fccdf 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -75,13 +75,6 @@ func NewServer() (*iris.Application, error) { ShowList: false, }) - // 注册widget静态资源服务 - app.HandleDir("/widget", iris.Dir("widget/out"), iris.DirOptions{ - IndexName: "index.html", - Compress: true, - ShowList: false, - }) - return app, nil } diff --git a/widget/.env.example b/widget/.env.example deleted file mode 100644 index c7ec9df..0000000 --- a/widget/.env.example +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8083 \ No newline at end of file diff --git a/widget/.gitignore b/widget/.gitignore deleted file mode 100644 index 7b8da95..0000000 --- a/widget/.gitignore +++ /dev/null @@ -1,42 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files (can opt-in for committing if needed) -.env* -!.env.example - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/widget/app/frame/page.tsx b/widget/app/frame/page.tsx deleted file mode 100644 index 24a08ae..0000000 --- a/widget/app/frame/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ChatShell } from "@/components/im/chat-shell"; - -export default function FramePage() { - return ; -} diff --git a/widget/app/globals.css b/widget/app/globals.css deleted file mode 100644 index aa6c1cc..0000000 --- a/widget/app/globals.css +++ /dev/null @@ -1,157 +0,0 @@ -@import "tailwindcss"; - -:root { - --background: #f3f7fb; - --foreground: #111827; - --card: #ffffff; - --card-foreground: #111827; - --muted: #f3f4f6; - --muted-foreground: #6b7280; - --primary: #2563eb; - --primary-foreground: #ffffff; - --border: #e5e7eb; - --danger: #dc2626; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - height: 100%; - overflow-x: hidden; - background: - radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 30%), - linear-gradient(180deg, #fbfdff 0%, #f4f8fc 100%); - color: var(--foreground); - font-family: - "SF Pro Display", "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; -} - -body { - overscroll-behavior: none; -} - -button, -input, -textarea { - font: inherit; -} - -textarea.cs-agent-scrollbar { - scrollbar-width: thin; - scrollbar-color: #cbd5e1 transparent; -} - -textarea.cs-agent-scrollbar::-webkit-scrollbar { - width: 8px; -} - -textarea.cs-agent-scrollbar::-webkit-scrollbar-track { - background: transparent; -} - -textarea.cs-agent-scrollbar::-webkit-scrollbar-thumb { - border: 2px solid transparent; - border-radius: 999px; - background: #cbd5e1; - background-clip: padding-box; -} - -textarea.cs-agent-scrollbar::-webkit-scrollbar-thumb:hover { - background: #94a3b8; - background-clip: padding-box; -} - -.cs-agent-scrollbar { - scrollbar-width: thin; - scrollbar-color: #cbd5e1 transparent; -} - -.cs-agent-scrollbar::-webkit-scrollbar { - width: 6px; -} - -.cs-agent-scrollbar::-webkit-scrollbar-track { - background: transparent; -} - -.cs-agent-scrollbar::-webkit-scrollbar-thumb { - border: 2px solid transparent; - border-radius: 999px; - background: #cbd5e1; - background-clip: padding-box; -} - -.cs-agent-scrollbar::-webkit-scrollbar-thumb:hover { - background: #94a3b8; - background-clip: padding-box; -} - -.cs-agent-shell { - /* padding: 10px; */ -} - -.cs-agent-panel { - border-radius: 28px; - box-shadow: - 0 24px 80px rgba(15, 23, 42, 0.14), - 0 10px 24px rgba(15, 23, 42, 0.08); - backdrop-filter: blur(16px); -} - -.cs-agent-grid-bg { - background-image: - radial-gradient(circle at top, rgba(255, 255, 255, 0.72), transparent 32%), - linear-gradient(180deg, rgba(247, 249, 252, 0.92) 0%, rgba(238, 244, 251, 0.94) 100%), - linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px); - background-size: - auto, - auto, - 22px 22px, - 22px 22px; - background-position: - center top, - center, - center, - center; -} - -.cs-agent-fade-up { - animation: cs-agent-fade-up 220ms ease-out; -} - -.cs-agent-status-dot { - animation: cs-agent-pulse 1.8s ease-in-out infinite; -} - -@keyframes cs-agent-fade-up { - from { - opacity: 0; - transform: translate3d(0, 8px, 0); - } - - to { - opacity: 1; - transform: translate3d(0, 0, 0); - } -} - -@keyframes cs-agent-pulse { - 0%, - 100% { - transform: scale(1); - } - - 50% { - transform: scale(1.08); - } -} - -/* 图片预览:遮罩在 dialog 内部绘制,避免与原生 ::backdrop 叠色 */ -.cs-agent-image-lightbox::backdrop { - background: transparent; -} diff --git a/widget/app/layout.tsx b/widget/app/layout.tsx deleted file mode 100644 index 00c3fac..0000000 --- a/widget/app/layout.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Metadata } from "next"; - -import { ImageLightboxProvider } from "@/components/image-lightbox"; - -import "./globals.css"; - -export const metadata: Metadata = { - title: "贝壳AI客服插件", - description: "Embedded customer service widget", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} diff --git a/widget/app/page.tsx b/widget/app/page.tsx deleted file mode 100644 index 9ab2f1f..0000000 --- a/widget/app/page.tsx +++ /dev/null @@ -1,686 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import Image from "next/image"; -import { useEffect, useState } from "react"; -import { - ArrowRight, - Bot, - ChartColumn, - Cpu, - DatabaseZap, - GitBranch, - MessageSquareText, - ShieldCheck, -} from "lucide-react"; - -import { generateUUID } from "@/lib/utils"; -import type { WidgetHostConfig } from "@/lib/widget/config"; - -const STORAGE_KEY = "cs-agent-widget-test-config"; -const GITHUB_URL = "https://github.com/huabeitech/cs-ai-agent"; -const GITEE_URL = "https://gitee.com/huabeitech/cs-ai-agent"; -const OFFICIAL_SITE_URL = "https://aiagent.huabei.pro"; - -type TestConfig = WidgetHostConfig; - -const featureCards = [ - { - title: "先让 AI 处理重复路径", - description: "把常见问题和标准流程优先交给 Agent,降低人工压力。", - icon: MessageSquareText, - }, - { - title: "人工接管不脱离上下文", - description: "在低置信度、命中规则或用户要求时,带着会话上下文平滑转人工。", - icon: Bot, - }, - { - title: "知识、人工和工单形成闭环", - description: "把知识检索、人工接管、外部系统调用和工单流程放进同一条客服链路。", - icon: GitBranch, - }, - { - title: "面向客服运营而不是 Demo", - description: "强调稳定运行、可观测流程和结构化升级,不把项目做成聊天壳子。", - icon: ShieldCheck, - }, -]; - -const scopeCards = [ - { - title: "会话运行时", - description: "接收用户消息、维护会话状态并触发回复执行。", - icon: MessageSquareText, - }, - { - title: "AI 知识检索", - description: "基于 RAG 的企业知识库,提升答案准确性。", - icon: DatabaseZap, - }, - { - title: "AI Agent 运行时", - description: "自定义 Skill,通过 MCP 协议对接企业业务系统。", - icon: Cpu, - }, - { - title: "人工协同", - description: "在 AI 不该继续时,把会话转给人工客服或指定客服组。", - icon: Bot, - }, - { - title: "工单闭环", - description: "从会话创建工单,跟踪处理过程,直到最终关闭。", - icon: ChartColumn, - }, -]; - -const flowSteps = [ - { - step: "01", - title: "用户发起咨询", - description: "用户通过 Web Widget 或开放接口进入系统,创建新会话或匹配已有会话。", - }, - { - step: "02", - title: "系统装载上下文", - description: "加载会话历史、AI 配置、可用 Skill、知识库和工具能力,为当前问题准备执行上下文。", - }, - { - step: "03", - title: "AI 优先回复", - description: "AI Agent 结合历史消息、知识检索结果和可用工具,优先处理常见问题与标准流程。", - }, - { - step: "04", - title: "按需调用能力", - description: "在回复过程中执行 Skill、通过 MCP 调用外部系统,或触发特定业务流程。", - }, - { - step: "05", - title: "转交人工处理", - description: "当 AI 无法继续有效处理,或用户希望由人工接待时,会话带着上下文转交客服。", - }, - { - step: "06", - title: "转入工单流程", - description: "当问题需要跨时段、跨角色持续跟进时,可以从会话直接转入工单流程。", - }, -]; - -function getWidgetRootPath(pathname: string): string { - return pathname.startsWith("/widget") ? "/widget" : ""; -} - -function getWidgetSdkUrl(baseUrl: string, pathname: string): string { - return `${baseUrl.replace(/\/$/, "")}${getWidgetRootPath(pathname)}/sdk/cs-agent-widget.js`; -} - -function generateRandomSubject(): string { - return `设备会话-${generateUUID().replace(/-/g, "").slice(0, 8)}`; -} - -function buildDefaultConfig(baseUrl: string): TestConfig { - return { - channelId: "", - baseUrl, - apiBaseUrl: baseUrl, - title: "IoT 智能客服", - subtitle: "设备告警、工单协同与在线服务统一入口", - position: "right", - themeColor: "#2563eb", - width: "680px", - subject: generateRandomSubject(), - }; -} - -function getInitialConfig(): TestConfig { - if (typeof window === "undefined") { - return buildDefaultConfig(""); - } - - const origin = window.location.origin; - const query = new URLSearchParams(window.location.search); - const savedText = window.localStorage.getItem(STORAGE_KEY); - const savedConfig = savedText - ? (JSON.parse(savedText) as Partial) - : {}; - - return { - ...buildDefaultConfig(origin), - ...savedConfig, - channelId: query.get("channelId") ?? savedConfig.channelId ?? "", - baseUrl: query.get("baseUrl") ?? savedConfig.baseUrl ?? origin, - apiBaseUrl: - query.get("apiBaseUrl") ?? - savedConfig.apiBaseUrl ?? - savedConfig.baseUrl ?? - origin, - width: query.get("width") ?? savedConfig.width ?? "680px", - subject: - query.get("subject") ?? savedConfig.subject ?? generateRandomSubject(), - }; -} - -function removeMountedWidget() { - if (typeof window === "undefined") { - return; - } - - document - .querySelectorAll( - '[data-cs-agent-widget="launcher"], [data-cs-agent-widget="frame"], [data-cs-agent-widget="script"]', - ) - .forEach((node) => node.remove()); - - delete window.CSAgentConfig; - delete window.__CS_AGENT_WIDGET_CONFIG__; - delete (window as Window & { __CS_AGENT_WIDGET_LOADED__?: boolean }) - .__CS_AGENT_WIDGET_LOADED__; -} - -function injectWidget(config: TestConfig) { - removeMountedWidget(); - window.CSAgentConfig = config; - - const script = document.createElement("script"); - script.async = true; - script.src = getWidgetSdkUrl(window.location.origin, window.location.pathname); - script.dataset.csAgentWidget = "script"; - document.body.appendChild(script); -} - -function WidgetHomePageInner() { - const [config, setConfig] = useState(getInitialConfig); - const [status, setStatus] = useState(() => - getInitialConfig().channelId ? "Widget 已挂载" : "请先填写 channelId", - ); - - useEffect(() => { - if (config.channelId) { - injectWidget(config); - } else { - removeMountedWidget(); - } - - return () => { - removeMountedWidget(); - }; - }, [config]); - - function updateField( - key: K, - value: TestConfig[K], - ) { - setConfig((current) => ({ ...current, [key]: value })); - } - - function handleApply() { - const nextConfig = { - ...config, - channelId: config.channelId.trim(), - baseUrl: config.baseUrl.trim() || window.location.origin, - apiBaseUrl: - config.apiBaseUrl?.trim() || - config.baseUrl.trim() || - window.location.origin, - title: config.title?.trim() || "IoT 智能客服", - subtitle: config.subtitle?.trim() || "设备告警、工单协同与在线服务统一入口", - themeColor: config.themeColor?.trim() || "#2563eb", - width: config.width?.trim() || "680px", - subject: config.subject?.trim() || generateRandomSubject(), - }; - - setConfig(nextConfig); - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig)); - - if (!nextConfig.channelId) { - removeMountedWidget(); - setStatus("请先填写 channelId"); - return; - } - - injectWidget(nextConfig); - setStatus("Widget 已挂载"); - } - - const sdkUrl = getWidgetSdkUrl(config.baseUrl, window.location.pathname); - const snippet = ` -`; - - return ( -
-
-
- -
- -
- -
-
-

- AI 优先接待,人工无缝协同 -

- -

- 贝壳 AI 客服是一个以 AI Agent 为核心的智能客服系统,把在线会话、 - 知识库检索、工单流转和人工接管串成一条完整链路。 -

- - - -
- {[ - { label: "接待模式", value: "AI + 人工协同" }, - { label: "回答方式", value: "知识库 RAG 驱动" }, - { label: "运行能力", value: "Skills + MCP + 工单流程" }, - ].map((item) => ( -
-
{item.label}
-
- {item.value} -
-
- ))} -
-
-
- -
-
-
-
产品能力
-

- 面向真实客服场景的开源系统 -

-

- 用一套系统处理用户咨询、AI 回复、知识检索、人工接管和后续工单。 -

-
- -
- {featureCards.map((item) => { - const Icon = item.icon; - return ( -
-
- -
-

- {item.title} -

-

- {item.description} -

-
- ); - })} -
-
-
- -
-
-
-
- 覆盖完整客服链路 -
-

- 从第一条消息到问题关闭,关键环节都在同一套系统里。 -

- -
- {scopeCards.map((item) => { - const Icon = item.icon; - return ( -
-
-
- -
-
-
- {item.title} -
-
- {item.description} -
-
-
-
- ); - })} -
-
- -
-
- 客服处理闭环 -
-

- 从用户发起咨询,到 AI 回复、人工接管、工单跟踪,核心流程都在同一套系统里完成。 -

- -
- {flowSteps.map((item) => ( -
-
- {item.step} -
-
-
- {item.title} -
-
- {item.description} -
-
-
- ))} -
-
-
-
- -
-
-
-
-
接入方式
-

- 保留在线配置与接入脚本,直接拿来演示和集成 -

-

- 这里可以直接填写 `channelId`、调整宿主参数并挂载 Widget,也可以复制脚本到业务系统完成基础接入。 -

-
-
- -
-
-
Widget 配置
-
- 宿主页面接入时需要的基础参数。 -
- -
- - - - - - - - - -
- -
- - -
-
- -
-
接入脚本
-
- 复制到宿主页面即可完成基础接入。 -
-
-                  {snippet}
-                
- -
-
-
-
- - -
-
- ); -} - -const WidgetHomePage = dynamic(async () => WidgetHomePageInner, { - ssr: false, -}); - -export default WidgetHomePage; diff --git a/widget/components.json b/widget/components.json deleted file mode 100644 index f382eb7..0000000 --- a/widget/components.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-nova", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} -} diff --git a/widget/components/im/chat-shell.tsx b/widget/components/im/chat-shell.tsx deleted file mode 100644 index f453663..0000000 --- a/widget/components/im/chat-shell.tsx +++ /dev/null @@ -1,323 +0,0 @@ -"use client"; - -import { - Maximize2Icon, - Minimize2Icon, - MinusIcon, - RotateCwIcon, - XIcon -} from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useShallow } from "zustand/react/shallow"; - -import { ConnectionStatus } from "@/components/im/connection-status"; -import { MessageEditor } from "@/components/im/message-editor"; -import { - MessageList, - type MessageListHandle, -} from "@/components/im/message-list"; -import { closeConversation } from "@/lib/services/conversation"; -import { useChatStore } from "@/lib/store/chat-store"; -import { - bindHostBridge, - requestHostClose, - requestHostMinimize, - requestHostToggleMaximize, -} from "@/lib/widget/host-bridge"; - -export function ChatShell() { - const messageListRef = useRef(null); - const [isMaximized, setIsMaximized] = useState(false); - const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); - const [isClosingConversation, setIsClosingConversation] = useState(false); - - const { - title, - subtitle, - themeColor, - conversation, - messages, - messagesHasMore, - messagesLoadingMore, - loadOlderMessages, - status, - error, - isOpen, - isVisible, - setIsOpen, - setIsVisible, - bootstrap, - handleSendMessage, - uploadMessageImage, - sendAttachment, - retry, - disconnectSocket, - markConversationRead, - } = useChatStore( - useShallow((state) => ({ - title: state.title, - subtitle: state.subtitle, - themeColor: state.themeColor, - conversation: state.conversation, - messages: state.messages, - messagesHasMore: state.messagesHasMore, - messagesLoadingMore: state.messagesLoadingMore, - loadOlderMessages: state.loadOlderMessages, - status: state.status, - error: state.error, - isOpen: state.isOpen, - isVisible: state.isVisible, - setIsOpen: state.setIsOpen, - setIsVisible: state.setIsVisible, - bootstrap: state.bootstrap, - handleSendMessage: state.handleSendMessage, - uploadMessageImage: state.uploadMessageImage, - sendAttachment: state.sendAttachment, - retry: state.retry, - disconnectSocket: state.disconnectSocket, - markConversationRead: state.markConversationRead, - })), - ); - - const maybeMarkConversationRead = useCallback(() => { - if (!isVisible || !conversation || typeof document === "undefined") { - return; - } - if (document.visibilityState !== "visible") { - return; - } - void markConversationRead().catch((error) => { - console.error("Failed to mark widget conversation read", error); - }); - }, [conversation, isVisible, markConversationRead]); - - useEffect(() => { - return bindHostBridge({ - onOpen: () => { - setIsOpen(true); - setIsVisible(true); - }, - onMinimize: () => { - setIsVisible(false); - }, - onMaximizedChange: (nextIsMaximized) => { - setIsMaximized(nextIsMaximized); - }, - }); - }, [setIsOpen, setIsVisible]); - - useEffect(() => { - bootstrap(); - - return () => { - if (!isOpen) { - disconnectSocket(); - } - }; - }, [isOpen, bootstrap, disconnectSocket]); - - useEffect(() => { - maybeMarkConversationRead(); - }, [maybeMarkConversationRead, messages.length]); - - useEffect(() => { - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") { - maybeMarkConversationRead(); - } - }; - const handleFocus = () => { - maybeMarkConversationRead(); - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("focus", handleFocus); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("focus", handleFocus); - }; - }, [maybeMarkConversationRead]); - - async function handleSend(content: string) { - await handleSendMessage(content); - messageListRef.current?.scrollToBottom(); - } - - function handleMinimize() { - setIsVisible(false); - requestHostMinimize(); - } - - function handleToggleMaximize() { - requestHostToggleMaximize(); - } - - async function confirmCloseConversation() { - if (isClosingConversation) { - return; - } - setIsClosingConversation(true); - try { - if (conversation?.id) { - await closeConversation(conversation.id); - } - setIsCloseDialogOpen(false); - requestHostClose(); - } catch (closeError) { - window.alert( - closeError instanceof Error ? closeError.message : "关闭会话失败", - ); - } finally { - setIsClosingConversation(false); - } - } - - useEffect(() => { - if (!isCloseDialogOpen) { - return; - } - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && !isClosingConversation) { - setIsCloseDialogOpen(false); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isCloseDialogOpen, isClosingConversation]); - - return ( -
-
-
-
-
-
-
- {title} -
-
- {subtitle} -
-
-
- {/*
- - 会话加密传输 -
*/} - {status !== "connected" ? ( - - ) : null} -
- - - - -
-
-
-
- -
- - -
- - {error ? ( -
- {error} -
- ) : null} -
- - {isCloseDialogOpen ? ( -
-
-
- {/*
- -
*/} -
-
- 结束当前对话? -
-
- 结束会话,客服将无法再查看您的消息记录,如需再次联系请重新发起对话。 -
-
-
- -
- - -
-
-
- ) : null} -
- ); -} diff --git a/widget/components/im/connection-status.tsx b/widget/components/im/connection-status.tsx deleted file mode 100644 index 0760167..0000000 --- a/widget/components/im/connection-status.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; - -type ConnectionStatusProps = { - status: "connecting" | "connected" | "disconnected"; -}; - -const statusText: Record = { - connecting: "连接中", - connected: "在线服务", - disconnected: "连接已断开", -}; - -export function ConnectionStatus({ status }: ConnectionStatusProps) { - const toneClass = - status === "connected" - ? "border-emerald-200/80 bg-emerald-50 text-emerald-700" - : status === "connecting" - ? "border-amber-200/80 bg-amber-50 text-amber-700" - : "border-slate-200/80 bg-slate-100 text-slate-600"; - - return ( -
- - {statusText[status]} -
- ); -} diff --git a/widget/components/im/message-editor.tsx b/widget/components/im/message-editor.tsx deleted file mode 100644 index d0893e0..0000000 --- a/widget/components/im/message-editor.tsx +++ /dev/null @@ -1,365 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { EditorContent, useEditor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import Image from "@tiptap/extension-image"; -import Placeholder from "@tiptap/extension-placeholder"; -import { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react"; - -import { generateUUID } from "@/lib/utils"; - -type UploadedImage = { - assetId: string; - provider: string; - storageKey: string; - url: string; - filename?: string; -}; - -const MessageImage = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - dataAssetId: { - default: null, - parseHTML: (element) => element.getAttribute("data-asset-id"), - renderHTML: (attributes) => - attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {}, - }, - dataProvider: { - default: null, - parseHTML: (element) => element.getAttribute("data-provider"), - renderHTML: (attributes) => - attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {}, - }, - dataStorageKey: { - default: null, - parseHTML: (element) => element.getAttribute("data-storage-key"), - renderHTML: (attributes) => - attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {}, - }, - }; - }, -}); - -type MessageEditorProps = { - disabled?: boolean; - uploadingAsset?: boolean; - onSend: (html: string) => Promise; - onUploadImage: (file: File) => Promise; - onSendAttachment: (file: File) => Promise; -}; - -export function MessageEditor({ - disabled = false, - uploadingAsset = false, - onSend, - onUploadImage, - onSendAttachment, -}: MessageEditorProps) { - 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 isUploading = uploadingAsset || localUploading; - - 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, - }), - MessageImage, - 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-[13px] leading-6 text-slate-900 outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-xl [&_img]:object-contain", - }, - 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 html = editor.getHTML(); - if (!isMeaningfulHTML(html)) { - return; - } - await onSendRef.current(html); - editor.commands.clearContent(true); - } - - 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); - const placeholderId = `uploading-${generateUUID()}`; - editor - .chain() - .focus() - .setImage({ - src: objectUrl, - alt: file.name || "uploading-image", - title: placeholderId, - }) - .run(); - - try { - setLocalUploading(true); - const uploaded = await onUploadImageRef.current(file); - if (!uploaded?.url) { - removeImageByTitle(editor, placeholderId); - return; - } - replaceImageSourceByTitle(editor, placeholderId, uploaded); - } finally { - setLocalUploading(false); - URL.revokeObjectURL(objectUrl); - 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(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; -} - -function removeImageByTitle(editor: NonNullable>, title: string) { - const { state } = editor; - let targetPos: number | null = null; - state.doc.descendants((node, pos) => { - if (node.type.name === "image" && node.attrs.title === title) { - targetPos = pos; - return false; - } - return true; - }); - if (targetPos === null) { - return; - } - editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run(); -} - -function replaceImageSourceByTitle( - editor: NonNullable>, - title: string, - uploaded: UploadedImage, -) { - const { state, view } = editor; - let targetPos: number | null = null; - state.doc.descendants((node, pos) => { - if (node.type.name === "image" && node.attrs.title === title) { - targetPos = pos; - return false; - } - return true; - }); - if (targetPos === null) { - return; - } - const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, { - ...view.state.doc.nodeAt(targetPos)?.attrs, - src: uploaded.url, - alt: uploaded.filename || "image", - dataAssetId: uploaded.assetId, - dataProvider: uploaded.provider, - dataStorageKey: uploaded.storageKey, - title: "", - }); - view.dispatch(transaction); -} diff --git a/widget/components/im/message-html.tsx b/widget/components/im/message-html.tsx deleted file mode 100644 index e753855..0000000 --- a/widget/components/im/message-html.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { memo, useEffect, useRef } from "react"; - -type MessageHTMLProps = { - html: string; - className?: string; - onImageSettled?: () => void; - onImageClick?: (src: string, alt?: string) => void; -}; - -function MessageHTMLComponent({ - html, - className = "", - onImageSettled, - onImageClick, -}: MessageHTMLProps) { - const containerRef = useRef(null); - const onImageSettledRef = useRef(onImageSettled); - const onImageClickRef = useRef(onImageClick); - - useEffect(() => { - onImageSettledRef.current = onImageSettled; - }, [onImageSettled]); - - useEffect(() => { - onImageClickRef.current = onImageClick; - }, [onImageClick]); - - useEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - const images = Array.from(container.querySelectorAll("img")); - if (images.length === 0) { - return; - } - const cleanups = images.map((image) => { - const handleSettled = () => onImageSettledRef.current?.(); - const handleClick = () => { - const src = image.getAttribute("src"); - if (src) { - const alt = image.getAttribute("alt") ?? undefined; - onImageClickRef.current?.(src, alt); - } - }; - image.addEventListener("load", handleSettled); - image.addEventListener("error", handleSettled); - image.addEventListener("click", handleClick); - if (image.complete) { - onImageSettledRef.current?.(); - } - image.classList.add("cursor-zoom-in"); - return () => { - image.removeEventListener("load", handleSettled); - image.removeEventListener("error", handleSettled); - image.removeEventListener("click", handleClick); - }; - }); - return () => { - cleanups.forEach((cleanup) => cleanup()); - }; - }, [html, onImageSettled, onImageClick]); - - return ( -

- ); -} - -export const MessageHTML = memo( - MessageHTMLComponent, - (prevProps, nextProps) => - prevProps.html === nextProps.html && - prevProps.className === nextProps.className && - prevProps.onImageSettled === nextProps.onImageSettled && - prevProps.onImageClick === nextProps.onImageClick, -); diff --git a/widget/components/im/message-input.tsx b/widget/components/im/message-input.tsx deleted file mode 100644 index 6a8613f..0000000 --- a/widget/components/im/message-input.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useLayoutEffect, useRef, useState } from "react"; -import { ImageIcon, SendHorizonalIcon } from "lucide-react"; - -type MessageInputProps = { - disabled?: boolean; - uploadingImage?: boolean; - onSend: (content: string) => Promise; - onSendImage: (file: File) => Promise; -}; - -export function MessageInput({ - disabled, - uploadingImage = false, - onSend, - onSendImage, -}: MessageInputProps) { - const [value, setValue] = useState(""); - const [submitting, setSubmitting] = useState(false); - const textareaRef = useRef(null); - const imageInputRef = useRef(null); - - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - const lineHeight = 20; - const maxHeight = lineHeight * 8; - textarea.style.height = "0px"; - textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; - textarea.style.overflowY = - textarea.scrollHeight > maxHeight ? "auto" : "hidden"; - }, [value]); - - async function handleSubmit() { - const content = value.trim(); - if (!content || disabled || submitting) { - return; - } - setSubmitting(true); - try { - await onSend(content); - setValue(""); - } finally { - setSubmitting(false); - requestAnimationFrame(() => { - textareaRef.current?.focus(); - }); - } - } - - async function handleSelectImage(event: React.ChangeEvent) { - const file = event.target.files?.[0]; - event.target.value = ""; - if (!file || disabled || uploadingImage || submitting) { - return; - } - if (!file.type.startsWith("image/")) { - return; - } - try { - await onSendImage(file); - } finally { - requestAnimationFrame(() => { - textareaRef.current?.focus(); - }); - } - } - - return ( -
-
- -
-