edit
"use client";
import "@copilotkit/react-ui/styles.css";
import "./style.css";
import MarkdownIt from "markdown-it";
import { diffWords } from "diff";
import { useEffect, useState } from "react";
import {
CopilotKit,
useCoAgent,
useCopilotAction,
useCopilotChat,
} from "@copilotkit/react-core";
import { CopilotSidebar, useCopilotChatSuggestions } from "@copilotkit/react-ui";
import { initialPrompt, chatSuggestions } from "@/lib/prompts";
import {
EditorCommand,
EditorCommandEmpty,
EditorCommandItem,
EditorCommandList,
EditorContent as NovelEditorContent,
type EditorInstance,
EditorRoot,
ImageResizer,
type JSONContent,
handleCommandNavigation,
handleImageDrop,
handleImagePaste,
} from "novel";
import { useDebouncedCallback } from "use-debounce";
import { defaultExtensions } from "@/components/editor/extensions";
import { ColorSelector } from "@/components/editor/selectors/color-selector";
import { LinkSelector } from "@/components/editor/selectors/link-selector";
import { MathSelector } from "@/components/editor/selectors/math-selector";
import { NodeSelector } from "@/components/editor/selectors/node-selector";
import { Separator } from "@/components/ui/separator";
import GenerativeMenuSwitch from "@/components/editor/generative/generative-menu-switch";
import { uploadFn } from "@/components/editor/image-upload";
import { TextButtons } from "@/components/editor/selectors/text-buttons";
import { slashCommand, suggestionItems } from "@/components/editor/slash-command";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
FileText,
Save,
Share2,
Download,
Settings,
Maximize2,
Minimize2,
Moon,
Sun,
Zap,
Clock,
Users,
Eye,
MoreHorizontal,
Sparkles,
Brain,
Wand2,
Timer,
CheckCircle2,
AlertCircle,
Info
} from "lucide-react";
import { useTheme } from "next-themes";
import "@/styles/prosemirror.css";
const hljs = require("highlight.js");
const extensions = [...defaultExtensions, slashCommand];
export default function PredictiveStateUpdates() {
return (
<CopilotKit
runtimeUrl={"/api/copilotkit"}
showDevConsole={false}
agent="sample_agent"
>
<DocumentEditorApp />
</CopilotKit>
);
}
const DocumentEditorApp = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const { theme, setTheme } = useTheme();
return (
<div className="h-screen w-full flex flex-col relative overflow-hidden"
style={{
"--copilot-kit-primary-color": "#222",
"--copilot-kit-separator-color": "#CCC",
} as React.CSSProperties}>
{/* 动态背景效果 */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-50 via-blue-50 to-cyan-50 dark:from-purple-950/20 dark:via-blue-950/20 dark:to-cyan-950/20" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,_rgba(255,255,255,0.3)_1px,_transparent_0)] dark:bg-[radial-gradient(circle_at_1px_1px,_rgba(255,255,255,0.1)_1px,_transparent_0)] bg-[size:20px_20px] opacity-60" />
{/* 浮动装饰元素 */}
<div className="absolute top-20 left-20 w-64 h-64 bg-gradient-to-r from-purple-400/20 to-blue-400/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-r from-cyan-400/20 to-blue-400/20 rounded-full blur-3xl animate-pulse animation-delay-2000" />
<div className="absolute top-1/2 left-1/3 w-32 h-32 bg-gradient-to-r from-pink-400/20 to-purple-400/20 rounded-full blur-2xl animate-bounce animation-delay-1000" />
{/* 主要内容 */}
<div className="relative z-10 h-screen flex flex-col">
{/* 顶部导航栏 */}
<TopNavigation
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
theme={theme}
onThemeChange={setTheme}
/>
{/* 主要内容区域 */}
<div className="flex-1 flex overflow-hidden">
{/* 文档编辑区域 */}
<div className="flex-1 flex flex-col">
{/* 工具栏 */}
<EditorToolbar />
{/* 编辑器区域 */}
<div className="flex-1 flex">
<div className="flex-1 p-6">
<DocumentEditor />
</div>
{/* AI助手侧边栏 */}
<CopilotSidebar
defaultOpen={sidebarOpen}
labels={{
title: "AI写作助手",
initial: initialPrompt.sample_agent,
}}
clickOutsideToClose={false}
className="w-96 border-l border-gray-200/50 dark:border-gray-700/50 bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl"
/>
</div>
</div>
</div>
{/* 底部状态栏 */}
<StatusBar />
</div>
</div>
);
};
const TopNavigation = ({
isFullscreen,
onToggleFullscreen,
theme,
onThemeChange
}: {
isFullscreen: boolean;
onToggleFullscreen: () => void;
theme: string | undefined;
onThemeChange: (theme: string) => void;
}) => {
return (
<header className="h-16 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-b border-gray-200/30 dark:border-gray-700/30 flex items-center justify-between px-6 relative">
{/* 顶部渐变装饰 */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-purple-500 via-blue-500 to-cyan-500" />
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-3">
{/* Logo */}
<div className="relative group">
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 via-blue-500 to-cyan-500 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-300 group-hover:scale-110">
<Sparkles className="w-5 h-5 text-white group-hover:rotate-12 transition-transform duration-300" />
</div>
<div className="absolute inset-0 w-10 h-10 bg-gradient-to-r from-purple-500 via-blue-500 to-cyan-500 rounded-xl opacity-0 group-hover:opacity-30 blur-lg transition-all duration-300" />
</div>
{/* 标题 */}
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-gray-900 via-blue-800 to-purple-800 dark:from-white dark:via-blue-200 dark:to-purple-200 bg-clip-text text-transparent">
AI文档编辑器
</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">智能写作,创意无限</p>
</div>
</div>
{/* 状态指示器 */}
<div className="flex items-center space-x-3">
<Badge variant="secondary" className="bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 border-emerald-200 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300">
<div className="w-2 h-2 bg-emerald-500 rounded-full mr-2 animate-pulse" />
AI在线
</Badge>
<Badge variant="outline" className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300">
<Zap className="w-3 h-3 mr-1" />
AI增强
</Badge>
</div>
</div>
<div className="flex items-center space-x-2">
<TooltipProvider>
{/* 保存按钮 */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="relative group overflow-hidden hover:bg-gradient-to-r hover:from-emerald-50 hover:to-green-50 dark:hover:from-emerald-900/20 dark:hover:to-green-900/20 border border-transparent hover:border-emerald-200 dark:hover:border-emerald-700 transition-all duration-300">
<Save className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors duration-300" />
</Button>
</TooltipTrigger>
<TooltipContent>保存文档</TooltipContent>
</Tooltip>
{/* 分享按钮 */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="relative group overflow-hidden hover:bg-gradient-to-r hover:from-blue-50 hover:to-purple-50 dark:hover:from-blue-900/20 dark:hover:to-purple-900/20 border border-transparent hover:border-blue-200 dark:hover:border-blue-700 transition-all duration-300">
<Share2 className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300" />
</Button>
</TooltipTrigger>
<TooltipContent>分享文档</TooltipContent>
</Tooltip>
{/* 导出按钮 */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="relative group overflow-hidden hover:bg-gradient-to-r hover:from-orange-50 hover:to-red-50 dark:hover:from-orange-900/20 dark:hover:to-red-900/20 border border-transparent hover:border-orange-200 dark:hover:border-orange-700 transition-all duration-300">
<Download className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-orange-600 dark:group-hover:text-orange-400 transition-colors duration-300" />
</Button>
</TooltipTrigger>
<TooltipContent>导出文档</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-4" />
{/* 主题切换 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onThemeChange(theme === 'dark' ? 'light' : 'dark')}
className="relative group overflow-hidden hover:bg-gradient-to-r hover:from-yellow-50 hover:to-orange-50 dark:hover:from-yellow-900/20 dark:hover:to-orange-900/20 border border-transparent hover:border-yellow-200 dark:hover:border-yellow-700 transition-all duration-300"
>
{theme === 'dark' ? (
<Sun className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-yellow-600 dark:group-hover:text-yellow-400 transition-colors duration-300" />
) : (
<Moon className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-300" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>切换主题</TooltipContent>
</Tooltip>
{/* 全屏按钮 */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={onToggleFullscreen} className="relative group overflow-hidden hover:bg-gradient-to-r hover:from-purple-50 hover:to-pink-50 dark:hover:from-purple-900/20 dark:hover:to-pink-900/20 border border-transparent hover:border-purple-200 dark:hover:border-purple-700 transition-all duration-300">
{isFullscreen ? (
<Minimize2 className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-300" />
) : (
<Maximize2 className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-300" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>全屏模式</TooltipContent>
</Tooltip>
{/* 设置按钮 */}
<Button variant="ghost" size="sm" className="relative group overflow-hidden hover:bg-gradient-to-r hover:from-gray-50 hover:to-slate-50 dark:hover:from-gray-800/50 dark:hover:to-slate-800/50 border border-transparent hover:border-gray-200 dark:hover:border-gray-700 transition-all duration-300">
<Settings className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-gray-800 dark:group-hover:text-gray-200 transition-colors duration-300" />
</Button>
</TooltipProvider>
</div>
</header>
);
};
const EditorToolbar = () => {
return (
<div className="h-14 bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl border-b border-gray-200/30 dark:border-gray-700/30 flex items-center px-6 relative">
{/* 装饰性渐变线 */}
<div className="absolute bottom-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-purple-500/50 to-transparent" />
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-6">
{/* AI状态卡片 */}
<div className="flex items-center space-x-3">
<div className="relative group">
<Button variant="ghost" size="sm" className="bg-gradient-to-r from-purple-500/10 to-blue-500/10 hover:from-purple-500/20 hover:to-blue-500/20 border border-purple-200/50 dark:border-purple-700/50 text-purple-700 dark:text-purple-300 transition-all duration-300 hover:scale-105">
<div className="flex items-center space-x-2">
<div className="w-5 h-5 bg-gradient-to-r from-purple-500 to-blue-500 rounded-lg flex items-center justify-center">
<Brain className="w-3 h-3 text-white" />
</div>
<span className="font-medium">AI助手已激活</span>
</div>
</Button>
{/* 脉冲效果 */}
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/20 to-blue-500/20 rounded-lg animate-pulse opacity-50" />
</div>
<Badge variant="outline" className="bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 border-emerald-200 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 animate-pulse">
<Sparkles className="w-3 h-3 mr-1" />
选中文字显示AI工具
</Badge>
</div>
</div>
{/* 使用提示 */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
<div className="w-6 h-6 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold">💡</span>
</div>
<span className="hidden md:block">选中文字后点击 "Ask AI" 进行润色、扩写、缩短等操作</span>
<span className="md:hidden">选中文字使用AI功能</span>
</div>
</div>
</div>
</div>
);
};
interface AgentState {
document: string;
}
const DocumentEditor = () => {
const [placeholderVisible, setPlaceholderVisible] = useState(false);
const [currentDocument, setCurrentDocument] = useState("");
const [saveStatus, setSaveStatus] = useState("已保存");
const [editorInstance, setEditorInstance] = useState<EditorInstance | null>(null);
const [wordCount, setWordCount] = useState(0);
const [readingTime, setReadingTime] = useState(0);
const { isLoading } = useCopilotChat();
// AI工具栏状态
const [openNode, setOpenNode] = useState(false);
const [openColor, setOpenColor] = useState(false);
const [openLink, setOpenLink] = useState(false);
const [openAI, setOpenAI] = useState(false);
const {
state: agentState,
setState: setAgentState,
nodeName,
} = useCoAgent<AgentState>({
name: "sample_agent",
initialState: {
document: "",
},
});
const debouncedUpdates = useDebouncedCallback(
async (editor: EditorInstance) => {
if (!isLoading) {
const text = editor.getText();
const markdown = editor.storage.markdown.getMarkdown();
setCurrentDocument(text);
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
setReadingTime(Math.ceil(text.split(' ').length / 200)); // 假设每分钟200字
setAgentState({
document: text,
});
setSaveStatus("已保存");
}
},
500,
);
useEffect(() => {
if (editorInstance) {
editorInstance.setEditable(!isLoading);
}
}, [isLoading, editorInstance]);
useEffect(() => {
if (nodeName === "end" && editorInstance) {
if (
currentDocument.trim().length > 0 &&
currentDocument !== agentState?.document
) {
const newDocument = agentState?.document || "";
const diff = diffPartialText(currentDocument, newDocument, true);
const markdown = fromMarkdown(diff);
editorInstance.commands.setContent(markdown);
}
}
}, [nodeName, editorInstance, currentDocument, agentState?.document]);
useEffect(() => {
if (isLoading && editorInstance) {
if (currentDocument.trim().length > 0) {
const newDocument = agentState?.document || "";
const diff = diffPartialText(currentDocument, newDocument);
const markdown = fromMarkdown(diff);
editorInstance.commands.setContent(markdown);
} else {
const markdown = fromMarkdown(agentState?.document || "");
editorInstance.commands.setContent(markdown);
}
}
}, [agentState?.document, isLoading, editorInstance, currentDocument]);
const text = editorInstance?.getText() || "";
useEffect(() => {
setPlaceholderVisible(text.length === 0);
}, [text]);
useCopilotAction({
name: "confirm_changes",
renderAndWaitForResponse: ({ args, respond, status }) => {
const [accepted, setAccepted] = useState<boolean | null>(null);
return (
<Card className="m-4 shadow-lg">
<CardContent className="p-6">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<AlertCircle className="w-6 h-6 text-amber-500" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
确认AI建议的更改
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
AI助手建议对您的文档进行以下更改,请确认是否接受。
</p>
</div>
</div>
{accepted === null && (
<div className="mt-6 flex justify-end space-x-3">
<Button
variant="outline"
disabled={status !== "executing"}
onClick={() => {
if (respond && editorInstance) {
setAccepted(false);
editorInstance.commands.setContent(fromMarkdown(currentDocument));
setAgentState({ document: currentDocument });
respond({ accepted: false });
}
}}
>
拒绝更改
</Button>
<Button
disabled={status !== "executing"}
onClick={() => {
if (respond && editorInstance) {
setAccepted(true);
editorInstance.commands.setContent(
fromMarkdown(agentState?.document || "")
);
setCurrentDocument(agentState?.document || "");
setAgentState({ document: agentState?.document || "" });
respond({ accepted: true });
}
}}
>
<CheckCircle2 className="w-4 h-4 mr-2" />
接受更改
</Button>
</div>
)}
{accepted !== null && (
<div className="mt-6 flex justify-end">
<Badge variant={accepted ? "default" : "secondary"}>
{accepted ? (
<>
<CheckCircle2 className="w-3 h-3 mr-1" />
已接受
</>
) : (
<>
<AlertCircle className="w-3 h-3 mr-1" />
已拒绝
</>
)}
</Badge>
</div>
)}
</CardContent>
</Card>
);
},
});
useCopilotChatSuggestions({
instructions: chatSuggestions.sample_agent,
});
const defaultContent = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "",
},
],
},
],
};
return (
<Card className="h-full border-0 shadow-none">
<CardContent className="p-0 h-full">
<div className="relative h-full">
{/* 保存状态指示器 */}
<div className="absolute top-4 right-4 z-10">
<Badge variant="secondary" className="text-xs">
{saveStatus === "已保存" ? (
<>
<CheckCircle2 className="w-3 h-3 mr-1 text-green-500" />
{saveStatus}
</>
) : (
<>
<Timer className="w-3 h-3 mr-1 text-yellow-500" />
{saveStatus}
</>
)}
</Badge>
</div>
{/* AI加载指示器 */}
{isLoading && (
<div className="absolute top-4 left-4 z-10">
<Badge variant="outline" className="text-xs">
<Wand2 className="w-3 h-3 mr-1 animate-pulse text-blue-500" />
AI正在处理...
</Badge>
</div>
)}
{/* Novel编辑器 */}
<div className="h-full overflow-hidden">
<EditorRoot>
<NovelEditorContent
immediatelyRender={false}
initialContent={defaultContent as JSONContent}
extensions={extensions}
className="h-full border-none focus:outline-none"
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event),
},
handlePaste: (view, event) =>
handleImagePaste(view, event, uploadFn),
handleDrop: (view, event, _slice, moved) =>
handleImageDrop(view, event, moved, uploadFn),
attributes: {
class: "prose prose-lg dark:prose-invert max-w-none focus:outline-none px-8 py-12 min-h-full",
},
}}
onUpdate={({ editor }) => {
setEditorInstance(editor);
debouncedUpdates(editor);
setSaveStatus("保存中...");
}}
onCreate={({ editor }) => {
setEditorInstance(editor);
}}
slotAfter={<ImageResizer />}
>
<EditorCommand className="z-50 h-auto max-h-[330px] overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-1 py-2 shadow-xl">
<EditorCommandEmpty className="px-2 text-gray-500 dark:text-gray-400">
未找到结果
</EditorCommandEmpty>
<EditorCommandList>
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => item.command?.(val)}
className="flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 aria-selected:bg-gray-100 dark:aria-selected:bg-gray-700"
key={item.title}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
{item.icon}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
{item.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{item.description}
</p>
</div>
</EditorCommandItem>
))}
</EditorCommandList>
</EditorCommand>
{/* AI增强的悬浮工具栏 - 选中文字时显示,包含润色、扩写等功能 */}
<GenerativeMenuSwitch open={openAI} onOpenChange={setOpenAI}>
<Separator orientation="vertical" />
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<Separator orientation="vertical" />
<TextButtons />
<Separator orientation="vertical" />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
<Separator orientation="vertical" />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<Separator orientation="vertical" />
<MathSelector />
</GenerativeMenuSwitch>
</NovelEditorContent>
</EditorRoot>
</div>
{/* 占位符 */}
{placeholderVisible && (
<div className="absolute top-20 left-12 pointer-events-none">
<div className="text-gray-400 dark:text-gray-500 text-lg">
✨ 开始输入,让AI助手帮您创作精彩内容...
</div>
<div className="text-gray-300 dark:text-gray-600 text-sm mt-2">
尝试输入 "/" 来查看可用的命令和格式选项
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center space-x-2 text-gray-300 dark:text-gray-600 text-xs">
<div className="w-4 h-4 bg-blue-100 dark:bg-blue-900 rounded flex items-center justify-center">
<Brain className="w-2 h-2 text-blue-600 dark:text-blue-400" />
</div>
<span>选中文字后点击"Ask AI"进行润色、扩写</span>
</div>
<div className="flex items-center space-x-2 text-gray-300 dark:text-gray-600 text-xs">
<div className="w-4 h-4 bg-green-100 dark:bg-green-900 rounded flex items-center justify-center">
<Wand2 className="w-2 h-2 text-green-600 dark:text-green-400" />
</div>
<span>右侧AI助手可以帮您优化文本、生成内容</span>
</div>
<div className="flex items-center space-x-2 text-gray-300 dark:text-gray-600 text-xs">
<div className="w-4 h-4 bg-purple-100 dark:bg-purple-900 rounded flex items-center justify-center">
<Sparkles className="w-2 h-2 text-purple-600 dark:text-purple-400" />
</div>
<span>支持markdown语法、代码块、数学公式</span>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
};
const StatusBar = () => {
const [wordCount, setWordCount] = useState(0);
const [readingTime, setReadingTime] = useState(0);
return (
<footer className="h-10 bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl border-t border-gray-200/30 dark:border-gray-700/30 flex items-center justify-between px-6 text-xs text-gray-600 dark:text-gray-400 relative">
{/* 顶部装饰线 */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-cyan-500/50 to-transparent" />
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-2 group hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors duration-300">
<div className="w-4 h-4 bg-gradient-to-r from-emerald-500 to-green-500 rounded-full flex items-center justify-center">
<FileText className="w-2 h-2 text-white" />
</div>
<span className="font-medium">系统就绪</span>
</div>
<div className="flex items-center space-x-2 group hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-300">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span>AI助手在线</span>
</div>
</div>
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-2 group hover:text-purple-600 dark:hover:text-purple-400 transition-colors duration-300">
<Clock className="w-3 h-3" />
<span>实时保存</span>
</div>
<div className="flex items-center space-x-2 group hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-300">
<div className="w-4 h-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center">
<Sparkles className="w-2 h-2 text-white" />
</div>
<span className="font-medium">Novel编辑器</span>
</div>
<div className="flex items-center space-x-2 text-xs bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-700 px-2 py-1 rounded-full">
<span>v1.0.0</span>
</div>
</div>
</footer>
);
};
function fromMarkdown(text: string) {
const md = new MarkdownIt({
typographer: true,
html: true,
});
return md.render(text);
}
function diffPartialText(
oldText: string,
newText: string,
isComplete: boolean = false
) {
let oldTextToCompare = oldText;
if (oldText.length > newText.length && !isComplete) {
oldTextToCompare = oldText.slice(0, newText.length);
}
const changes = diffWords(oldTextToCompare, newText);
let result = "";
changes.forEach((part) => {
if (part.added) {
result += `<em>${part.value}</em>`;
} else if (part.removed) {
result += `<s>${part.value}</s>`;
} else {
result += part.value;
}
});
if (oldText.length > newText.length && !isComplete) {
result += oldText.slice(newText.length);
}
return result;
style.css
/* 现代化AI文档编辑器样式 */
/* 全局布局样式 */
.document-editor-app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 炫酷动画效果 */
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 5px rgba(168, 85, 247, 0.4); }
50% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.8), 0 0 30px rgba(168, 85, 247, 0.4); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes bounce-gentle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes rotate-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes scale-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
/* 动画延迟类 */
.animation-delay-1000 {
animation-delay: 1s;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-3000 {
animation-delay: 3s;
}
/* 炫酷效果类 */
.gradient-bg {
background: linear-gradient(-45deg, #667eea, #764ba2, #667eea, #764ba2);
background-size: 400% 400%;
animation: gradient-shift 3s ease infinite;
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
.pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.shimmer-effect::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 2s infinite;
}
.bounce-gentle {
animation: bounce-gentle 2s ease-in-out infinite;
}
.rotate-slow {
animation: rotate-slow 10s linear infinite;
}
.scale-pulse {
animation: scale-pulse 2s ease-in-out infinite;
}
/* 顶部导航栏样式 */
.top-navigation {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.dark .top-navigation {
background: rgba(17, 24, 39, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
/* 编辑器工具栏样式 */
.editor-toolbar {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.95);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .editor-toolbar {
background: rgba(17, 24, 39, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
/* 编辑器内容样式 */
.ProseMirror {
outline: none;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 16px;
line-height: 1.75;
color: #1f2937;
background: transparent;
min-height: 100%;
padding: 2rem 0;
transition: all 0.3s ease;
}
.dark .ProseMirror {
color: #f9fafb;
}
/* 悬浮效果 */
.hover-lift {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 渐变按钮效果 */
.gradient-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.gradient-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.gradient-button:hover::before {
left: 100%;
}
/* 玻璃态效果 */
.glass-effect {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
}
.dark .glass-effect {
background: rgba(17, 24, 39, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* 荧光效果 */
.neon-glow {
box-shadow:
0 0 5px rgba(168, 85, 247, 0.5),
0 0 10px rgba(168, 85, 247, 0.3),
0 0 15px rgba(168, 85, 247, 0.2),
0 0 20px rgba(168, 85, 247, 0.1);
}
.dark .neon-glow {
box-shadow:
0 0 5px rgba(168, 85, 247, 0.7),
0 0 10px rgba(168, 85, 247, 0.5),
0 0 15px rgba(168, 85, 247, 0.3),
0 0 20px rgba(168, 85, 247, 0.2);
}
/* 占位符样式 */
.ProseMirror .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #9ca3af;
pointer-events: none;
height: 0;
font-style: italic;
opacity: 0;
animation: fadeIn 0.5s ease-in-out 0.5s forwards;
}
.dark .ProseMirror .is-editor-empty:first-child::before {
color: #6b7280;
}
/* 文本渐变效果 */
.text-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .text-gradient {
background: linear-gradient(135deg, #9333ea 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 标题样式 */
.ProseMirror h1 {
font-size: 2.25rem;
font-weight: 700;
line-height: 1.2;
margin-top: 2rem;
margin-bottom: 1rem;
color: #111827;
background: linear-gradient(135deg, #1f2937 0%, #4f46e5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .ProseMirror h1 {
background: linear-gradient(135deg, #f9fafb 0%, #60a5fa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.ProseMirror h2 {
font-size: 1.875rem;
font-weight: 600;
line-height: 1.3;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #1f2937;
}
.dark .ProseMirror h2 {
color: #f3f4f6;
}
.ProseMirror h3 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.4;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: #1f2937;
}
.dark .ProseMirror h3 {
color: #f3f4f6;
}
/* 段落样式 */
.ProseMirror p {
margin-top: 0;
margin-bottom: 1.25rem;
line-height: 1.75;
}
.ProseMirror p:last-child {
margin-bottom: 0;
}
/* 列表样式 */
.ProseMirror ul,
.ProseMirror ol {
margin-top: 0;
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.ProseMirror ul li,
.ProseMirror ol li {
margin-bottom: 0.5rem;
}
.ProseMirror ul li::marker {
color: #6b7280;
}
.ProseMirror ol li::marker {
color: #6b7280;
font-weight: 500;
}
/* 引用样式 */
.ProseMirror blockquote {
margin: 1.5rem 0;
padding-left: 1.5rem;
border-left: 4px solid;
border-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%) 1;
color: #6b7280;
font-style: italic;
position: relative;
}
.ProseMirror blockquote::before {
content: '';
position: absolute;
left: -4px;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.dark .ProseMirror blockquote {
color: #9ca3af;
}
/* 代码样式 */
.ProseMirror code {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
color: #e11d48;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.dark .ProseMirror code {
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
color: #fbbf24;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ProseMirror pre {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1.5rem;
margin: 1.5rem 0;
overflow-x: auto;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem;
line-height: 1.5;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.dark .ProseMirror pre {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
border-color: #4b5563;
}
.ProseMirror pre code {
background: transparent;
color: inherit;
padding: 0;
border-radius: 0;
font-size: inherit;
border: none;
}
/* 链接样式 */
.ProseMirror a {
color: #2563eb;
text-decoration: none;
border-bottom: 2px solid transparent;
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
transition: all 0.3s ease;
position: relative;
}
.ProseMirror a::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
transition: width 0.3s ease;
}
.ProseMirror a:hover::after {
width: 100%;
}
.dark .ProseMirror a {
background: linear-gradient(135deg, #60a5fa 0%, #93c5fd 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 分割线样式 */
.ProseMirror hr {
border: none;
height: 2px;
background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
margin: 2rem 0;
border-radius: 1px;
}
.dark .ProseMirror hr {
background: linear-gradient(135deg, #4b5563 0%, #6b7280 100%);
}
/* 表格样式 */
.ProseMirror table {
border-collapse: collapse;
margin: 1.5rem 0;
width: 100%;
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.ProseMirror table td,
.ProseMirror table th {
border: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.ProseMirror table th {
background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
font-weight: 600;
}
.dark .ProseMirror table {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
}
.dark .ProseMirror table td,
.dark .ProseMirror table th {
border-color: #4b5563;
}
.dark .ProseMirror table th {
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
}
/* 任务列表样式 */
.ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.ProseMirror ul[data-type="taskList"] li input[type="checkbox"] {
margin-right: 0.5rem;
margin-top: 0.125rem;
flex-shrink: 0;
accent-color: #667eea;
}
/* 图片样式 */
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 0.75rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.ProseMirror img:hover {
transform: scale(1.02);
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.15);
}
/* 选中状态样式 */
.ProseMirror-selectednode {
outline: 2px solid #667eea;
outline-offset: 2px;
border-radius: 0.25rem;
}
/* AI高亮样式 */
.ProseMirror .ai-highlight {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
animation: highlight-pulse 2s ease-in-out infinite;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.dark .ProseMirror .ai-highlight {
background: linear-gradient(135deg, #451a03 0%, #78350f 100%);
border: 1px solid rgba(251, 191, 36, 0.2);
}
@keyframes highlight-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
/* 斜杠命令菜单样式 */
.slash-command-menu {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
.dark .slash-command-menu {
background: rgba(17, 24, 39, 0.95);
border-color: rgba(255, 255, 255, 0.1);
}
/* 悬浮工具栏样式 */
.floating-toolbar {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.1);
border-radius: 0.75rem;
}
.dark .floating-toolbar {
background: rgba(17, 24, 39, 0.95);
border-color: rgba(255, 255, 255, 0.1);
}
/* 状态栏样式 */
.status-bar {
backdrop-filter: blur(20px);
background: rgba(249, 250, 251, 0.95);
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .status-bar {
background: rgba(17, 24, 39, 0.95);
border-top-color: rgba(255, 255, 255, 0.06);
}
/* 自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #d1d5db 0%, #9ca3af 100%);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #4b5563 0%, #6b7280 100%);
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);
}
/* 动画效果 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
.slide-in {
animation: slideIn 0.5s ease-out;
}
.scale-in {
animation: scaleIn 0.3s ease-out;
}
/* 响应式设计 */
@media (max-width: 768px) {
.ProseMirror {
font-size: 14px;
padding: 1rem 0;
}
.ProseMirror h1 {
font-size: 1.875rem;
}
.ProseMirror h2 {
font-size: 1.5rem;
}
.ProseMirror h3 {
font-size: 1.25rem;
}
}
/* CopilotKit自定义样式 */
.copilotKitWindow {
border-radius: 1rem !important;
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.1) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
backdrop-filter: blur(20px) !important;
}
.dark .copilotKitWindow {
border-color: rgba(255, 255, 255, 0.1) !important;
}
.copilotKitHeader {
background: rgba(249, 250, 251, 0.95) !important;
backdrop-filter: blur(20px) !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.dark .copilotKitHeader {
background: rgba(17, 24, 39, 0.95) !important;
border-bottom-color: rgba(255, 255, 255, 0.06) !important;
}
/* 高质量排版 */
.ProseMirror {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 打印样式 */
@media print {
.ProseMirror {
font-size: 12pt;
line-height: 1.5;
color: black;
}
.ProseMirror h1 {
font-size: 18pt;
}
.ProseMirror h2 {
font-size: 16pt;
}
.ProseMirror h3 {
font-size: 14pt;
}
}
command
// SPDX-License-Identifier: MIT
import {
ArrowDownWideNarrow,
CheckCheck,
RefreshCcwDot,
StepForward,
WrapText,
Sparkles,
Zap,
PenTool,
} from "lucide-react";
import { getPrevText, useEditor } from "novel";
import { CommandGroup, CommandItem, CommandSeparator } from "../../ui/command";
import { Button } from "../../ui/button";
const options = [
{
value: "improve",
label: "优化文本",
description: "改进写作质量和表达",
icon: RefreshCcwDot,
gradient: "from-emerald-500 to-teal-500",
bgGradient: "from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20",
},
{
value: "fix",
label: "语法修正",
description: "检查并修复语法错误",
icon: CheckCheck,
gradient: "from-blue-500 to-cyan-500",
bgGradient: "from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20",
},
{
value: "shorter",
label: "内容精简",
description: "保持核心意思,减少冗余",
icon: ArrowDownWideNarrow,
gradient: "from-orange-500 to-red-500",
bgGradient: "from-orange-50 to-red-50 dark:from-orange-900/20 dark:to-red-900/20",
},
{
value: "longer",
label: "内容扩写",
description: "增加细节和详细描述",
icon: WrapText,
gradient: "from-purple-500 to-pink-500",
bgGradient: "from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20",
},
{
value: "creative",
label: "创意改写",
description: "用更有创意的方式表达",
icon: Sparkles,
gradient: "from-violet-500 to-purple-500",
bgGradient: "from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20",
},
{
value: "professional",
label: "专业化",
description: "转换为更正式的表达",
icon: PenTool,
gradient: "from-gray-600 to-gray-800",
bgGradient: "from-gray-50 to-gray-100 dark:from-gray-800/20 dark:to-gray-900/20",
},
];
interface AISelectorCommandsProps {
onSelect: (value: string, option: string) => void;
}
const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => {
const { editor } = useEditor();
if (!editor) return null;
return (
<div className="space-y-3">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider px-1">
AI 功能选项
</div>
<div className="grid grid-cols-1 gap-2">
{options.map((option, index) => {
const Icon = option.icon;
return (
<Button
key={option.value}
variant="ghost"
className={`group relative overflow-hidden h-auto p-0 bg-gradient-to-r ${option.bgGradient} border border-gray-200/50 dark:border-gray-700/50 hover:border-gray-300 dark:hover:border-gray-600 rounded-xl transition-all duration-300 hover:shadow-lg hover:scale-[1.02]`}
onClick={() => onSelect(getPrevText(editor, 5000), option.value)}
>
<div className="relative w-full p-4 flex items-center space-x-3">
{/* 图标容器 */}
<div className={`w-10 h-10 bg-gradient-to-r ${option.gradient} rounded-lg flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-300 group-hover:scale-110`}>
<Icon className="w-5 h-5 text-white" />
</div>
{/* 文本内容 */}
<div className="flex-1 text-left">
<div className="font-medium text-gray-900 dark:text-white group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors">
{option.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{option.description}
</div>
</div>
{/* 箭头指示器 */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<StepForward className="w-4 h-4 text-gray-400" />
</div>
{/* 悬停效果背景 */}
<div className="absolute inset-0 bg-white/50 dark:bg-gray-800/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-xl" />
</div>
</Button>
);
})}
</div>
{/* 快捷提示 */}
<div className="mt-4 p-3 bg-gradient-to-r from-blue-50 via-purple-50 to-pink-50 dark:from-blue-900/10 dark:via-purple-900/10 dark:to-pink-900/10 rounded-xl border border-blue-200/30 dark:border-blue-700/30">
<div className="flex items-center space-x-2">
<Zap className="w-4 h-4 text-blue-500" />
<span className="text-xs text-gray-600 dark:text-gray-400">
选择一个选项或在上方输入自定义指令
</span>
</div>
</div>
</div>
);
};
export default AISelectorCommands;
ai-selector
// SPDX-License-Identifier: MIT
"use client";
import { Command, CommandInput } from "@/components/ui/command";
import { ArrowUp } from "lucide-react";
import { useEditor } from "novel";
import { addAIHighlight } from "novel";
import { useCallback, useState } from "react";
import Markdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "../../ui/button";
import Magic from "../../ui/icons/magic";
import { ScrollArea } from "../../ui/scroll-area";
import AICompletionCommands from "./ai-completion-command";
import AISelectorCommands from "./ai-selector-commands";
import { LoadingOutlined } from "@ant-design/icons";
// import { resolveServiceURL } from "~/core/api/resolve-service-url";
// import { fetchStream } from "~/core/sse";
//TODO: I think it makes more sense to create a custom Tiptap extension for this functionality https://tiptap.dev/docs/editor/ai/introduction
interface AISelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// 临时模拟函数,后续需要替换为实际实现
const fetchStream = async (url: string, options: RequestInit) => {
return [] as any;
};
const resolveServiceURL = (path: string) => {
return path;
};
function useProseCompletion() {
const [completion, setCompletion] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const complete = useCallback(
async (prompt: string, options?: { body?: Record<string, any> }) => {
setIsLoading(true);
setError(null);
try {
const response = await fetchStream(
resolveServiceURL("/api/prose/generate"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt,
...options?.body,
}),
},
);
let fullText = "";
// Process the streaming response
for await (const chunk of response) {
fullText += chunk.data;
setCompletion(fullText);
}
setIsLoading(false);
return fullText;
} catch (e) {
const error = e instanceof Error ? e : new Error("An error occurred");
setError(error);
toast.error(error.message);
setIsLoading(false);
throw error;
}
},
[],
);
const reset = useCallback(() => {
setCompletion("");
setError(null);
setIsLoading(false);
}, []);
return {
completion,
// complete,
isLoading,
error,
reset,
};
}
export function AISelector({ onOpenChange }: AISelectorProps) {
const { editor } = useEditor();
const [inputValue, setInputValue] = useState("");
const { completion, isLoading } = useProseCompletion();
if (!editor) return null;
const hasCompletion = completion.length > 0;
return (
<div className="w-[420px] bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border border-gray-200/50 dark:border-gray-700/50 rounded-2xl shadow-2xl overflow-hidden">
{/* 渐变标题栏 */}
<div className="bg-gradient-to-r from-purple-500 via-blue-500 to-cyan-500 p-[1px]">
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl rounded-t-2xl p-4">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-blue-500 rounded-lg flex items-center justify-center">
<Magic className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">AI 写作助手</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{hasCompletion ? "处理您的内容" : "选择或输入 AI 指令"}
</p>
</div>
</div>
</div>
</div>
{/* 内容区域 */}
<div className="p-6">
{hasCompletion && (
<div className="mb-6 max-h-[300px] overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl p-4 border border-blue-200/50 dark:border-blue-700/50">
<ScrollArea className="max-h-[250px]">
<div className="prose prose-sm dark:prose-invert prose-blue">
<Markdown>{completion}</Markdown>
</div>
</ScrollArea>
</div>
</div>
)}
{isLoading && (
<div className="flex items-center justify-center p-8">
<div className="relative">
<div className="w-12 h-12 bg-gradient-to-r from-purple-500 to-blue-500 rounded-full flex items-center justify-center animate-pulse">
<Magic className="w-6 h-6 text-white" />
</div>
<div className="absolute inset-0 w-12 h-12 bg-gradient-to-r from-purple-500 to-blue-500 rounded-full animate-ping opacity-30"></div>
</div>
<div className="ml-4">
<p className="font-medium text-gray-900 dark:text-white">AI 正在思考...</p>
<p className="text-sm text-gray-500 dark:text-gray-400">请稍等片刻</p>
</div>
</div>
)}
{!isLoading && (
<div className="space-y-4">
{/* 输入框 */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/20 to-blue-500/20 rounded-xl blur-sm group-focus-within:blur-none transition-all duration-300"></div>
<div className="relative bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 group-focus-within:border-purple-500/50 dark:group-focus-within:border-purple-400/50 transition-all duration-300">
<CommandInput
value={inputValue}
onValueChange={setInputValue}
autoFocus
placeholder={
hasCompletion
? "告诉 AI 下一步要做什么..."
: "请输入您的指令或选择下方选项..."
}
onFocus={() => addAIHighlight(editor)}
className="border-0 bg-transparent px-4 py-3 text-gray-900 dark:text-white placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:outline-none focus:ring-0"
/>
<Button
size="sm"
className="absolute top-2 right-2 h-8 w-8 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 border-0 shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200"
>
<ArrowUp className="h-4 w-4 text-white" />
</Button>
</div>
</div>
{/* AI 选项 */}
{hasCompletion ? (
<AICompletionCommands
onDiscard={() => {
editor.chain().unsetHighlight().focus().run();
onOpenChange(false);
}}
completion={completion}
/>
) : (
<div className="space-y-2">
<AISelectorCommands
onSelect={(value, option) => {
// complete(value, { body: { option } })
}}
/>
</div>
)}
</div>
)}
</div>
</div>
);
}
generative-menu-switch
// SPDX-License-Identifier: MIT
import { EditorBubble, removeAIHighlight, useEditor } from "novel";
import { Fragment, type ReactNode, useEffect } from "react";
import { Button } from "../../ui/button";
import Magic from "../../ui/icons/magic";
import { AISelector } from "./ai-selector";
import { useReplay } from "@/app/core/replay";
import { TooltipContent, TooltipTrigger, Tooltip } from "@/components/ui/tooltip";
import { Sparkles, Wand2, Stars } from "lucide-react";
interface GenerativeMenuSwitchProps {
children: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const GenerativeMenuSwitch = ({
children,
open,
onOpenChange,
}: GenerativeMenuSwitchProps) => {
const { editor } = useEditor();
const { isReplay } = useReplay();
useEffect(() => {
if (!open && editor) removeAIHighlight(editor);
}, [open]);
if (!editor) return null;
return (
<EditorBubble
tippyOptions={{
placement: open ? "bottom-start" : "top",
onHidden: () => {
onOpenChange(false);
editor.chain().unsetHighlight().run();
},
}}
className="border-none bg-transparent shadow-none"
>
{open && <AISelector open={open} onOpenChange={onOpenChange} />}
{!open && (
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border border-gray-200/50 dark:border-gray-700/50 rounded-2xl shadow-2xl overflow-hidden">
{/* 渐变边框效果 */}
<div className="bg-gradient-to-r from-purple-500 via-blue-500 to-cyan-500 p-[1px] rounded-2xl">
<div className="bg-white dark:bg-gray-900 rounded-2xl p-1">
<div className="flex items-center">
{isReplay ? (
<Tooltip>
<TooltipTrigger>
<Button
className="relative overflow-hidden bg-gradient-to-r from-gray-400 to-gray-500 text-white border-0 rounded-xl px-4 py-2 shadow-lg cursor-not-allowed"
variant="ghost"
size="sm"
disabled
>
<div className="flex items-center space-x-2">
<div className="w-5 h-5 bg-white/20 rounded-lg flex items-center justify-center">
<Magic className="h-3 w-3" />
</div>
<span className="font-medium">Ask AI</span>
</div>
</Button>
</TooltipTrigger>
<TooltipContent>回放模式下无法使用AI功能</TooltipContent>
</Tooltip>
) : (
<Button
className="relative overflow-hidden bg-gradient-to-r from-purple-500 via-blue-500 to-cyan-500 hover:from-purple-600 hover:via-blue-600 hover:to-cyan-600 text-white border-0 rounded-xl px-4 py-2 shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300 group"
variant="ghost"
onClick={() => onOpenChange(true)}
size="sm"
>
{/* 背景动画效果 */}
<div className="absolute inset-0 bg-gradient-to-r from-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative flex items-center space-x-2">
{/* 图标容器 */}
<div className="w-5 h-5 bg-white/20 rounded-lg flex items-center justify-center group-hover:bg-white/30 transition-colors duration-300">
<Magic className="h-3 w-3 group-hover:scale-110 transition-transform duration-300" />
</div>
<span className="font-medium">Ask AI</span>
{/* 闪烁星星效果 */}
<div className="absolute -top-1 -right-1 opacity-60 group-hover:opacity-100 transition-opacity duration-300">
<Sparkles className="w-3 h-3 animate-pulse" />
</div>
</div>
{/* 光泽效果 */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700 ease-in-out" />
</Button>
)}
{/* 分隔线 */}
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-2" />
{/* 其他工具 */}
<div className="flex items-center space-x-1">
{children}
</div>
</div>
</div>
</div>
</div>
)}
</EditorBubble>
);
};
export default GenerativeMenuSwitch;