首次发布git
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { AIAssist } from "./ai-assist";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Toolbar } from "./editor/toolbar";
|
||||
import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./editor/slash-command";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { lowlight } from 'lowlight';
|
||||
import { CodeBlockComponent } from "./editor/code-block";
|
||||
import Link from "@tiptap/extension-link";
|
||||
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Subscript from "@tiptap/extension-subscript";
|
||||
import Superscript from "@tiptap/extension-superscript";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import { Callout } from "./editor/extensions/callout";
|
||||
import { AIMark } from "./editor/extensions/ai-mark";
|
||||
import { TaskItemComponent } from "./editor/extensions/task-item";
|
||||
import { useSettingsStore } from "@/lib/settings-store";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Youtube from "@tiptap/extension-youtube";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
|
||||
interface EditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
onEditorReady?: (editor: any) => void;
|
||||
onToggleAI?: () => void;
|
||||
onExport?: () => void;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, editable = true }: EditorProps) {
|
||||
const [showAI, setShowAI] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const { fontFamily, fontSize } = useSettingsStore();
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Gapcursor,
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
codeBlock: false,
|
||||
bulletList: {
|
||||
keepMarks: true,
|
||||
keepAttributes: false,
|
||||
},
|
||||
orderedList: {
|
||||
keepMarks: true,
|
||||
keepAttributes: false,
|
||||
},
|
||||
}),
|
||||
SlashCommand.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
render: renderSuggestionItems,
|
||||
},
|
||||
}),
|
||||
CodeBlockLowlight
|
||||
.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockComponent)
|
||||
}
|
||||
})
|
||||
.configure({ lowlight, defaultLanguage: 'plaintext' }),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors',
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Subscript,
|
||||
Superscript,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TaskItemComponent)
|
||||
}
|
||||
}),
|
||||
Callout,
|
||||
AIMark,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Image.configure({
|
||||
inline: true,
|
||||
allowBase64: true,
|
||||
}),
|
||||
Youtube.configure({
|
||||
controls: false,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true, // Allow HTML input/output
|
||||
transformPastedText: true, // Auto-transform pasted markdown
|
||||
transformCopiedText: true, // Auto-transform copied markdown
|
||||
})
|
||||
],
|
||||
content: content,
|
||||
onUpdate: ({ editor }) => {
|
||||
if (editor.getHTML() !== content) {
|
||||
onChange(editor.getHTML());
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-zinc dark:prose-invert max-w-none focus:outline-none min-h-[300px]",
|
||||
style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${useSettingsStore.getState().lineHeight}; --table-line-height: ${useSettingsStore.getState().tableLineHeight};`,
|
||||
spellcheck: "false",
|
||||
},
|
||||
},
|
||||
editable: editable,
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(editable);
|
||||
}
|
||||
}, [editor, editable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
queueMicrotask(() => {
|
||||
editor.commands.setContent(content);
|
||||
});
|
||||
}
|
||||
if (editor && onEditorReady) {
|
||||
onEditorReady(editor);
|
||||
}
|
||||
}, [content, editor, onEditorReady]);
|
||||
|
||||
const handleStopAI = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
const handleAISuggest = async (prompt: any) => {
|
||||
if (!editor) return;
|
||||
|
||||
const { aiConfig } = useSettingsStore.getState();
|
||||
if (!aiConfig.apiKey) {
|
||||
alert("请先在设置中配置 AI API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to } = editor.state.selection;
|
||||
const selectedText = editor.state.doc.textBetween(from, to, " ");
|
||||
|
||||
// Custom prompt logic
|
||||
const systemPrompt = prompt.systemPrompt || "You are a helpful assistant.";
|
||||
let userPrompt = selectedText;
|
||||
|
||||
if (!userPrompt) {
|
||||
const pos = from;
|
||||
userPrompt = editor.state.doc.textBetween(Math.max(0, pos - 1000), pos, "\n");
|
||||
}
|
||||
|
||||
setShowAI(false);
|
||||
setIsGenerating(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: aiConfig,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt }
|
||||
]
|
||||
}),
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
editor.chain().focus().insertContent("\n\n").toggleMark('aiMark').run();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const content = data.choices[0]?.delta?.content;
|
||||
if (content) {
|
||||
editor.commands.insertContent(content);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.chain().focus().insertContent("\n\n").unsetMark('aiMark').run();
|
||||
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
editor.chain().focus().insertContent(" [已停止]").unsetMark('aiMark').run();
|
||||
} else {
|
||||
console.error("AI Error", e);
|
||||
alert("AI 请求失败,请检查配置或网络");
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="relative group/editor flex flex-col min-h-full">
|
||||
<Toolbar editor={editor} onToggleAI={onToggleAI} onExport={onExport} />
|
||||
|
||||
{showAI && (
|
||||
<div className="absolute top-12 right-4 z-50">
|
||||
<AIAssist
|
||||
isOpen={showAI}
|
||||
onSuggest={handleAISuggest}
|
||||
onClose={() => setShowAI(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 mt-4">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isGenerating) {
|
||||
handleStopAI();
|
||||
} else {
|
||||
setShowAI(!showAI);
|
||||
}
|
||||
}}
|
||||
className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${isGenerating
|
||||
? "bg-red-500 text-white animate-pulse"
|
||||
: "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
title={isGenerating ? "停止生成 (Stop)" : "AI 助手 (AI Assist)"}
|
||||
>
|
||||
{isGenerating ? <div className="h-5 w-5 bg-current rounded-sm" /> : <Sparkles size={20} />}
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user