Remove widget
This commit is contained in:
@@ -75,13 +75,6 @@ func NewServer() (*iris.Application, error) {
|
|||||||
ShowList: false,
|
ShowList: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 注册widget静态资源服务
|
|
||||||
app.HandleDir("/widget", iris.Dir("widget/out"), iris.DirOptions{
|
|
||||||
IndexName: "index.html",
|
|
||||||
Compress: true,
|
|
||||||
ShowList: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8083
|
|
||||||
@@ -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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ChatShell } from "@/components/im/chat-shell";
|
|
||||||
|
|
||||||
export default function FramePage() {
|
|
||||||
return <ChatShell />;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<body>
|
|
||||||
<ImageLightboxProvider>{children}</ImageLightboxProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<TestConfig>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
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<TestConfig>(getInitialConfig);
|
|
||||||
const [status, setStatus] = useState(() =>
|
|
||||||
getInitialConfig().channelId ? "Widget 已挂载" : "请先填写 channelId",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config.channelId) {
|
|
||||||
injectWidget(config);
|
|
||||||
} else {
|
|
||||||
removeMountedWidget();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
removeMountedWidget();
|
|
||||||
};
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
function updateField<K extends keyof TestConfig>(
|
|
||||||
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 = `<script>
|
|
||||||
window.CSAgentConfig = {
|
|
||||||
channelId: "${config.channelId || ""}",
|
|
||||||
baseUrl: "${config.baseUrl}",
|
|
||||||
apiBaseUrl: "${config.apiBaseUrl || config.baseUrl}",
|
|
||||||
title: "${config.title || "IoT 智能客服"}",
|
|
||||||
subtitle: "${config.subtitle || "设备告警、工单协同与在线服务统一入口"}",
|
|
||||||
position: "${config.position || "right"}",
|
|
||||||
themeColor: "${config.themeColor || "#2563eb"}",
|
|
||||||
width: "${config.width || "680px"}",
|
|
||||||
subject: "${config.subject || ""}",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<script async src="${sdkUrl}"></script>`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen overflow-y-auto overflow-x-hidden scroll-smooth">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[24rem] bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.14),transparent_46%)]" />
|
|
||||||
|
|
||||||
<header className="sticky top-0 z-20 border-b border-slate-200/80 bg-white/90 backdrop-blur-xl">
|
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 md:px-6">
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_SITE_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-3 text-slate-950"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/images/logo.png"
|
|
||||||
alt="贝壳 AI 客服"
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="h-10 w-10 rounded-2xl object-contain ring-1 ring-slate-200"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-base font-semibold">
|
|
||||||
贝壳 AI 客服
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<nav className="hidden items-center gap-8 text-sm text-slate-600 md:flex">
|
|
||||||
<a href="#features" className="transition hover:text-slate-950">
|
|
||||||
产品能力
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={GITHUB_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="transition hover:text-slate-950"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_SITE_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="transition hover:text-slate-950"
|
|
||||||
>
|
|
||||||
官网
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="#integration"
|
|
||||||
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
立即体验
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="px-4 pb-18 pt-16 md:px-6 md:pb-24 md:pt-20">
|
|
||||||
<div className="mx-auto flex max-w-4xl flex-col items-center text-center">
|
|
||||||
<h1 className="mt-6 text-4xl font-semibold tracking-[-0.05em] text-slate-950 md:text-6xl">
|
|
||||||
AI 优先接待,人工无缝协同
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
|
||||||
贝壳 AI 客服是一个以 AI Agent 为核心的智能客服系统,把在线会话、
|
|
||||||
知识库检索、工单流转和人工接管串成一条完整链路。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<a
|
|
||||||
href="#integration"
|
|
||||||
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-5 py-3 text-sm font-medium text-white transition hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
开始接入
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_SITE_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-full border border-slate-200 bg-white px-5 py-3 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:text-slate-950"
|
|
||||||
>
|
|
||||||
查看官网
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 grid w-full gap-4 md:grid-cols-3">
|
|
||||||
{[
|
|
||||||
{ label: "接待模式", value: "AI + 人工协同" },
|
|
||||||
{ label: "回答方式", value: "知识库 RAG 驱动" },
|
|
||||||
{ label: "运行能力", value: "Skills + MCP + 工单流程" },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className="rounded-lg border border-white/80 bg-white/90 p-6 text-left shadow-[0_18px_40px_rgba(15,23,42,0.05)]"
|
|
||||||
>
|
|
||||||
<div className="text-sm text-slate-500">{item.label}</div>
|
|
||||||
<div className="mt-2 text-xl font-semibold text-slate-950">
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="features" className="px-4 py-8 md:px-6 md:py-10">
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<div className="text-sm font-medium text-blue-700">产品能力</div>
|
|
||||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-slate-950">
|
|
||||||
面向真实客服场景的开源系统
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-base leading-8 text-slate-600">
|
|
||||||
用一套系统处理用户咨询、AI 回复、知识检索、人工接管和后续工单。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{featureCards.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={item.title}
|
|
||||||
className="rounded-lg border border-slate-200 bg-white p-6 shadow-[0_18px_40px_rgba(15,23,42,0.04)]"
|
|
||||||
>
|
|
||||||
<div className="inline-flex rounded-2xl bg-blue-50 p-3 text-blue-700">
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-4 text-lg font-semibold text-slate-950">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-600">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="px-4 py-16 md:px-6">
|
|
||||||
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-7 shadow-[0_24px_60px_rgba(15,23,42,0.06)]">
|
|
||||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-slate-950">
|
|
||||||
覆盖完整客服链路
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-base leading-8 text-slate-600">
|
|
||||||
从第一条消息到问题关闭,关键环节都在同一套系统里。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-1">
|
|
||||||
{scopeCards.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.title}
|
|
||||||
className="rounded-lg border border-slate-200 bg-slate-50 p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="rounded-2xl bg-blue-50 p-3 text-blue-700">
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-semibold text-slate-950">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm leading-7 text-slate-600">
|
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 p-7 text-white shadow-[0_24px_60px_rgba(15,23,42,0.12)]">
|
|
||||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em]">
|
|
||||||
客服处理闭环
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
|
||||||
从用户发起咨询,到 AI 回复、人工接管、工单跟踪,核心流程都在同一套系统里完成。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
{flowSteps.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.step}
|
|
||||||
className="flex items-start gap-4 rounded-lg border border-white/10 bg-white/5 p-5"
|
|
||||||
>
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-sm font-semibold text-slate-950">
|
|
||||||
{item.step}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-white">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm leading-7 text-slate-200">
|
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="integration" className="px-4 py-16 md:px-6 md:py-20">
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<div className="text-sm font-medium text-blue-700">接入方式</div>
|
|
||||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-slate-950">
|
|
||||||
保留在线配置与接入脚本,直接拿来演示和集成
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-base leading-8 text-slate-600">
|
|
||||||
这里可以直接填写 `channelId`、调整宿主参数并挂载 Widget,也可以复制脚本到业务系统完成基础接入。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-6 xl:grid-cols-[0.94fr_1.06fr]">
|
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-6 shadow-[0_24px_60px_rgba(15,23,42,0.06)]">
|
|
||||||
<div className="text-lg font-semibold text-slate-950">Widget 配置</div>
|
|
||||||
<div className="mt-1 text-sm text-slate-500">
|
|
||||||
宿主页面接入时需要的基础参数。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
channelId
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.channelId}
|
|
||||||
onChange={(event) => updateField("channelId", event.target.value)}
|
|
||||||
placeholder="请输入后台渠道 channelId"
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
baseUrl
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.baseUrl}
|
|
||||||
onChange={(event) => updateField("baseUrl", event.target.value)}
|
|
||||||
placeholder="Widget 地址,例如 http://localhost:3001"
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
apiBaseUrl
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.apiBaseUrl ?? ""}
|
|
||||||
onChange={(event) => updateField("apiBaseUrl", event.target.value)}
|
|
||||||
placeholder="后端地址,例如 http://localhost:8080"
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
标题
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.title ?? ""}
|
|
||||||
onChange={(event) => updateField("title", event.target.value)}
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
副标题
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.subtitle ?? ""}
|
|
||||||
onChange={(event) => updateField("subtitle", event.target.value)}
|
|
||||||
placeholder="例如:设备告警、工单协同与在线服务统一入口"
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
主题色
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.themeColor ?? ""}
|
|
||||||
onChange={(event) => updateField("themeColor", event.target.value)}
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
宽度
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.width ?? ""}
|
|
||||||
onChange={(event) => updateField("width", event.target.value)}
|
|
||||||
placeholder="例如 680px、50vw"
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
会话主题
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={config.subject ?? ""}
|
|
||||||
onChange={(event) => updateField("subject", event.target.value)}
|
|
||||||
placeholder="例如:站点A-储能柜-001"
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
|
||||||
悬浮位置
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={config.position ?? "right"}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateField(
|
|
||||||
"position",
|
|
||||||
event.target.value as "left" | "right",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
|
||||||
>
|
|
||||||
<option value="right">右侧</option>
|
|
||||||
<option value="left">左侧</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleApply}
|
|
||||||
className="rounded-full bg-blue-600 px-5 py-3 text-sm font-medium text-white transition hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
挂载 Widget
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const nextConfig = {
|
|
||||||
...buildDefaultConfig(window.location.origin),
|
|
||||||
subject: generateRandomSubject(),
|
|
||||||
};
|
|
||||||
setConfig(nextConfig);
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig));
|
|
||||||
window.localStorage.removeItem("cs-agent:external-id");
|
|
||||||
removeMountedWidget();
|
|
||||||
setStatus("已重置");
|
|
||||||
}}
|
|
||||||
className="rounded-full border border-slate-200 bg-white px-5 py-3 text-sm font-medium text-slate-700 transition hover:border-slate-300"
|
|
||||||
>
|
|
||||||
重置配置
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-slate-200 bg-white p-6 shadow-[0_24px_60px_rgba(15,23,42,0.06)]">
|
|
||||||
<div className="text-lg font-semibold text-slate-950">接入脚本</div>
|
|
||||||
<div className="mt-1 text-sm text-slate-500">
|
|
||||||
复制到宿主页面即可完成基础接入。
|
|
||||||
</div>
|
|
||||||
<pre className="cs-agent-scrollbar mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-slate-950 p-5 text-xs leading-6 text-slate-100">
|
|
||||||
<code>{snippet}</code>
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="border-t border-slate-200 bg-white px-4 py-8 md:px-6">
|
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>贝壳 AI 客服</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-5">
|
|
||||||
<a
|
|
||||||
href={GITHUB_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="transition hover:text-slate-900"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={GITEE_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="transition hover:text-slate-900"
|
|
||||||
>
|
|
||||||
Gitee
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_SITE_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="transition hover:text-slate-900"
|
|
||||||
>
|
|
||||||
官网
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const WidgetHomePage = dynamic(async () => WidgetHomePageInner, {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default WidgetHomePage;
|
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
@@ -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<MessageListHandle | null>(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 (
|
|
||||||
<main
|
|
||||||
className="cs-agent-shell relative flex h-screen overflow-hidden bg-(--background)"
|
|
||||||
style={{ "--primary": themeColor } as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<section className="cs-agent-panel flex h-full w-full flex-col overflow-hidden border border-white/70">
|
|
||||||
<header className="relative shrink-0 overflow-hidden border-b border-white/60 px-4 pb-3 pt-3 shadow-[0_10px_24px_rgba(15,23,42,0.05)]">
|
|
||||||
<div className="absolute inset-x-0 top-0 h-20 bg-[radial-gradient(circle_at_top_right,rgba(37,99,235,0.14),transparent_52%)]" />
|
|
||||||
<div className="relative flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-[16px] font-semibold tracking-[0.01em] text-slate-950">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[12px] text-slate-500">
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* <div className="inline-flex items-center gap-1.5 rounded-full border border-white/70 bg-white/65 px-2.5 py-1 text-[11px] text-slate-500 shadow-[0_6px_18px_rgba(15,23,42,0.04)]">
|
|
||||||
<ShieldCheckIcon className="size-3.5 text-emerald-600" />
|
|
||||||
会话加密传输
|
|
||||||
</div> */}
|
|
||||||
{status !== "connected" ? (
|
|
||||||
<ConnectionStatus status={status} />
|
|
||||||
) : null}
|
|
||||||
<div className="inline-flex items-center gap-1 rounded-[18px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(241,245,249,0.82))] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_12px_28px_rgba(15,23,42,0.07)] backdrop-blur-xl">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={retry}
|
|
||||||
aria-label="重新连接"
|
|
||||||
title="重新连接"
|
|
||||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-slate-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(239,246,255,0.96))] hover:text-sky-600 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(56,189,248,0.16)]"
|
|
||||||
>
|
|
||||||
<RotateCwIcon className="size-3.75 transition duration-200 group-hover:rotate-[-20deg]" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleMinimize}
|
|
||||||
aria-label="收起聊天窗口"
|
|
||||||
title="收起聊天窗口"
|
|
||||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-slate-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] hover:text-slate-700 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(15,23,42,0.10)]"
|
|
||||||
>
|
|
||||||
<MinusIcon className="size-3.75 transition duration-200 group-hover:scale-x-[0.88]" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggleMaximize}
|
|
||||||
aria-label={isMaximized ? "取消最大化" : "最大化聊天窗口"}
|
|
||||||
title={isMaximized ? "取消最大化" : "最大化聊天窗口"}
|
|
||||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-slate-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(238,249,244,0.96))] hover:text-emerald-700 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(16,185,129,0.14)]"
|
|
||||||
>
|
|
||||||
{isMaximized ? (
|
|
||||||
<Minimize2Icon className="size-3.75 transition duration-200 group-hover:scale-[0.94]" />
|
|
||||||
) : (
|
|
||||||
<Maximize2Icon className="size-3.75 transition duration-200 group-hover:scale-[1.04]" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsCloseDialogOpen(true)}
|
|
||||||
aria-label="关闭聊天窗口"
|
|
||||||
title="关闭聊天窗口"
|
|
||||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-rose-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,241,242,0.98))] hover:text-rose-600 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(244,63,94,0.16)]"
|
|
||||||
>
|
|
||||||
<XIcon className="size-3.75 transition duration-200 group-hover:scale-[0.92]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className=".cs-agent-grid-bg grid min-h-0 flex-1 overflow-hidden grid-rows-[minmax(0,1fr)_auto]">
|
|
||||||
<MessageList
|
|
||||||
ref={messageListRef}
|
|
||||||
messages={messages}
|
|
||||||
onNearBottomVisible={maybeMarkConversationRead}
|
|
||||||
hasMoreOlder={messagesHasMore}
|
|
||||||
loadingOlder={messagesLoadingMore}
|
|
||||||
onLoadOlder={loadOlderMessages}
|
|
||||||
/>
|
|
||||||
<MessageEditor
|
|
||||||
disabled={!conversation}
|
|
||||||
onSend={handleSend}
|
|
||||||
onUploadImage={uploadMessageImage}
|
|
||||||
onSendAttachment={sendAttachment}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="border-t border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{isCloseDialogOpen ? (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.16),transparent_38%),rgba(15,23,42,0.24)] px-5 backdrop-blur-sm">
|
|
||||||
<div className="cs-agent-fade-up w-full max-w-[320px] rounded-[26px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(241,245,249,0.94))] p-5 shadow-[0_28px_80px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.92)]">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{/* <div className="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-linear-to-b from-rose-50 to-rose-100 text-rose-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_12px_22px_rgba(244,63,94,0.12)]">
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</div> */}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-[15px] font-semibold tracking-[0.01em] text-slate-950">
|
|
||||||
结束当前对话?
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 text-[12px] leading-5 text-slate-500">
|
|
||||||
结束会话,客服将无法再查看您的消息记录,如需再次联系请重新发起对话。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isClosingConversation}
|
|
||||||
onClick={() => setIsCloseDialogOpen(false)}
|
|
||||||
className="inline-flex h-9 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/80 px-4 text-[12px] font-medium text-slate-600 shadow-[0_10px_20px_rgba(15,23,42,0.05)] transition hover:-translate-y-0.5 hover:border-slate-300 hover:bg-white hover:text-slate-800 disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
继续对话
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isClosingConversation}
|
|
||||||
onClick={() => void confirmCloseConversation()}
|
|
||||||
className="inline-flex h-9 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#f43f5e,#fb7185)] px-4 text-[12px] font-semibold text-white shadow-[0_14px_28px_rgba(244,63,94,0.24)] transition hover:-translate-y-0.5 hover:opacity-92 disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{isClosingConversation ? "结束中..." : "确认结束"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type ConnectionStatusProps = {
|
|
||||||
status: "connecting" | "connected" | "disconnected";
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusText: Record<ConnectionStatusProps["status"], string> = {
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.02em] shadow-[0_6px_16px_rgba(15,23,42,0.06)]",
|
|
||||||
toneClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"cs-agent-status-dot inline-block size-2 rounded-full",
|
|
||||||
status === "connected"
|
|
||||||
? "bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]"
|
|
||||||
: status === "connecting"
|
|
||||||
? "bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.16)]"
|
|
||||||
: "bg-slate-400 shadow-[0_0_0_4px_rgba(148,163,184,0.14)]",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{statusText[status]}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<void>;
|
|
||||||
onUploadImage: (file: File) => Promise<UploadedImage | null>;
|
|
||||||
onSendAttachment: (file: File) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MessageEditor({
|
|
||||||
disabled = false,
|
|
||||||
uploadingAsset = false,
|
|
||||||
onSend,
|
|
||||||
onUploadImage,
|
|
||||||
onSendAttachment,
|
|
||||||
}: MessageEditorProps) {
|
|
||||||
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 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<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);
|
|
||||||
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<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="px-3 pb-3 pt-2">
|
|
||||||
<div className="rounded-xl border border-white/60 bg-white/78 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.05)] backdrop-blur">
|
|
||||||
<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-1.5 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true;
|
|
||||||
imageInputRef.current?.click();
|
|
||||||
}}
|
|
||||||
disabled={disabled || isUploading}
|
|
||||||
aria-label={isUploading ? "图片上传中" : "发送图片"}
|
|
||||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-xl border border-slate-200/80 bg-white/90 text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.05)] transition duration-200 hover:-translate-y-0.5 hover:text-slate-700 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-300"
|
|
||||||
>
|
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true;
|
|
||||||
attachmentInputRef.current?.click();
|
|
||||||
}}
|
|
||||||
disabled={disabled || isUploading}
|
|
||||||
aria-label={isUploading ? "附件上传中" : "发送附件"}
|
|
||||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-xl border border-slate-200/80 bg-white/90 text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.05)] transition duration-200 hover:-translate-y-0.5 hover:text-slate-700 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-300"
|
|
||||||
>
|
|
||||||
<PaperclipIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<p className="text-[10px] text-slate-400">Enter 发送</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSend()}
|
|
||||||
disabled={disabled || isUploading}
|
|
||||||
aria-label="发送"
|
|
||||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,var(--primary),color-mix(in_srgb,var(--primary)_75%,white_25%))] text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_28%,transparent)] transition duration-200 hover:-translate-y-0.5 hover:brightness-105 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
|
|
||||||
>
|
|
||||||
<SendHorizonalIcon className="size-4" />
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeImageByTitle(editor: NonNullable<ReturnType<typeof useEditor>>, 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<ReturnType<typeof useEditor>>,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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<HTMLDivElement>(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 (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={`break-words [&_p]:m-0 [&_p+*]:mt-2 [&_h1]:m-0 [&_h1]:text-base [&_h1]:font-semibold [&_h1+*]:mt-2 [&_h2]:m-0 [&_h2]:text-[15px] [&_h2]:font-semibold [&_h2+*]:mt-2 [&_h3]:m-0 [&_h3]:font-semibold [&_h3+*]:mt-2 [&_h4]:m-0 [&_h4]:font-medium [&_h4+*]:mt-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-current/20 [&_blockquote]:pl-3 [&_blockquote]:opacity-90 [&_pre]:my-2 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-black/6 [&_pre]:px-3 [&_pre]:py-2 [&_pre]:text-[13px] [&_pre]:leading-6 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 [&_code]:py-0.5 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_hr]:my-3 [&_hr]:border-current/10 [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-current/10 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-medium [&_td]:border [&_td]:border-current/10 [&_td]:px-2 [&_td]:py-1 [&_img]:my-2 [&_img]:max-h-64 [&_img]:max-w-full [&_img]:rounded-xl [&_img]:object-contain [&_.im-attachment]:min-w-0 [&_.im-attachment-link]:flex [&_.im-attachment-link]:min-w-0 [&_.im-attachment-link]:items-center [&_.im-attachment-link]:gap-3 [&_.im-attachment-link]:rounded-2xl [&_.im-attachment-link]:no-underline [&_.im-attachment-link]:transition-colors hover:[&_.im-attachment-link]:bg-black/5 [&_.im-attachment-icon]:flex [&_.im-attachment-icon]:size-10 [&_.im-attachment-icon]:shrink-0 [&_.im-attachment-icon]:items-center [&_.im-attachment-icon]:justify-center [&_.im-attachment-icon]:rounded-2xl [&_.im-attachment-icon]:bg-black/5 [&_.im-attachment-icon_svg]:size-5 [&_.im-attachment-content]:flex [&_.im-attachment-content]:min-w-0 [&_.im-attachment-content]:flex-col [&_.im-attachment-title]:truncate [&_.im-attachment-title]:font-medium [&_.im-attachment-meta]:text-xs [&_.im-attachment-meta]:opacity-70 ${className}`}
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageHTML = memo(
|
|
||||||
MessageHTMLComponent,
|
|
||||||
(prevProps, nextProps) =>
|
|
||||||
prevProps.html === nextProps.html &&
|
|
||||||
prevProps.className === nextProps.className &&
|
|
||||||
prevProps.onImageSettled === nextProps.onImageSettled &&
|
|
||||||
prevProps.onImageClick === nextProps.onImageClick,
|
|
||||||
);
|
|
||||||
@@ -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<void>;
|
|
||||||
onSendImage: (file: File) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MessageInput({
|
|
||||||
disabled,
|
|
||||||
uploadingImage = false,
|
|
||||||
onSend,
|
|
||||||
onSendImage,
|
|
||||||
}: MessageInputProps) {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) {
|
|
||||||
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 (
|
|
||||||
<div className="px-3 pb-3 pt-2">
|
|
||||||
<div className="rounded-3xl border border-white/60 bg-white/78 p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.05)] backdrop-blur">
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleSelectImage}
|
|
||||||
/>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => setValue(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
void handleSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="输入消息,Enter 发送,Shift + Enter 换行"
|
|
||||||
disabled={disabled || uploadingImage}
|
|
||||||
rows={2}
|
|
||||||
className="cs-agent-scrollbar min-h-12 flex-1 resize-none bg-transparent px-1.5 pt-1 text-[13px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => imageInputRef.current?.click()}
|
|
||||||
disabled={disabled || uploadingImage || submitting}
|
|
||||||
aria-label={uploadingImage ? "图片上传中" : "发送图片"}
|
|
||||||
className="mt-1 inline-flex size-11 shrink-0 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/90 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.05)] transition duration-200 hover:-translate-y-0.5 hover:text-slate-700 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-300"
|
|
||||||
>
|
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={disabled || submitting || uploadingImage}
|
|
||||||
aria-label={submitting ? "发送中" : "发送"}
|
|
||||||
className="mt-1 inline-flex size-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,var(--primary),color-mix(in_srgb,var(--primary)_75%,white_25%))] text-white shadow-[0_12px_26px_color-mix(in_srgb,var(--primary)_28%,transparent)] transition duration-200 hover:-translate-y-0.5 hover:brightness-105 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
|
|
||||||
>
|
|
||||||
<SendHorizonalIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 px-1.5 text-[11px] text-slate-400">
|
|
||||||
Enter 发送,支持图片上传
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
import { MessageHTML } from "@/components/im/message-html";
|
|
||||||
import { useImageLightbox } from "@/components/image-lightbox";
|
|
||||||
import { renderMessageHTML } from "@/lib/services/message-asset";
|
|
||||||
import type { WidgetMessage } from "@/lib/services/types";
|
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
|
||||||
|
|
||||||
type MessageListProps = {
|
|
||||||
messages: WidgetMessage[];
|
|
||||||
onNearBottomVisible?: () => void;
|
|
||||||
hasMoreOlder?: boolean;
|
|
||||||
loadingOlder?: boolean;
|
|
||||||
onLoadOlder?: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageListHandle = {
|
|
||||||
scrollToBottom: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDayKey(value?: string) {
|
|
||||||
if (!value) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value.slice(0, 10);
|
|
||||||
}
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
|
|
||||||
date.getDate(),
|
|
||||||
).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimelineLabel(value?: string) {
|
|
||||||
if (!value) {
|
|
||||||
return "刚刚";
|
|
||||||
}
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const now = new Date();
|
|
||||||
const currentDayKey = getDayKey(value);
|
|
||||||
const todayDayKey = getDayKey(now.toISOString());
|
|
||||||
const timeText = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
||||||
if (currentDayKey === todayDayKey) {
|
|
||||||
return `今天 ${timeText}`;
|
|
||||||
}
|
|
||||||
return `${currentDayKey} ${timeText}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
|
|
||||||
function MessageList(
|
|
||||||
{
|
|
||||||
messages,
|
|
||||||
onNearBottomVisible,
|
|
||||||
hasMoreOlder = false,
|
|
||||||
loadingOlder = false,
|
|
||||||
onLoadOlder,
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const frameRef = useRef<number | null>(null);
|
|
||||||
const shouldStickToBottomRef = useRef(true);
|
|
||||||
const lastMessageId = messages.at(-1)?.id;
|
|
||||||
|
|
||||||
const isNearBottom = useCallback(
|
|
||||||
(element: HTMLElement, threshold = 80) =>
|
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight <=
|
|
||||||
threshold,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scheduleScrollToBottom = useCallback(
|
|
||||||
(attempts = 4) => {
|
|
||||||
if (frameRef.current !== null) {
|
|
||||||
cancelAnimationFrame(frameRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = (remaining: number, previousHeight = -1) => {
|
|
||||||
frameRef.current = requestAnimationFrame(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) {
|
|
||||||
frameRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentHeight = container.scrollHeight;
|
|
||||||
scrollToBottom();
|
|
||||||
if (remaining > 1 && currentHeight !== previousHeight) {
|
|
||||||
run(remaining - 1, currentHeight);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
frameRef.current = null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
run(attempts);
|
|
||||||
},
|
|
||||||
[scrollToBottom],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImageSettled = useCallback(() => {
|
|
||||||
if (shouldStickToBottomRef.current) {
|
|
||||||
scheduleScrollToBottom();
|
|
||||||
onNearBottomVisible?.();
|
|
||||||
}
|
|
||||||
}, [onNearBottomVisible, scheduleScrollToBottom]);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
scrollToBottom,
|
|
||||||
}));
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
shouldStickToBottomRef.current = true;
|
|
||||||
scheduleScrollToBottom();
|
|
||||||
return () => {
|
|
||||||
if (frameRef.current !== null) {
|
|
||||||
cancelAnimationFrame(frameRef.current);
|
|
||||||
frameRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [lastMessageId, scheduleScrollToBottom]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
const content = contentRef.current;
|
|
||||||
if (!container || !content) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
shouldStickToBottomRef.current = isNearBottom(container);
|
|
||||||
if (shouldStickToBottomRef.current) {
|
|
||||||
onNearBottomVisible?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (shouldStickToBottomRef.current) {
|
|
||||||
scheduleScrollToBottom();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
handleScroll();
|
|
||||||
container.addEventListener("scroll", handleScroll);
|
|
||||||
resizeObserver.observe(content);
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener("scroll", handleScroll);
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
isNearBottom,
|
|
||||||
onNearBottomVisible,
|
|
||||||
scheduleScrollToBottom,
|
|
||||||
scrollToBottom,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleLoadOlder = useCallback(async () => {
|
|
||||||
if (!onLoadOlder || loadingOlder || !hasMoreOlder) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const anchor = {
|
|
||||||
height: container.scrollHeight,
|
|
||||||
top: container.scrollTop,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await onLoadOlder();
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const c = containerRef.current;
|
|
||||||
if (!c) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
c.scrollTop = c.scrollHeight - anchor.height + anchor.top;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [hasMoreOlder, loadingOlder, onLoadOlder]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 cs-agent-scrollbar"
|
|
||||||
>
|
|
||||||
<div ref={contentRef} className="flex flex-col gap-4">
|
|
||||||
{hasMoreOlder && onLoadOlder ? (
|
|
||||||
<div className="flex justify-center py-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={loadingOlder}
|
|
||||||
onClick={() => void handleLoadOlder()}
|
|
||||||
className="rounded-full border border-white/70 bg-white/75 px-3 py-1 text-[11px] font-medium text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.04)] backdrop-blur transition hover:-translate-y-0.5 hover:border-sky-200 hover:text-sky-700 disabled:translate-y-0 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{loadingOlder ? "加载中…" : "加载更早的消息"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* <WelcomePanel title={title} welcomeText={welcomeText} /> */}
|
|
||||||
|
|
||||||
{/* {messages.length === 0 ? (
|
|
||||||
<div className="cs-agent-fade-up rounded-3xl border border-dashed border-slate-200 bg-white/72 px-4 py-5 text-sm leading-6 text-slate-500 shadow-[0_10px_22px_rgba(15,23,42,0.04)] backdrop-blur">
|
|
||||||
开始发送第一条消息后,会在这里保留完整会话记录。
|
|
||||||
</div>
|
|
||||||
) : null} */}
|
|
||||||
|
|
||||||
{messages.map((message, index) => {
|
|
||||||
const previousMessage = index > 0 ? messages[index - 1] : null;
|
|
||||||
const showTimeline =
|
|
||||||
index === 0 ||
|
|
||||||
getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MessageItem
|
|
||||||
key={message.id}
|
|
||||||
message={message}
|
|
||||||
showTimeline={showTimeline}
|
|
||||||
onImageSettled={handleImageSettled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
type MessageItemProps = {
|
|
||||||
message: WidgetMessage;
|
|
||||||
showTimeline: boolean;
|
|
||||||
onImageSettled: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MessageItem = memo(
|
|
||||||
function MessageItem({
|
|
||||||
message,
|
|
||||||
showTimeline,
|
|
||||||
onImageSettled,
|
|
||||||
}: MessageItemProps) {
|
|
||||||
const { open: openImageLightbox } = useImageLightbox();
|
|
||||||
const isCustomer = message.senderType === "customer";
|
|
||||||
const senderName = isCustomer ? "我" : message.senderName?.trim() || "客服";
|
|
||||||
const agentAvatarSrc =
|
|
||||||
!isCustomer && message.senderAvatar?.trim()
|
|
||||||
? message.senderAvatar.trim()
|
|
||||||
: undefined;
|
|
||||||
const htmlContent = buildMessageHTML(message);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cs-agent-fade-up">
|
|
||||||
{showTimeline ? (
|
|
||||||
<div className="mb-3 flex items-center justify-center">
|
|
||||||
<div className="rounded-full border border-white/70 bg-white/80 px-3 py-1 text-[11px] font-medium text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.04)] backdrop-blur">
|
|
||||||
{getTimelineLabel(message.sentAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex gap-2",
|
|
||||||
isCustomer ? "justify-end" : "justify-start",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isCustomer && agentAvatarSrc ? (
|
|
||||||
<Image
|
|
||||||
src={agentAvatarSrc}
|
|
||||||
alt=""
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="size-8 shrink-0 rounded-full object-cover ring-1 ring-white/80"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex max-w-[86%] flex-col gap-1",
|
|
||||||
isCustomer ? "items-end" : "items-start",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 px-1 text-[11px] text-slate-400">
|
|
||||||
<span className="font-medium">{senderName}</span>
|
|
||||||
<span>{formatDateTime(message.sentAt)}</span>
|
|
||||||
{isCustomer ? (
|
|
||||||
<span>{message.agentRead ? "客服已读" : "客服未读"}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-3 py-2 text-sm leading-normal shadow-[0_14px_28px_rgba(15,23,42,0.08)]",
|
|
||||||
isCustomer
|
|
||||||
? "bg-[#A9EA7A] text-[#161616]"
|
|
||||||
: "border border-white/80 bg-white/94 text-slate-900",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MessageHTML
|
|
||||||
html={htmlContent}
|
|
||||||
className={cn(
|
|
||||||
isCustomer
|
|
||||||
? "[&_p]:text-[#161616] [&_a]:text-[#161616] [&_a]:underline [&_img]:cursor-zoom-in"
|
|
||||||
: "[&_a]:text-slate-900 [&_a]:underline [&_img]:cursor-zoom-in",
|
|
||||||
)}
|
|
||||||
onImageSettled={onImageSettled}
|
|
||||||
onImageClick={openImageLightbox}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
(prevProps, nextProps) =>
|
|
||||||
prevProps.message === nextProps.message &&
|
|
||||||
prevProps.showTimeline === nextProps.showTimeline &&
|
|
||||||
prevProps.onImageSettled === nextProps.onImageSettled,
|
|
||||||
);
|
|
||||||
|
|
||||||
function buildMessageHTML(message: WidgetMessage) {
|
|
||||||
return renderMessageHTML(message);
|
|
||||||
}
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ExternalLinkIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
RotateCcwIcon,
|
|
||||||
RotateCwIcon,
|
|
||||||
XIcon,
|
|
||||||
ZoomInIcon,
|
|
||||||
ZoomOutIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import type { ReactZoomPanPinchContentRef } from "react-zoom-pan-pinch";
|
|
||||||
import {
|
|
||||||
TransformComponent,
|
|
||||||
TransformWrapper,
|
|
||||||
} from "react-zoom-pan-pinch";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type ImageLightboxItem = {
|
|
||||||
src: string;
|
|
||||||
alt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImageLightboxContextValue = {
|
|
||||||
open: (src: string, alt?: string) => void;
|
|
||||||
close: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ImageLightboxContext = createContext<ImageLightboxContextValue | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconBtnClass =
|
|
||||||
"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-white outline-none transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/40";
|
|
||||||
|
|
||||||
export function useImageLightbox(): ImageLightboxContextValue {
|
|
||||||
const ctx = useContext(ImageLightboxContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useImageLightbox 必须在 ImageLightboxProvider 内使用");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useImageLightboxOptional(): ImageLightboxContextValue | null {
|
|
||||||
return useContext(ImageLightboxContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ImageLightboxProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
src: string | null;
|
|
||||||
alt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function canOpenInNewTab(url: string): boolean {
|
|
||||||
if (!url) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (url.startsWith("/")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return (
|
|
||||||
parsed.protocol === "http:" ||
|
|
||||||
parsed.protocol === "https:" ||
|
|
||||||
parsed.protocol === "blob:"
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function LightboxImageBody({
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
pinchRef,
|
|
||||||
rotationDeg,
|
|
||||||
}: {
|
|
||||||
src: string;
|
|
||||||
alt?: string;
|
|
||||||
pinchRef: React.RefObject<ReactZoomPanPinchContentRef | null>;
|
|
||||||
rotationDeg: number;
|
|
||||||
}) {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const showOpenTab = canOpenInNewTab(src);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
pinchRef.current?.centerView(1, 0);
|
|
||||||
});
|
|
||||||
}, [rotationDeg, pinchRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-full min-h-0 w-full min-w-0 flex-1">
|
|
||||||
{loading && !error ? (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<div className="size-10 animate-pulse rounded-full bg-white/25" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<div className="flex min-h-[min(50vh,320px)] flex-col items-center justify-center gap-4 px-6 py-12 text-center text-sm text-white/90">
|
|
||||||
<p>图片加载失败</p>
|
|
||||||
{showOpenTab ? (
|
|
||||||
<a
|
|
||||||
href={src}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex h-7 items-center justify-center rounded-md bg-white/90 px-2.5 text-xs font-medium text-slate-900"
|
|
||||||
>
|
|
||||||
在新标签页打开
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TransformWrapper
|
|
||||||
ref={pinchRef}
|
|
||||||
initialScale={1}
|
|
||||||
minScale={0.35}
|
|
||||||
maxScale={8}
|
|
||||||
centerOnInit
|
|
||||||
centerZoomedOut
|
|
||||||
limitToBounds
|
|
||||||
wheel={{ step: 0.12 }}
|
|
||||||
pinch={{ step: 5 }}
|
|
||||||
panning={{ velocityDisabled: false }}
|
|
||||||
doubleClick={{ mode: "reset", step: 0.7 }}
|
|
||||||
>
|
|
||||||
<TransformComponent
|
|
||||||
wrapperClass="!h-full !w-full !max-h-full !max-w-full"
|
|
||||||
contentClass="!flex !h-full !min-h-0 !w-full !min-w-0 !items-center !justify-center !p-4 sm:!p-6"
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- 外链与任意尺寸大图预览 */}
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={alt || "预览图片"}
|
|
||||||
draggable={false}
|
|
||||||
style={{ transform: `rotate(${rotationDeg}deg)` }}
|
|
||||||
className={cn(
|
|
||||||
"max-h-[min(85vh,calc(100dvh-3rem))] max-w-full origin-center object-contain transition-transform duration-200 ease-out select-none",
|
|
||||||
loading ? "opacity-0" : "opacity-100",
|
|
||||||
)}
|
|
||||||
onLoad={() => {
|
|
||||||
setLoading(false);
|
|
||||||
setError(false);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
pinchRef.current?.centerView(1, 0);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
setLoading(false);
|
|
||||||
setError(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TransformComponent>
|
|
||||||
</TransformWrapper>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageLightboxDialogContent({
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
onRequestClose,
|
|
||||||
}: {
|
|
||||||
src: string;
|
|
||||||
alt?: string;
|
|
||||||
onRequestClose: () => void;
|
|
||||||
}) {
|
|
||||||
const pinchRef = useRef<ReactZoomPanPinchContentRef | null>(null);
|
|
||||||
const [rotationDeg, setRotationDeg] = useState(0);
|
|
||||||
const showOpenTab = canOpenInNewTab(src);
|
|
||||||
const titleText = alt?.trim() || "图片预览";
|
|
||||||
|
|
||||||
const rotateLeft = useCallback(() => {
|
|
||||||
setRotationDeg((d) => (d - 90 + 360) % 360);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const rotateRight = useCallback(() => {
|
|
||||||
setRotationDeg((d) => (d + 90) % 360);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-full min-h-0 w-full flex-col">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-0 bg-black/85"
|
|
||||||
aria-hidden
|
|
||||||
onClick={onRequestClose}
|
|
||||||
/>
|
|
||||||
<div className="relative z-10 flex h-full min-h-0 flex-col">
|
|
||||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-white/10 bg-black/55 px-2 py-2 text-white sm:gap-3 sm:px-4">
|
|
||||||
<h2
|
|
||||||
id="widget-image-lightbox-title"
|
|
||||||
className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-snug text-white"
|
|
||||||
>
|
|
||||||
{titleText}
|
|
||||||
</h2>
|
|
||||||
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="放大"
|
|
||||||
onClick={() => pinchRef.current?.zoomIn(0.25)}
|
|
||||||
>
|
|
||||||
<ZoomInIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="缩小"
|
|
||||||
onClick={() => pinchRef.current?.zoomOut(0.25)}
|
|
||||||
>
|
|
||||||
<ZoomOutIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="向左旋转"
|
|
||||||
onClick={rotateLeft}
|
|
||||||
>
|
|
||||||
<RotateCcwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="向右旋转"
|
|
||||||
onClick={rotateRight}
|
|
||||||
>
|
|
||||||
<RotateCwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="重置缩放、位置与旋转"
|
|
||||||
onClick={() => {
|
|
||||||
setRotationDeg(0);
|
|
||||||
pinchRef.current?.resetTransform(200);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
{showOpenTab ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="在新标签页打开"
|
|
||||||
onClick={() => {
|
|
||||||
window.open(src, "_blank", "noopener,noreferrer");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="关闭"
|
|
||||||
onClick={onRequestClose}
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<LightboxImageBody
|
|
||||||
pinchRef={pinchRef}
|
|
||||||
rotationDeg={rotationDeg}
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="sr-only">
|
|
||||||
使用滚轮或双指缩放,按住拖拽可平移图片;工具栏可向左或向右旋转。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImageLightboxView({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
}: ImageLightboxProps) {
|
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = dialogRef.current;
|
|
||||||
if (!el) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (open && src) {
|
|
||||||
if (!el.open) {
|
|
||||||
el.showModal();
|
|
||||||
}
|
|
||||||
} else if (el.open) {
|
|
||||||
el.close();
|
|
||||||
}
|
|
||||||
}, [open, src]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<dialog
|
|
||||||
ref={dialogRef}
|
|
||||||
className="cs-agent-image-lightbox fixed inset-0 z-[2147483646] m-0 flex h-full max-h-none w-full max-w-none flex-col border-0 bg-transparent p-0 [&:not([open])]:hidden"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="widget-image-lightbox-title"
|
|
||||||
onClose={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
{src ? (
|
|
||||||
<ImageLightboxDialogContent
|
|
||||||
key={src}
|
|
||||||
alt={alt}
|
|
||||||
src={src}
|
|
||||||
onRequestClose={() => onOpenChange(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImageLightboxProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [state, setState] = useState<ImageLightboxItem | null>(null);
|
|
||||||
|
|
||||||
const open = useCallback((src: string, alt?: string) => {
|
|
||||||
const trimmed = src?.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState({ src: trimmed, alt });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
|
||||||
setState(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const contextValue = useMemo(
|
|
||||||
() => ({
|
|
||||||
open,
|
|
||||||
close,
|
|
||||||
}),
|
|
||||||
[open, close],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ImageLightboxContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
<ImageLightboxView
|
|
||||||
open={state !== null}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
if (!next) {
|
|
||||||
setState(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
src={state?.src ?? null}
|
|
||||||
alt={state?.alt}
|
|
||||||
/>
|
|
||||||
</ImageLightboxContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
||||||
import nextTs from "eslint-config-next/typescript";
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
...nextVitals,
|
|
||||||
...nextTs,
|
|
||||||
globalIgnores([".next/**", "out/**", "build/**"]),
|
|
||||||
]);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { requestJson } from "@/lib/services/http";
|
|
||||||
import type { JsonResult, WidgetConversation } from "@/lib/services/types";
|
|
||||||
|
|
||||||
/** 身份由请求头 X-External-Source / X-External-Id(及可选 X-External-Name)提供,与 GetExternalInfo 一致 */
|
|
||||||
export async function createOrMatchConversation() {
|
|
||||||
const result = await requestJson<JsonResult<WidgetConversation>>(
|
|
||||||
"/api/open/im/conversation/create_or_match",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Error(result.message || "conversation init failed");
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeConversation(conversationId: number) {
|
|
||||||
const result = await requestJson<JsonResult<null>>(
|
|
||||||
"/api/open/im/conversation/close",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ conversationId }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (result.success === false) {
|
|
||||||
throw new Error(result.message || "conversation close failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { readWidgetConfig } from "@/lib/widget/config";
|
|
||||||
import { getOrCreateExternalId } from "@/lib/widget/visitor";
|
|
||||||
|
|
||||||
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const config = readWidgetConfig();
|
|
||||||
const baseUrl = (config.apiBaseUrl || config.baseUrl).replace(/\/$/, "");
|
|
||||||
const visitorId = getOrCreateExternalId();
|
|
||||||
const externalSource = (config.externalSource ?? "web_chat").trim() || "web_chat";
|
|
||||||
const headers = new Headers(init?.headers ?? {});
|
|
||||||
if (
|
|
||||||
!headers.has("Content-Type") &&
|
|
||||||
init?.body &&
|
|
||||||
!(typeof FormData !== "undefined" && init.body instanceof FormData)
|
|
||||||
) {
|
|
||||||
headers.set("Content-Type", "application/json");
|
|
||||||
}
|
|
||||||
headers.set("X-External-Source", externalSource);
|
|
||||||
headers.set("X-External-Id", visitorId);
|
|
||||||
const externalName = (config.subject ?? "").trim();
|
|
||||||
if (externalName) {
|
|
||||||
headers.set("X-External-Name", encodeURIComponent(externalName));
|
|
||||||
}
|
|
||||||
headers.set("X-Channel-Id", config.channelId);
|
|
||||||
const response = await fetch(`${baseUrl}${path}`, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import MarkdownIt from "markdown-it";
|
|
||||||
|
|
||||||
export type WidgetMessageAssetPayload = {
|
|
||||||
assetId: string;
|
|
||||||
filename?: string;
|
|
||||||
fileSize?: number;
|
|
||||||
mimeType?: string;
|
|
||||||
url?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageMarkdown = new MarkdownIt({
|
|
||||||
html: false,
|
|
||||||
linkify: true,
|
|
||||||
breaks: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function parseMessageAssetPayload(
|
|
||||||
payload?: string,
|
|
||||||
): WidgetMessageAssetPayload | null {
|
|
||||||
if (!payload?.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payload) as WidgetMessageAssetPayload;
|
|
||||||
if (!parsed?.assetId?.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderMessageHTML(message: {
|
|
||||||
messageType: string;
|
|
||||||
content: string;
|
|
||||||
payload?: string;
|
|
||||||
}) {
|
|
||||||
if (message.messageType === "html") {
|
|
||||||
return message.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = parseMessageAssetPayload(message.payload);
|
|
||||||
if (message.messageType === "image") {
|
|
||||||
if (asset?.url) {
|
|
||||||
return `<p><img src="${escapeHTMLAttr(asset.url)}" alt="${escapeHTMLAttr(
|
|
||||||
asset.filename || "image",
|
|
||||||
)}"></p>`;
|
|
||||||
}
|
|
||||||
return "<p>[图片]</p>";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.messageType === "attachment") {
|
|
||||||
if (asset?.url) {
|
|
||||||
const title = escapeHTML(asset.filename || message.content || "附件");
|
|
||||||
const meta = formatFileSize(asset.fileSize ?? 0);
|
|
||||||
const metaHTML = meta
|
|
||||||
? `<div class="im-attachment-meta">${escapeHTML(meta)}</div>`
|
|
||||||
: "";
|
|
||||||
return `<div class="im-attachment"><a href="${escapeHTMLAttr(
|
|
||||||
asset.url,
|
|
||||||
)}" target="_blank" rel="noreferrer" download="${escapeHTMLAttr(
|
|
||||||
asset.filename || "",
|
|
||||||
)}" class="im-attachment-link"><span class="im-attachment-icon" aria-hidden="true">${getAttachmentIconSVG()}</span><span class="im-attachment-content"><span class="im-attachment-title">${title}</span>${metaHTML}</span></a></div>`;
|
|
||||||
}
|
|
||||||
return `<p>${escapeHTML(message.content || "[附件]")}</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderTextMessageHTML(message.content || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeMessage(message: {
|
|
||||||
messageType: string;
|
|
||||||
content: string;
|
|
||||||
payload?: string;
|
|
||||||
}) {
|
|
||||||
if (message.messageType === "image") {
|
|
||||||
return "[图片]";
|
|
||||||
}
|
|
||||||
if (message.messageType === "attachment") {
|
|
||||||
const asset = parseMessageAssetPayload(message.payload);
|
|
||||||
return asset?.filename?.trim() ? `[附件] ${asset.filename.trim()}` : "[附件]";
|
|
||||||
}
|
|
||||||
if (message.messageType === "html") {
|
|
||||||
const text = extractTextFromHTML(message.content);
|
|
||||||
if (text.trim()) {
|
|
||||||
return text.substring(0, 100);
|
|
||||||
}
|
|
||||||
if (message.content.includes("<img")) {
|
|
||||||
return "[图片]";
|
|
||||||
}
|
|
||||||
return "[消息]";
|
|
||||||
}
|
|
||||||
return message.content?.substring(0, 100) || "[消息]";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(size: number) {
|
|
||||||
if (!Number.isFinite(size) || size <= 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
|
||||||
let value = size;
|
|
||||||
let index = 0;
|
|
||||||
while (value >= 1024 && index < units.length - 1) {
|
|
||||||
value /= 1024;
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
const digits = value >= 10 || index === 0 ? 0 : 1;
|
|
||||||
return `${value.toFixed(digits)} ${units[index]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextFromHTML(html: string): string {
|
|
||||||
if (typeof document === "undefined") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.innerHTML = html;
|
|
||||||
return div.textContent || div.innerText || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTextMessageHTML(content: string) {
|
|
||||||
const value = content.trim();
|
|
||||||
if (!value) {
|
|
||||||
return "<p></p>";
|
|
||||||
}
|
|
||||||
return messageMarkdown.render(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHTML(value: string) {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'")
|
|
||||||
.replaceAll("\n", "<br>");
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHTMLAttr(value: string) {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAttachmentIconSVG() {
|
|
||||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6"></path><path d="M9 15h6"></path><path d="M9 11h2"></path></svg>`;
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { requestJson } from "@/lib/services/http";
|
|
||||||
import type {
|
|
||||||
CursorResult,
|
|
||||||
JsonResult,
|
|
||||||
WidgetAsset,
|
|
||||||
WidgetMessage,
|
|
||||||
} from "@/lib/services/types";
|
|
||||||
import { generateUUID } from "@/lib/utils";
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_LIMIT = 50;
|
|
||||||
|
|
||||||
function buildListQuery(
|
|
||||||
conversationId: number,
|
|
||||||
options?: { cursor?: number; limit?: number },
|
|
||||||
) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
conversationId: String(conversationId),
|
|
||||||
});
|
|
||||||
const cursor = options?.cursor;
|
|
||||||
if (cursor !== undefined && cursor > 0) {
|
|
||||||
params.set("cursor", String(cursor));
|
|
||||||
}
|
|
||||||
const limit = options?.limit ?? DEFAULT_PAGE_LIMIT;
|
|
||||||
if (limit > 0) {
|
|
||||||
params.set("limit", String(limit));
|
|
||||||
}
|
|
||||||
return params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMessagesPage(
|
|
||||||
conversationId: number,
|
|
||||||
options?: { cursor?: number; limit?: number },
|
|
||||||
): Promise<CursorResult<WidgetMessage>> {
|
|
||||||
const qs = buildListQuery(conversationId, options);
|
|
||||||
const result = await requestJson<
|
|
||||||
JsonResult<CursorResult<WidgetMessage>>
|
|
||||||
>(`/api/open/im/message/list?${qs}`);
|
|
||||||
if (result.success === false) {
|
|
||||||
throw new Error(result.message || "加载消息失败");
|
|
||||||
}
|
|
||||||
const data = result.data;
|
|
||||||
return {
|
|
||||||
results: data?.results ?? [],
|
|
||||||
cursor: data?.cursor ?? "",
|
|
||||||
hasMore: Boolean(data?.hasMore),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated 使用 fetchMessagesPage;保留别名供渐进迁移 */
|
|
||||||
export async function fetchMessages(conversationId: number) {
|
|
||||||
const page = await fetchMessagesPage(conversationId);
|
|
||||||
return page.results;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMessageWithPayload(
|
|
||||||
conversationId: number,
|
|
||||||
payload: {
|
|
||||||
messageType: string;
|
|
||||||
content: string;
|
|
||||||
payload?: string;
|
|
||||||
clientMsgId?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const result = await requestJson<JsonResult<WidgetMessage>>("/api/open/im/message/send", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
conversationId,
|
|
||||||
clientMsgId: payload.clientMsgId || `client_${generateUUID()}`,
|
|
||||||
messageType: payload.messageType,
|
|
||||||
content: payload.content,
|
|
||||||
payload: payload.payload || "",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Error(result.message || "send message failed");
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMessage(conversationId: number, content: string) {
|
|
||||||
return sendMessageWithPayload(conversationId, {
|
|
||||||
messageType: "html",
|
|
||||||
content,
|
|
||||||
payload: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markMessageRead(conversationId: number, messageId = 0) {
|
|
||||||
await requestJson<JsonResult<void>>("/api/open/im/message/read", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ conversationId, messageId }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadImage(conversationId: number, file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set("conversationId", String(conversationId));
|
|
||||||
formData.set("file", file);
|
|
||||||
const result = await requestJson<JsonResult<WidgetAsset>>(
|
|
||||||
"/api/open/im/message/upload_image",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Error(result.message || "upload image failed");
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadAttachment(conversationId: number, file: File) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set("conversationId", String(conversationId));
|
|
||||||
formData.set("file", file);
|
|
||||||
const result = await requestJson<JsonResult<WidgetAsset>>(
|
|
||||||
"/api/open/im/message/upload_attachment",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Error(result.message || "upload attachment failed");
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { WidgetMessage } from "./types";
|
|
||||||
import { summarizeMessage } from "./message-asset";
|
|
||||||
|
|
||||||
export function getNotificationBody(message: WidgetMessage): string {
|
|
||||||
return summarizeMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showNotification(title: string, body: string, onClick?: () => void) {
|
|
||||||
if (typeof Notification === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Notification.permission === "granted") {
|
|
||||||
const notification = new Notification(title, {
|
|
||||||
body,
|
|
||||||
icon: "/favicon.ico",
|
|
||||||
badge: "/favicon.ico",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onClick) {
|
|
||||||
notification.onclick = () => {
|
|
||||||
onClick();
|
|
||||||
notification.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.close();
|
|
||||||
}, 5000);
|
|
||||||
} else if (Notification.permission === "default") {
|
|
||||||
Notification.requestPermission().then((permission) => {
|
|
||||||
if (permission === "granted") {
|
|
||||||
showNotification(title, body, onClick);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { readWidgetConfig } from "@/lib/widget/config";
|
|
||||||
import { getOrCreateExternalId } from "@/lib/widget/visitor";
|
|
||||||
|
|
||||||
export type RealtimeEnvelope = {
|
|
||||||
type: string;
|
|
||||||
topic?: string;
|
|
||||||
data?: {
|
|
||||||
conversationId?: number;
|
|
||||||
messageId?: number;
|
|
||||||
};
|
|
||||||
payload?: {
|
|
||||||
conversationId?: number;
|
|
||||||
messageId?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createRealtimeConnection(onEvent: (event: RealtimeEnvelope) => void) {
|
|
||||||
const config = readWidgetConfig();
|
|
||||||
const baseUrl = (config.apiBaseUrl || config.baseUrl)
|
|
||||||
.replace(/^http/, "ws")
|
|
||||||
.replace(/\/$/, "");
|
|
||||||
const externalId = encodeURIComponent(getOrCreateExternalId());
|
|
||||||
const externalSource = encodeURIComponent(
|
|
||||||
(config.externalSource ?? "web_chat").trim() || "web_chat",
|
|
||||||
);
|
|
||||||
const externalName = (config.subject ?? "").trim();
|
|
||||||
const nameQuery =
|
|
||||||
externalName !== ""
|
|
||||||
? `&externalName=${encodeURIComponent(externalName)}`
|
|
||||||
: "";
|
|
||||||
const socket = new WebSocket(
|
|
||||||
`${baseUrl}/api/open/im/ws?externalId=${externalId}&externalSource=${externalSource}&channelId=${encodeURIComponent(config.channelId)}${nameQuery}`,
|
|
||||||
);
|
|
||||||
socket.addEventListener("message", (event) => {
|
|
||||||
try {
|
|
||||||
onEvent(JSON.parse(event.data) as RealtimeEnvelope);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return socket;
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
export type PageResult<T> = {
|
|
||||||
results: T[];
|
|
||||||
page?: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CursorResult<T> = {
|
|
||||||
results: T[];
|
|
||||||
cursor: string;
|
|
||||||
hasMore: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JsonResult<T> = {
|
|
||||||
success?: boolean;
|
|
||||||
errorCode?: number;
|
|
||||||
code?: number;
|
|
||||||
message?: string;
|
|
||||||
data?: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WidgetConversation = {
|
|
||||||
id: number;
|
|
||||||
subject?: string;
|
|
||||||
status?: number;
|
|
||||||
serviceMode?: number;
|
|
||||||
currentAssigneeId?: number;
|
|
||||||
lastMessageAt?: string;
|
|
||||||
lastMessageSummary?: string;
|
|
||||||
customerUnreadCount?: number;
|
|
||||||
agentUnreadCount?: number;
|
|
||||||
customerLastReadMessageId?: number;
|
|
||||||
customerLastReadSeqNo?: number;
|
|
||||||
customerLastReadAt?: string;
|
|
||||||
agentLastReadMessageId?: number;
|
|
||||||
agentLastReadSeqNo?: number;
|
|
||||||
agentLastReadAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WidgetMessage = {
|
|
||||||
id: number;
|
|
||||||
conversationId: number;
|
|
||||||
senderType: string;
|
|
||||||
senderName?: string;
|
|
||||||
senderAvatar?: string;
|
|
||||||
messageType: string;
|
|
||||||
content: string;
|
|
||||||
payload?: string;
|
|
||||||
seqNo?: number;
|
|
||||||
sentAt?: string;
|
|
||||||
customerRead?: boolean;
|
|
||||||
customerReadAt?: string;
|
|
||||||
agentRead?: boolean;
|
|
||||||
agentReadAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WidgetAsset = {
|
|
||||||
id: number;
|
|
||||||
assetId: string;
|
|
||||||
provider: string;
|
|
||||||
storageKey: string;
|
|
||||||
filename: string;
|
|
||||||
fileSize: number;
|
|
||||||
mimeType: string;
|
|
||||||
status: number;
|
|
||||||
url: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
createUserId: number;
|
|
||||||
createUserName: string;
|
|
||||||
updateUserId: number;
|
|
||||||
updateUserName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WidgetConfigResponse = {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
welcomeText?: string;
|
|
||||||
themeColor?: string;
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { requestJson } from "@/lib/services/http";
|
|
||||||
import type { JsonResult, WidgetConfigResponse } from "@/lib/services/types";
|
|
||||||
import { readWidgetConfig } from "@/lib/widget/config";
|
|
||||||
|
|
||||||
export async function fetchWidgetConfig() {
|
|
||||||
const config = readWidgetConfig();
|
|
||||||
const result = await requestJson<JsonResult<WidgetConfigResponse>>(
|
|
||||||
`/api/open/im/widget/config?channelId=${encodeURIComponent(config.channelId)}`,
|
|
||||||
);
|
|
||||||
return result.data ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { readWidgetConfig };
|
|
||||||
@@ -1,568 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
import { createOrMatchConversation } from "@/lib/services/conversation";
|
|
||||||
import {
|
|
||||||
fetchMessagesPage,
|
|
||||||
markMessageRead,
|
|
||||||
sendMessage,
|
|
||||||
sendMessageWithPayload,
|
|
||||||
uploadAttachment,
|
|
||||||
uploadImage,
|
|
||||||
} from "@/lib/services/message";
|
|
||||||
import {
|
|
||||||
getNotificationBody,
|
|
||||||
showNotification,
|
|
||||||
} from "@/lib/services/notification";
|
|
||||||
import { summarizeMessage } from "@/lib/services/message-asset";
|
|
||||||
import {
|
|
||||||
createRealtimeConnection,
|
|
||||||
type RealtimeEnvelope,
|
|
||||||
} from "@/lib/services/realtime";
|
|
||||||
import type {
|
|
||||||
WidgetAsset,
|
|
||||||
WidgetConfigResponse,
|
|
||||||
WidgetConversation,
|
|
||||||
WidgetMessage,
|
|
||||||
} from "@/lib/services/types";
|
|
||||||
import {
|
|
||||||
fetchWidgetConfig,
|
|
||||||
readWidgetConfig,
|
|
||||||
} from "@/lib/services/widget-config";
|
|
||||||
import { generateUUID } from "@/lib/utils";
|
|
||||||
|
|
||||||
type ChatStatus = "connecting" | "connected" | "disconnected";
|
|
||||||
|
|
||||||
const RECONNECT_BASE_DELAY = 2000;
|
|
||||||
const RECONNECT_MAX_DELAY = 30000;
|
|
||||||
|
|
||||||
function mergeMessagesByIdAsc(
|
|
||||||
a: WidgetMessage[],
|
|
||||||
b: WidgetMessage[],
|
|
||||||
): WidgetMessage[] {
|
|
||||||
const byId = new Map<number, WidgetMessage>();
|
|
||||||
for (const m of a) {
|
|
||||||
byId.set(m.id, m);
|
|
||||||
}
|
|
||||||
for (const m of b) {
|
|
||||||
byId.set(m.id, m);
|
|
||||||
}
|
|
||||||
return Array.from(byId.values()).sort((x, y) => x.id - y.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCursorId(cursor: string): number {
|
|
||||||
const n = Number.parseInt(cursor, 10);
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cursorFromLoadedMessages(messages: WidgetMessage[]): string {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return String(Math.min(...messages.map((m) => m.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function minWidgetMessageId(messages: WidgetMessage[]): number | null {
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.min(...messages.map((m) => m.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasMoreAfterLatestSyncMerge(args: {
|
|
||||||
previousMessages: WidgetMessage[];
|
|
||||||
previousHasMore: boolean;
|
|
||||||
merged: WidgetMessage[];
|
|
||||||
apiHasMore: boolean;
|
|
||||||
}): boolean {
|
|
||||||
const prevMin = minWidgetMessageId(args.previousMessages);
|
|
||||||
const mergedMin = minWidgetMessageId(args.merged);
|
|
||||||
|
|
||||||
if (mergedMin === null) {
|
|
||||||
return Boolean(args.apiHasMore);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.previousHasMore && prevMin !== null && mergedMin >= prevMin) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return args.previousHasMore || Boolean(args.apiHasMore);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatStore {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
welcomeText: string;
|
|
||||||
themeColor: string;
|
|
||||||
conversation: WidgetConversation | null;
|
|
||||||
messages: WidgetMessage[];
|
|
||||||
messagesCursor: string;
|
|
||||||
messagesHasMore: boolean;
|
|
||||||
messagesLoadingMore: boolean;
|
|
||||||
status: ChatStatus;
|
|
||||||
error: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
isVisible: boolean;
|
|
||||||
initialized: boolean;
|
|
||||||
socket: WebSocket | null;
|
|
||||||
readingMessageId: number;
|
|
||||||
|
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
setIsVisible: (isVisible: boolean) => void;
|
|
||||||
bootstrap: () => void;
|
|
||||||
handleSendMessage: (html: string) => Promise<void>;
|
|
||||||
uploadMessageImage: (file: File) => Promise<WidgetAsset | null>;
|
|
||||||
sendAttachment: (file: File) => Promise<void>;
|
|
||||||
retry: () => void;
|
|
||||||
disconnectSocket: () => void;
|
|
||||||
refreshMessages: () => Promise<void>;
|
|
||||||
syncLatestMessages: () => Promise<void>;
|
|
||||||
loadOlderMessages: () => Promise<void>;
|
|
||||||
markConversationRead: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let bootstrapToken = 0;
|
|
||||||
|
|
||||||
export const useChatStore = create<ChatStore>((set, get) => {
|
|
||||||
let reconnectTimer: number | null = null;
|
|
||||||
let pingTimer: number | null = null;
|
|
||||||
let reconnectAttempt = 0;
|
|
||||||
let shouldReconnect = false;
|
|
||||||
|
|
||||||
const clearRealtimeTimers = () => {
|
|
||||||
if (reconnectTimer !== null) {
|
|
||||||
window.clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (pingTimer !== null) {
|
|
||||||
window.clearInterval(pingTimer);
|
|
||||||
pingTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (!shouldReconnect || reconnectTimer !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(
|
|
||||||
RECONNECT_BASE_DELAY * 2 ** reconnectAttempt,
|
|
||||||
RECONNECT_MAX_DELAY,
|
|
||||||
);
|
|
||||||
set({ status: "connecting" });
|
|
||||||
reconnectTimer = window.setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
reconnectAttempt += 1;
|
|
||||||
if (!shouldReconnect || !get().isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
connectSocket();
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeSocket = (options?: { reconnect?: boolean }) => {
|
|
||||||
shouldReconnect = options?.reconnect ?? false;
|
|
||||||
clearRealtimeTimers();
|
|
||||||
if (!shouldReconnect) {
|
|
||||||
reconnectAttempt = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const socket = get().socket;
|
|
||||||
if (
|
|
||||||
socket &&
|
|
||||||
(socket.readyState === WebSocket.OPEN ||
|
|
||||||
socket.readyState === WebSocket.CONNECTING)
|
|
||||||
) {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ socket: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectSocket = () => {
|
|
||||||
const conversationId = get().conversation?.id;
|
|
||||||
if (!conversationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeSocket({ reconnect: false });
|
|
||||||
shouldReconnect = true;
|
|
||||||
|
|
||||||
const handleRealtimeEvent = (event: RealtimeEnvelope) => {
|
|
||||||
const payload = event.data ?? event.payload;
|
|
||||||
const needsRefresh =
|
|
||||||
event.type === "message.created" ||
|
|
||||||
event.type?.startsWith("conversation.");
|
|
||||||
|
|
||||||
if (needsRefresh && payload?.conversationId === conversationId) {
|
|
||||||
void get()
|
|
||||||
.syncLatestMessages()
|
|
||||||
.then(() => {
|
|
||||||
if (event.type === "message.created") {
|
|
||||||
const state = get();
|
|
||||||
const lastMessage = state.messages.at(-1);
|
|
||||||
if (
|
|
||||||
lastMessage &&
|
|
||||||
lastMessage.senderType !== "customer" &&
|
|
||||||
typeof document !== "undefined" &&
|
|
||||||
document.visibilityState !== "visible"
|
|
||||||
) {
|
|
||||||
showNotification(
|
|
||||||
"新消息",
|
|
||||||
getNotificationBody(lastMessage),
|
|
||||||
() => {
|
|
||||||
state.setIsOpen(true);
|
|
||||||
state.setIsVisible(true);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const socket = createRealtimeConnection(handleRealtimeEvent);
|
|
||||||
set({ socket });
|
|
||||||
|
|
||||||
socket.addEventListener("open", () => {
|
|
||||||
clearRealtimeTimers();
|
|
||||||
reconnectAttempt = 0;
|
|
||||||
pingTimer = window.setInterval(() => {
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: "ping" }));
|
|
||||||
}
|
|
||||||
}, 20000);
|
|
||||||
if (get().isOpen && get().socket === socket) {
|
|
||||||
set({ status: "connected" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener("error", () => {
|
|
||||||
if (get().socket === socket) {
|
|
||||||
scheduleReconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener("close", () => {
|
|
||||||
if (pingTimer !== null) {
|
|
||||||
window.clearInterval(pingTimer);
|
|
||||||
pingTimer = null;
|
|
||||||
}
|
|
||||||
if (get().socket === socket) {
|
|
||||||
set({ socket: null });
|
|
||||||
}
|
|
||||||
if (get().isOpen) {
|
|
||||||
if (shouldReconnect) {
|
|
||||||
scheduleReconnect();
|
|
||||||
} else {
|
|
||||||
set({ status: "disconnected" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: "在线客服",
|
|
||||||
subtitle: "",
|
|
||||||
welcomeText: "",
|
|
||||||
themeColor: "#2563eb",
|
|
||||||
conversation: null,
|
|
||||||
messages: [],
|
|
||||||
messagesCursor: "",
|
|
||||||
messagesHasMore: false,
|
|
||||||
messagesLoadingMore: false,
|
|
||||||
status: "connecting",
|
|
||||||
error: "",
|
|
||||||
isOpen: typeof window !== "undefined" ? window.self === window.top : false,
|
|
||||||
isVisible:
|
|
||||||
typeof window !== "undefined" ? window.self === window.top : false,
|
|
||||||
initialized: false,
|
|
||||||
socket: null,
|
|
||||||
readingMessageId: 0,
|
|
||||||
|
|
||||||
setIsOpen: (isOpen: boolean) => {
|
|
||||||
set({ isOpen });
|
|
||||||
},
|
|
||||||
|
|
||||||
setIsVisible: (isVisible: boolean) => {
|
|
||||||
set({ isVisible });
|
|
||||||
},
|
|
||||||
|
|
||||||
disconnectSocket: () => {
|
|
||||||
closeSocket({ reconnect: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshMessages: async () => {
|
|
||||||
const conversationId = get().conversation?.id;
|
|
||||||
if (!conversationId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page = await fetchMessagesPage(conversationId);
|
|
||||||
const currentConversation = get().conversation;
|
|
||||||
set({
|
|
||||||
messages: page.results,
|
|
||||||
messagesCursor: cursorFromLoadedMessages(page.results) || page.cursor,
|
|
||||||
messagesHasMore: page.hasMore,
|
|
||||||
conversation: currentConversation,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to refresh messages", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
syncLatestMessages: async () => {
|
|
||||||
const conversationId = get().conversation?.id;
|
|
||||||
if (!conversationId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page = await fetchMessagesPage(conversationId);
|
|
||||||
const batch = page.results;
|
|
||||||
if (batch.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstId = batch[0]!.id;
|
|
||||||
const currentConversation = get().conversation;
|
|
||||||
set((state) => {
|
|
||||||
const preserved = state.messages.filter((m) => m.id < firstId);
|
|
||||||
const merged = mergeMessagesByIdAsc(preserved, batch);
|
|
||||||
return {
|
|
||||||
messages: merged,
|
|
||||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor,
|
|
||||||
messagesHasMore: hasMoreAfterLatestSyncMerge({
|
|
||||||
previousMessages: state.messages,
|
|
||||||
previousHasMore: state.messagesHasMore,
|
|
||||||
merged,
|
|
||||||
apiHasMore: Boolean(page.hasMore),
|
|
||||||
}),
|
|
||||||
conversation: currentConversation,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to sync messages", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
loadOlderMessages: async () => {
|
|
||||||
const conversationId = get().conversation?.id;
|
|
||||||
if (
|
|
||||||
!conversationId ||
|
|
||||||
get().messagesLoadingMore ||
|
|
||||||
!get().messagesHasMore
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cursorId = parseCursorId(get().messagesCursor);
|
|
||||||
if (cursorId <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ messagesLoadingMore: true });
|
|
||||||
try {
|
|
||||||
const page = await fetchMessagesPage(conversationId, {
|
|
||||||
cursor: cursorId,
|
|
||||||
});
|
|
||||||
const currentConversation = get().conversation;
|
|
||||||
set((state) => {
|
|
||||||
const merged = mergeMessagesByIdAsc(page.results, state.messages);
|
|
||||||
return {
|
|
||||||
messages: merged,
|
|
||||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor,
|
|
||||||
messagesHasMore: page.hasMore,
|
|
||||||
messagesLoadingMore: false,
|
|
||||||
conversation: currentConversation,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
set({ messagesLoadingMore: false });
|
|
||||||
console.error("Failed to load older messages", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
markConversationRead: async () => {
|
|
||||||
const state = get();
|
|
||||||
const conversation = state.conversation;
|
|
||||||
const lastMessage = state.messages.at(-1);
|
|
||||||
if (!conversation?.id || !lastMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(conversation.customerUnreadCount ?? 0) <= 0 &&
|
|
||||||
(conversation.customerLastReadMessageId ?? 0) >= lastMessage.id
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.readingMessageId === lastMessage.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ readingMessageId: lastMessage.id });
|
|
||||||
try {
|
|
||||||
await markMessageRead(conversation.id, lastMessage.id);
|
|
||||||
set((current) => ({
|
|
||||||
readingMessageId: 0,
|
|
||||||
messages: current.messages.map((item) =>
|
|
||||||
(item.seqNo ?? 0) <= (lastMessage.seqNo ?? 0)
|
|
||||||
? { ...item, customerRead: true }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
conversation: current.conversation
|
|
||||||
? {
|
|
||||||
...current.conversation,
|
|
||||||
customerUnreadCount: 0,
|
|
||||||
customerLastReadMessageId: lastMessage.id,
|
|
||||||
customerLastReadSeqNo: lastMessage.seqNo,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
set({ readingMessageId: 0 });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
bootstrap: () => {
|
|
||||||
const token = ++bootstrapToken;
|
|
||||||
|
|
||||||
if (!get().isOpen) {
|
|
||||||
closeSocket({ reconnect: false });
|
|
||||||
set({ status: "disconnected" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activateChat = async () => {
|
|
||||||
try {
|
|
||||||
set({ error: "", status: "connecting" });
|
|
||||||
|
|
||||||
const hostConfig = readWidgetConfig();
|
|
||||||
const widgetConfig: WidgetConfigResponse =
|
|
||||||
await fetchWidgetConfig().catch(() => ({}));
|
|
||||||
if (bootstrapToken !== token || !get().isOpen) return;
|
|
||||||
|
|
||||||
set({
|
|
||||||
title: hostConfig.title || widgetConfig.title || "在线客服",
|
|
||||||
subtitle: hostConfig.subtitle || widgetConfig.subtitle || "",
|
|
||||||
welcomeText: widgetConfig.welcomeText || "",
|
|
||||||
themeColor:
|
|
||||||
hostConfig.themeColor || widgetConfig.themeColor || "#2563eb",
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentConversation = get().conversation;
|
|
||||||
if (!get().initialized || !currentConversation) {
|
|
||||||
currentConversation = await createOrMatchConversation();
|
|
||||||
if (bootstrapToken !== token || !get().isOpen) return;
|
|
||||||
set({ initialized: true, conversation: currentConversation });
|
|
||||||
}
|
|
||||||
|
|
||||||
await get().refreshMessages();
|
|
||||||
if (bootstrapToken !== token || !get().isOpen) return;
|
|
||||||
|
|
||||||
connectSocket();
|
|
||||||
} catch (bootstrapError) {
|
|
||||||
if (bootstrapToken !== token || !get().isOpen) return;
|
|
||||||
set({
|
|
||||||
status: "disconnected",
|
|
||||||
error:
|
|
||||||
bootstrapError instanceof Error
|
|
||||||
? bootstrapError.message
|
|
||||||
: "初始化失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void activateChat();
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSendMessage: async (content: string) => {
|
|
||||||
const conversationId = get().conversation?.id;
|
|
||||||
if (!conversationId) return;
|
|
||||||
set({ error: "" });
|
|
||||||
try {
|
|
||||||
const nextMessage = await sendMessage(conversationId, content);
|
|
||||||
set((state) => ({
|
|
||||||
messages: state.messages.some((m) => m.id === nextMessage.id)
|
|
||||||
? state.messages.map((m) =>
|
|
||||||
m.id === nextMessage.id ? nextMessage : m,
|
|
||||||
)
|
|
||||||
: [...state.messages, nextMessage],
|
|
||||||
conversation: state.conversation
|
|
||||||
? {
|
|
||||||
...state.conversation,
|
|
||||||
customerLastReadMessageId: nextMessage.id,
|
|
||||||
customerLastReadSeqNo: nextMessage.seqNo,
|
|
||||||
customerUnreadCount: 0,
|
|
||||||
lastMessageAt: nextMessage.sentAt,
|
|
||||||
lastMessageSummary: summarizeMessage(nextMessage),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
set({ error: e instanceof Error ? e.message : "发送消息失败" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadMessageImage: async (file: File) => {
|
|
||||||
const conversationId = get().conversation?.id;
|
|
||||||
if (!conversationId) return null;
|
|
||||||
set({ error: "" });
|
|
||||||
try {
|
|
||||||
const asset = await uploadImage(conversationId, file);
|
|
||||||
return asset;
|
|
||||||
} catch (e) {
|
|
||||||
set({ error: e instanceof Error ? e.message : "发送图片失败" });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
sendAttachment: async (file: File) => {
|
|
||||||
const conversationId = get().conversation?.id;
|
|
||||||
if (!conversationId) return;
|
|
||||||
set({ error: "" });
|
|
||||||
try {
|
|
||||||
const asset = await uploadAttachment(conversationId, file);
|
|
||||||
const nextMessage = await sendMessageWithPayload(conversationId, {
|
|
||||||
messageType: "attachment",
|
|
||||||
content: asset.filename,
|
|
||||||
payload: JSON.stringify({ assetId: asset.assetId }),
|
|
||||||
clientMsgId: `widget_attachment_${generateUUID()}`,
|
|
||||||
});
|
|
||||||
set((state) => ({
|
|
||||||
messages: state.messages.some((m) => m.id === nextMessage.id)
|
|
||||||
? state.messages.map((m) =>
|
|
||||||
m.id === nextMessage.id ? nextMessage : m,
|
|
||||||
)
|
|
||||||
: [...state.messages, nextMessage],
|
|
||||||
conversation: state.conversation
|
|
||||||
? {
|
|
||||||
...state.conversation,
|
|
||||||
customerLastReadMessageId: nextMessage.id,
|
|
||||||
customerLastReadSeqNo: nextMessage.seqNo,
|
|
||||||
customerUnreadCount: 0,
|
|
||||||
lastMessageAt: nextMessage.sentAt,
|
|
||||||
lastMessageSummary: summarizeMessage(nextMessage),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
set({ error: e instanceof Error ? e.message : "发送附件失败" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
retry: async () => {
|
|
||||||
if (!get().conversation?.id) return;
|
|
||||||
|
|
||||||
set({ error: "", status: "connecting" });
|
|
||||||
try {
|
|
||||||
await get().refreshMessages();
|
|
||||||
if (get().isOpen) {
|
|
||||||
shouldReconnect = true;
|
|
||||||
connectSocket();
|
|
||||||
}
|
|
||||||
} catch (retryError) {
|
|
||||||
set({
|
|
||||||
status: "disconnected",
|
|
||||||
error: retryError instanceof Error ? retryError.message : "刷新失败",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateUUID() {
|
|
||||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
||||||
return globalThis.crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = new Uint8Array(16);
|
|
||||||
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
||||||
globalThis.crypto.getRandomValues(bytes);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < bytes.length; i += 1) {
|
|
||||||
bytes[i] = Math.floor(Math.random() * 256);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
||||||
|
|
||||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
|
||||||
return [
|
|
||||||
hex.slice(0, 4).join(""),
|
|
||||||
hex.slice(4, 6).join(""),
|
|
||||||
hex.slice(6, 8).join(""),
|
|
||||||
hex.slice(8, 10).join(""),
|
|
||||||
hex.slice(10, 16).join(""),
|
|
||||||
].join("-");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(value?: string | null) {
|
|
||||||
if (!value) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const pad = (num: number) => String(num).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
export type WidgetHostConfig = {
|
|
||||||
channelId: string;
|
|
||||||
baseUrl: string;
|
|
||||||
apiBaseUrl?: string;
|
|
||||||
/** 与后端 enums.ExternalSource 一致,默认 web_chat */
|
|
||||||
externalSource?: string;
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
position?: "left" | "right";
|
|
||||||
themeColor?: string;
|
|
||||||
width?: string;
|
|
||||||
/** 访客展示名,随请求以 X-External-Name / WS query externalName 传给后端作 ExternalName */
|
|
||||||
subject?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
CSAgentConfig?: WidgetHostConfig;
|
|
||||||
__CS_AGENT_WIDGET_CONFIG__?: WidgetHostConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readWidgetConfig(): WidgetHostConfig {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return {
|
|
||||||
channelId: "",
|
|
||||||
baseUrl: "",
|
|
||||||
apiBaseUrl: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const query = new URLSearchParams(window.location.search);
|
|
||||||
const fallback = {
|
|
||||||
channelId: query.get("channelId") ?? "",
|
|
||||||
baseUrl: query.get("baseUrl") ?? "",
|
|
||||||
apiBaseUrl: query.get("apiBaseUrl") ?? undefined,
|
|
||||||
externalSource: query.get("externalSource") ?? undefined,
|
|
||||||
title: query.get("title") ?? undefined,
|
|
||||||
subtitle: query.get("subtitle") ?? undefined,
|
|
||||||
position: (query.get("position") as "left" | "right" | null) ?? undefined,
|
|
||||||
themeColor: query.get("themeColor") ?? undefined,
|
|
||||||
width: query.get("width") ?? undefined,
|
|
||||||
subject: query.get("subject") ?? undefined,
|
|
||||||
};
|
|
||||||
return window.__CS_AGENT_WIDGET_CONFIG__ ?? window.CSAgentConfig ?? fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setWidgetConfig(config: WidgetHostConfig) {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.__CS_AGENT_WIDGET_CONFIG__ = config;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { setWidgetConfig, type WidgetHostConfig } from "@/lib/widget/config";
|
|
||||||
|
|
||||||
const INIT_MESSAGE_TYPE = "cs-agent:init";
|
|
||||||
const OPEN_MESSAGE_TYPE = "cs-agent:open";
|
|
||||||
const MINIMIZE_MESSAGE_TYPE = "cs-agent:minimize";
|
|
||||||
const MAXIMIZED_MESSAGE_TYPE = "cs-agent:maximized";
|
|
||||||
const READY_MESSAGE_TYPE = "cs-agent:ready";
|
|
||||||
const REQUEST_MINIMIZE_MESSAGE_TYPE = "cs-agent:request-minimize";
|
|
||||||
const REQUEST_CLOSE_MESSAGE_TYPE = "cs-agent:request-close";
|
|
||||||
const REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE = "cs-agent:request-toggle-maximize";
|
|
||||||
|
|
||||||
type HostBridgeOptions = {
|
|
||||||
onOpen?: () => void;
|
|
||||||
onMinimize?: () => void;
|
|
||||||
onMaximizedChange?: (isMaximized: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function bindHostBridge(options: HostBridgeOptions = {}) {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return () => undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.parent && window.parent !== window) {
|
|
||||||
window.parent.postMessage({ type: READY_MESSAGE_TYPE }, "*");
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMessage = (
|
|
||||||
event: MessageEvent,
|
|
||||||
) => {
|
|
||||||
const data = event.data as
|
|
||||||
| {
|
|
||||||
type?: string;
|
|
||||||
payload?: WidgetHostConfig | { isMaximized?: boolean };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
if (!data?.type) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === INIT_MESSAGE_TYPE && data.payload) {
|
|
||||||
setWidgetConfig(data.payload as WidgetHostConfig);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === OPEN_MESSAGE_TYPE) {
|
|
||||||
options.onOpen?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === MINIMIZE_MESSAGE_TYPE) {
|
|
||||||
options.onMinimize?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === MAXIMIZED_MESSAGE_TYPE) {
|
|
||||||
options.onMaximizedChange?.(
|
|
||||||
Boolean((data.payload as { isMaximized?: boolean } | undefined)?.isMaximized),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("message", handleMessage);
|
|
||||||
return () => window.removeEventListener("message", handleMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function postToParent(type: string) {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.parent && window.parent !== window) {
|
|
||||||
window.parent.postMessage({ type }, "*");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requestHostMinimize() {
|
|
||||||
postToParent(REQUEST_MINIMIZE_MESSAGE_TYPE);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requestHostClose() {
|
|
||||||
postToParent(REQUEST_CLOSE_MESSAGE_TYPE);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requestHostToggleMaximize() {
|
|
||||||
postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE);
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
(function () {
|
|
||||||
if (window.__CS_AGENT_WIDGET_LOADED__) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.__CS_AGENT_WIDGET_LOADED__ = true;
|
|
||||||
|
|
||||||
var config = window.CSAgentConfig || {};
|
|
||||||
var baseUrl = String(config.baseUrl || "").replace(/\/$/, "");
|
|
||||||
if (!config.channelId || !baseUrl) {
|
|
||||||
console.error("[cs-agent-widget] channelId and baseUrl are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWidgetBaseUrl() {
|
|
||||||
var currentScript = document.currentScript;
|
|
||||||
if (currentScript && currentScript.src) {
|
|
||||||
return currentScript.src.replace(/\/sdk\/cs-agent-widget\.js(?:\?.*)?$/, "");
|
|
||||||
}
|
|
||||||
if (/\/widget$/.test(baseUrl)) {
|
|
||||||
return baseUrl;
|
|
||||||
}
|
|
||||||
return baseUrl + "/widget";
|
|
||||||
}
|
|
||||||
|
|
||||||
var button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.dataset.csAgentWidget = "launcher";
|
|
||||||
button.setAttribute("aria-label", config.title || "在线客服");
|
|
||||||
button.textContent = config.title || "在线客服";
|
|
||||||
button.style.position = "fixed";
|
|
||||||
button.style.bottom = "24px";
|
|
||||||
button.style.right = config.position === "left" ? "" : "24px";
|
|
||||||
button.style.left = config.position === "left" ? "24px" : "";
|
|
||||||
button.style.zIndex = "2147483000";
|
|
||||||
button.style.border = "0";
|
|
||||||
button.style.borderRadius = "999px";
|
|
||||||
button.style.padding = "14px 18px";
|
|
||||||
button.style.background = config.themeColor || "#0f6cbd";
|
|
||||||
button.style.color = "#fff";
|
|
||||||
button.style.font = "600 14px/1 sans-serif";
|
|
||||||
button.style.boxShadow = "0 18px 40px rgba(15, 35, 65, 0.24)";
|
|
||||||
button.style.cursor = "pointer";
|
|
||||||
|
|
||||||
var widgetBaseUrl = resolveWidgetBaseUrl();
|
|
||||||
var frameUrl = new URL(widgetBaseUrl + "/frame/");
|
|
||||||
frameUrl.searchParams.set("channelId", config.channelId);
|
|
||||||
frameUrl.searchParams.set("baseUrl", baseUrl);
|
|
||||||
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl);
|
|
||||||
if (config.title) frameUrl.searchParams.set("title", config.title);
|
|
||||||
if (config.subtitle) frameUrl.searchParams.set("subtitle", config.subtitle);
|
|
||||||
if (config.position) frameUrl.searchParams.set("position", config.position);
|
|
||||||
if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor);
|
|
||||||
if (config.width) frameUrl.searchParams.set("width", config.width);
|
|
||||||
var frame = null;
|
|
||||||
var frameLoaded = false;
|
|
||||||
var frameReady = false;
|
|
||||||
var initSent = false;
|
|
||||||
var isOpen = false;
|
|
||||||
var isMaximized = false;
|
|
||||||
var frameHideTimer = null;
|
|
||||||
var frameDestroyTimer = null;
|
|
||||||
var animationDuration = 260;
|
|
||||||
|
|
||||||
function clearFrameTimers() {
|
|
||||||
if (frameHideTimer) {
|
|
||||||
window.clearTimeout(frameHideTimer);
|
|
||||||
frameHideTimer = null;
|
|
||||||
}
|
|
||||||
if (frameDestroyTimer) {
|
|
||||||
window.clearTimeout(frameDestroyTimer);
|
|
||||||
frameDestroyTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFrameLayout() {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.style.position = "fixed";
|
|
||||||
frame.style.border = "0";
|
|
||||||
frame.style.overflow = "hidden";
|
|
||||||
frame.style.background = "#fff";
|
|
||||||
frame.style.zIndex = "2147483000";
|
|
||||||
frame.style.boxShadow = "0 28px 80px rgba(15, 35, 65, 0.28)";
|
|
||||||
frame.style.willChange = "top,right,bottom,left,width,height,opacity,transform,border-radius";
|
|
||||||
frame.style.transition =
|
|
||||||
"top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease";
|
|
||||||
frame.style.transformOrigin =
|
|
||||||
config.position === "left" ? "left bottom" : "right bottom";
|
|
||||||
|
|
||||||
if (isMaximized) {
|
|
||||||
frame.style.top = "20px";
|
|
||||||
frame.style.right = "20px";
|
|
||||||
frame.style.bottom = "20px";
|
|
||||||
frame.style.left = "20px";
|
|
||||||
frame.style.width = "calc(100vw - 40px)";
|
|
||||||
frame.style.maxWidth = "none";
|
|
||||||
frame.style.height = "calc(100vh - 40px)";
|
|
||||||
frame.style.borderRadius = "24px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.style.top = "";
|
|
||||||
frame.style.bottom = "88px";
|
|
||||||
frame.style.right = config.position === "left" ? "" : "24px";
|
|
||||||
frame.style.left = config.position === "left" ? "24px" : "";
|
|
||||||
frame.style.width = config.width || "380px";
|
|
||||||
frame.style.maxWidth = "calc(100vw - 24px)";
|
|
||||||
frame.style.height = "min(760px, calc(100vh - 112px))";
|
|
||||||
frame.style.borderRadius = "28px";
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushFrameState() {
|
|
||||||
if (!frame || !frameLoaded || !frameReady) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initSent) {
|
|
||||||
initSent = true;
|
|
||||||
postToFrame({
|
|
||||||
type: "cs-agent:init",
|
|
||||||
payload: config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
postToFrame({ type: isOpen ? "cs-agent:open" : "cs-agent:minimize" });
|
|
||||||
postToFrame({
|
|
||||||
type: "cs-agent:maximized",
|
|
||||||
payload: { isMaximized: isMaximized },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function postToFrame(message) {
|
|
||||||
if (!frame || !frame.contentWindow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
frame.contentWindow.postMessage(message, frameUrl.origin);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[cs-agent-widget] postMessage failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncFrameVisibility() {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearFrameTimers();
|
|
||||||
applyFrameLayout();
|
|
||||||
frame.style.display = "block";
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
frame.style.visibility = "visible";
|
|
||||||
frame.style.pointerEvents = "auto";
|
|
||||||
frameHideTimer = window.setTimeout(function () {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
frame.style.opacity = "1";
|
|
||||||
frame.style.transform = "translate3d(0, 0, 0) scale(1)";
|
|
||||||
}, 16);
|
|
||||||
flushFrameState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.style.pointerEvents = "none";
|
|
||||||
frame.style.opacity = "0";
|
|
||||||
frame.style.transform = isMaximized
|
|
||||||
? "translate3d(0, 10px, 0) scale(0.985)"
|
|
||||||
: "translate3d(0, 16px, 0) scale(0.96)";
|
|
||||||
frameHideTimer = window.setTimeout(function () {
|
|
||||||
if (!frame || isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
frame.style.visibility = "hidden";
|
|
||||||
}, animationDuration);
|
|
||||||
flushFrameState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWindowMessage(event) {
|
|
||||||
if (!frame || event.source !== frame.contentWindow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = event.data || {};
|
|
||||||
if (data.type === "cs-agent:ready") {
|
|
||||||
frameReady = true;
|
|
||||||
flushFrameState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === "cs-agent:request-minimize") {
|
|
||||||
isOpen = false;
|
|
||||||
syncFrameVisibility();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === "cs-agent:request-close") {
|
|
||||||
destroyFrame();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === "cs-agent:request-toggle-maximize") {
|
|
||||||
isMaximized = !isMaximized;
|
|
||||||
syncFrameVisibility();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyFrame() {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearFrameTimers();
|
|
||||||
frame.style.pointerEvents = "none";
|
|
||||||
frame.style.opacity = "0";
|
|
||||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.94)";
|
|
||||||
frame.style.visibility = "hidden";
|
|
||||||
frameDestroyTimer = window.setTimeout(function () {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (frame.parentNode) {
|
|
||||||
frame.parentNode.removeChild(frame);
|
|
||||||
}
|
|
||||||
frame = null;
|
|
||||||
frameLoaded = false;
|
|
||||||
frameReady = false;
|
|
||||||
initSent = false;
|
|
||||||
isOpen = false;
|
|
||||||
isMaximized = false;
|
|
||||||
clearFrameTimers();
|
|
||||||
}, animationDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFrame() {
|
|
||||||
if (frame) {
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame = document.createElement("iframe");
|
|
||||||
frame.dataset.csAgentWidget = "frame";
|
|
||||||
frame.title = config.title || "在线客服";
|
|
||||||
frame.src = frameUrl.toString();
|
|
||||||
applyFrameLayout();
|
|
||||||
frame.style.display = "block";
|
|
||||||
frame.style.visibility = "hidden";
|
|
||||||
frame.style.pointerEvents = "none";
|
|
||||||
frame.style.opacity = "0";
|
|
||||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)";
|
|
||||||
frame.addEventListener("load", function () {
|
|
||||||
frameLoaded = true;
|
|
||||||
syncFrameVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(frame);
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.addEventListener("click", function () {
|
|
||||||
if (!frame) {
|
|
||||||
createFrame();
|
|
||||||
}
|
|
||||||
isOpen = !isOpen;
|
|
||||||
syncFrameVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("message", handleWindowMessage);
|
|
||||||
document.body.appendChild(button);
|
|
||||||
})();
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { generateUUID } from "@/lib/utils";
|
|
||||||
|
|
||||||
const EXTERNAL_ID_KEY = "cs-agent:external-id";
|
|
||||||
|
|
||||||
export function getOrCreateExternalId() {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const current = window.localStorage.getItem(EXTERNAL_ID_KEY);
|
|
||||||
if (current) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
const visitorId = `visitor_${generateUUID()}`;
|
|
||||||
window.localStorage.setItem(EXTERNAL_ID_KEY, visitorId);
|
|
||||||
return visitorId;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { NextConfig } from "next"
|
|
||||||
import { PHASE_DEVELOPMENT_SERVER } from "next/constants"
|
|
||||||
|
|
||||||
const backendBaseUrl =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || "http://127.0.0.1:8083"
|
|
||||||
const productionBasePath = "/widget"
|
|
||||||
|
|
||||||
export default function nextConfig(phase: string): NextConfig {
|
|
||||||
if (phase === PHASE_DEVELOPMENT_SERVER) {
|
|
||||||
return {
|
|
||||||
reactStrictMode: true,
|
|
||||||
trailingSlash: true,
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
async rewrites() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/api/:path*",
|
|
||||||
destination: `${backendBaseUrl}/api/:path*`,
|
|
||||||
basePath: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/storage/:path*",
|
|
||||||
destination: `${backendBaseUrl}/storage/:path*`,
|
|
||||||
basePath: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
reactStrictMode: true,
|
|
||||||
output: "export",
|
|
||||||
basePath: productionBasePath,
|
|
||||||
assetPrefix: `${productionBasePath}/`,
|
|
||||||
trailingSlash: true,
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "widget",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "PORT=4000 next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "eslint",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"build:sdk": "node ./scripts/build-sdk.mjs"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tiptap/extension-image": "^3.20.2",
|
|
||||||
"@tiptap/extension-placeholder": "^3.20.2",
|
|
||||||
"@tiptap/react": "^3.20.2",
|
|
||||||
"@tiptap/starter-kit": "^3.20.2",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.577.0",
|
|
||||||
"markdown-it": "^14.1.1",
|
|
||||||
"next": "16.1.6",
|
|
||||||
"react": "19.2.3",
|
|
||||||
"react-dom": "19.2.3",
|
|
||||||
"react-zoom-pan-pinch": "^3.7.0",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
|
||||||
"zustand": "^5.0.12"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"@types/markdown-it": "^14.1.2",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "16.1.6",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-4765
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- sharp
|
|
||||||
- unrs-resolver
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,270 +0,0 @@
|
|||||||
(function () {
|
|
||||||
if (window.__CS_AGENT_WIDGET_LOADED__) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.__CS_AGENT_WIDGET_LOADED__ = true;
|
|
||||||
|
|
||||||
var config = window.CSAgentConfig || {};
|
|
||||||
var baseUrl = String(config.baseUrl || "").replace(/\/$/, "");
|
|
||||||
if (!config.channelId || !baseUrl) {
|
|
||||||
console.error("[cs-agent-widget] channelId and baseUrl are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWidgetBaseUrl() {
|
|
||||||
var currentScript = document.currentScript;
|
|
||||||
if (currentScript && currentScript.src) {
|
|
||||||
return currentScript.src.replace(/\/sdk\/cs-agent-widget\.js(?:\?.*)?$/, "");
|
|
||||||
}
|
|
||||||
if (/\/widget$/.test(baseUrl)) {
|
|
||||||
return baseUrl;
|
|
||||||
}
|
|
||||||
return baseUrl + "/widget";
|
|
||||||
}
|
|
||||||
|
|
||||||
var button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.dataset.csAgentWidget = "launcher";
|
|
||||||
button.setAttribute("aria-label", config.title || "在线客服");
|
|
||||||
button.textContent = config.title || "在线客服";
|
|
||||||
button.style.position = "fixed";
|
|
||||||
button.style.bottom = "24px";
|
|
||||||
button.style.right = config.position === "left" ? "" : "24px";
|
|
||||||
button.style.left = config.position === "left" ? "24px" : "";
|
|
||||||
button.style.zIndex = "2147483000";
|
|
||||||
button.style.border = "0";
|
|
||||||
button.style.borderRadius = "999px";
|
|
||||||
button.style.padding = "14px 18px";
|
|
||||||
button.style.background = config.themeColor || "#0f6cbd";
|
|
||||||
button.style.color = "#fff";
|
|
||||||
button.style.font = "600 14px/1 sans-serif";
|
|
||||||
button.style.boxShadow = "0 18px 40px rgba(15, 35, 65, 0.24)";
|
|
||||||
button.style.cursor = "pointer";
|
|
||||||
|
|
||||||
var widgetBaseUrl = resolveWidgetBaseUrl();
|
|
||||||
var frameUrl = new URL(widgetBaseUrl + "/frame/");
|
|
||||||
frameUrl.searchParams.set("channelId", config.channelId);
|
|
||||||
frameUrl.searchParams.set("baseUrl", baseUrl);
|
|
||||||
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl);
|
|
||||||
if (config.title) frameUrl.searchParams.set("title", config.title);
|
|
||||||
if (config.subtitle) frameUrl.searchParams.set("subtitle", config.subtitle);
|
|
||||||
if (config.position) frameUrl.searchParams.set("position", config.position);
|
|
||||||
if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor);
|
|
||||||
if (config.width) frameUrl.searchParams.set("width", config.width);
|
|
||||||
var frame = null;
|
|
||||||
var frameLoaded = false;
|
|
||||||
var frameReady = false;
|
|
||||||
var initSent = false;
|
|
||||||
var isOpen = false;
|
|
||||||
var isMaximized = false;
|
|
||||||
var frameHideTimer = null;
|
|
||||||
var frameDestroyTimer = null;
|
|
||||||
var animationDuration = 260;
|
|
||||||
|
|
||||||
function clearFrameTimers() {
|
|
||||||
if (frameHideTimer) {
|
|
||||||
window.clearTimeout(frameHideTimer);
|
|
||||||
frameHideTimer = null;
|
|
||||||
}
|
|
||||||
if (frameDestroyTimer) {
|
|
||||||
window.clearTimeout(frameDestroyTimer);
|
|
||||||
frameDestroyTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFrameLayout() {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.style.position = "fixed";
|
|
||||||
frame.style.border = "0";
|
|
||||||
frame.style.overflow = "hidden";
|
|
||||||
frame.style.background = "#fff";
|
|
||||||
frame.style.zIndex = "2147483000";
|
|
||||||
frame.style.boxShadow = "0 28px 80px rgba(15, 35, 65, 0.28)";
|
|
||||||
frame.style.willChange = "top,right,bottom,left,width,height,opacity,transform,border-radius";
|
|
||||||
frame.style.transition =
|
|
||||||
"top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease";
|
|
||||||
frame.style.transformOrigin =
|
|
||||||
config.position === "left" ? "left bottom" : "right bottom";
|
|
||||||
|
|
||||||
if (isMaximized) {
|
|
||||||
frame.style.top = "20px";
|
|
||||||
frame.style.right = "20px";
|
|
||||||
frame.style.bottom = "20px";
|
|
||||||
frame.style.left = "20px";
|
|
||||||
frame.style.width = "calc(100vw - 40px)";
|
|
||||||
frame.style.maxWidth = "none";
|
|
||||||
frame.style.height = "calc(100vh - 40px)";
|
|
||||||
frame.style.borderRadius = "24px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.style.top = "";
|
|
||||||
frame.style.bottom = "88px";
|
|
||||||
frame.style.right = config.position === "left" ? "" : "24px";
|
|
||||||
frame.style.left = config.position === "left" ? "24px" : "";
|
|
||||||
frame.style.width = config.width || "380px";
|
|
||||||
frame.style.maxWidth = "calc(100vw - 24px)";
|
|
||||||
frame.style.height = "min(760px, calc(100vh - 112px))";
|
|
||||||
frame.style.borderRadius = "28px";
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushFrameState() {
|
|
||||||
if (!frame || !frameLoaded || !frameReady) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initSent) {
|
|
||||||
initSent = true;
|
|
||||||
postToFrame({
|
|
||||||
type: "cs-agent:init",
|
|
||||||
payload: config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
postToFrame({ type: isOpen ? "cs-agent:open" : "cs-agent:minimize" });
|
|
||||||
postToFrame({
|
|
||||||
type: "cs-agent:maximized",
|
|
||||||
payload: { isMaximized: isMaximized },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function postToFrame(message) {
|
|
||||||
if (!frame || !frame.contentWindow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
frame.contentWindow.postMessage(message, frameUrl.origin);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[cs-agent-widget] postMessage failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncFrameVisibility() {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearFrameTimers();
|
|
||||||
applyFrameLayout();
|
|
||||||
frame.style.display = "block";
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
frame.style.visibility = "visible";
|
|
||||||
frame.style.pointerEvents = "auto";
|
|
||||||
frameHideTimer = window.setTimeout(function () {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
frame.style.opacity = "1";
|
|
||||||
frame.style.transform = "translate3d(0, 0, 0) scale(1)";
|
|
||||||
}, 16);
|
|
||||||
flushFrameState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.style.pointerEvents = "none";
|
|
||||||
frame.style.opacity = "0";
|
|
||||||
frame.style.transform = isMaximized
|
|
||||||
? "translate3d(0, 10px, 0) scale(0.985)"
|
|
||||||
: "translate3d(0, 16px, 0) scale(0.96)";
|
|
||||||
frameHideTimer = window.setTimeout(function () {
|
|
||||||
if (!frame || isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
frame.style.visibility = "hidden";
|
|
||||||
}, animationDuration);
|
|
||||||
flushFrameState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWindowMessage(event) {
|
|
||||||
if (!frame || event.source !== frame.contentWindow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = event.data || {};
|
|
||||||
if (data.type === "cs-agent:ready") {
|
|
||||||
frameReady = true;
|
|
||||||
flushFrameState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === "cs-agent:request-minimize") {
|
|
||||||
isOpen = false;
|
|
||||||
syncFrameVisibility();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === "cs-agent:request-close") {
|
|
||||||
destroyFrame();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type === "cs-agent:request-toggle-maximize") {
|
|
||||||
isMaximized = !isMaximized;
|
|
||||||
syncFrameVisibility();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyFrame() {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearFrameTimers();
|
|
||||||
frame.style.pointerEvents = "none";
|
|
||||||
frame.style.opacity = "0";
|
|
||||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.94)";
|
|
||||||
frame.style.visibility = "hidden";
|
|
||||||
frameDestroyTimer = window.setTimeout(function () {
|
|
||||||
if (!frame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (frame.parentNode) {
|
|
||||||
frame.parentNode.removeChild(frame);
|
|
||||||
}
|
|
||||||
frame = null;
|
|
||||||
frameLoaded = false;
|
|
||||||
frameReady = false;
|
|
||||||
initSent = false;
|
|
||||||
isOpen = false;
|
|
||||||
isMaximized = false;
|
|
||||||
clearFrameTimers();
|
|
||||||
}, animationDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFrame() {
|
|
||||||
if (frame) {
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame = document.createElement("iframe");
|
|
||||||
frame.dataset.csAgentWidget = "frame";
|
|
||||||
frame.title = config.title || "在线客服";
|
|
||||||
frame.src = frameUrl.toString();
|
|
||||||
applyFrameLayout();
|
|
||||||
frame.style.display = "block";
|
|
||||||
frame.style.visibility = "hidden";
|
|
||||||
frame.style.pointerEvents = "none";
|
|
||||||
frame.style.opacity = "0";
|
|
||||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)";
|
|
||||||
frame.addEventListener("load", function () {
|
|
||||||
frameLoaded = true;
|
|
||||||
syncFrameVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(frame);
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.addEventListener("click", function () {
|
|
||||||
if (!frame) {
|
|
||||||
createFrame();
|
|
||||||
}
|
|
||||||
isOpen = !isOpen;
|
|
||||||
syncFrameVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("message", handleWindowMessage);
|
|
||||||
document.body.appendChild(button);
|
|
||||||
})();
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { cp, mkdir } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const rootDir = path.resolve(currentDir, "..");
|
|
||||||
const source = path.join(rootDir, "lib", "widget", "sdk-template.js");
|
|
||||||
const targetDir = path.join(rootDir, "public", "sdk");
|
|
||||||
const target = path.join(targetDir, "cs-agent-widget.js");
|
|
||||||
|
|
||||||
await mkdir(targetDir, { recursive: true });
|
|
||||||
await cp(source, target);
|
|
||||||
|
|
||||||
console.log(`sdk written to ${target}`);
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2017",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts",
|
|
||||||
"**/*.mts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user