Files
NoteAI/src/components/chat/ai-chat-panel.tsx
T
2026-02-24 11:33:08 +08:00

273 lines
12 KiB
TypeScript

"use client";
import { useState, useRef, useEffect } from "react";
import { Send, User, Bot, Copy, FileText, X, Sparkles, Eraser } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/lib/settings-store";
import { Editor } from "@tiptap/react";
import { marked } from "marked";
import { sanitizeHtml } from "@/lib/sanitize-html";
interface Message {
id: string;
role: "user" | "assistant" | "system";
content: string;
}
interface AIChatPanelProps {
editor: Editor | null;
isOpen: boolean;
onClose: () => void;
}
export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
const { aiConfig } = useSettingsStore();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [useContext, setUseContext] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [tokenCount, setTokenCount] = useState(0);
// ... (rest of the code)
// Simple token estimation: ~4 chars per token for English, ~1 char per token for Chinese
useEffect(() => {
const text = messages.map(m => m.content).join("") + input;
setTokenCount(Math.ceil(text.length * 0.7));
}, [messages, input]);
// Scroll to bottom effect
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsLoading(false);
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
if (!aiConfig.apiKey) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Error: No API Key configured. Please go to Settings > AI Config." }]);
return;
}
const userMsg: Message = { id: Date.now().toString(), role: "user", content: input };
setMessages(prev => [...prev, userMsg]);
setInput("");
setIsLoading(true);
abortControllerRef.current = new AbortController();
const contextMsg: Message | null = (useContext && editor)
? { id: "context", role: "system", content: `Current Document Context:\n${editor.getText().slice(0, 4000)}...` }
: null;
const apiMessages = [
...(contextMsg ? [contextMsg] : []),
...messages.filter(m => m.role !== "system"),
userMsg
];
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: apiMessages.map(m => ({ role: m.role, content: m.content })),
config: aiConfig
}),
signal: abortControllerRef.current.signal
});
if (!res.ok) throw new Error(res.statusText);
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
const assistantMsg: Message = { id: (Date.now() + 1).toString(), role: "assistant", content: "" };
setMessages(prev => [...prev, assistantMsg]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content || "";
if (content) {
assistantMsg.content += content;
setMessages(prev => prev.map(m => m.id === assistantMsg.id ? { ...assistantMsg } : m));
}
} catch (e) {
console.error("Parse error", e);
}
}
}
}
} catch (e: unknown) {
const isAbortError = e instanceof Error && e.name === "AbortError";
if (isAbortError) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Genertion stopped by user." }]);
} else {
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: `Error: ${e instanceof Error ? e.message : "Unknown error"}` }]);
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
const handleInsert = (content: string) => {
if (editor) {
editor.commands.insertContent(content);
}
};
if (!isOpen) return null;
return (
<div className="fixed right-0 top-0 bottom-0 w-96 bg-background border-l shadow-xl z-50 flex flex-col animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Sparkles className="text-primary w-5 h-5" />
<div>
<h2 className="font-semibold text-lg leading-none">AI 助手</h2>
<p className="text-[10px] text-muted-foreground mt-0.5 font-mono opacity-80">
{aiConfig.model || "gpt-3.5-turbo"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground mr-2">Est. Tokens: {tokenCount}</span>
<button onClick={onClose} className="p-1 hover:bg-muted rounded-md transition-colors">
<X size={18} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-20">
<p>有什么我可以帮你的吗?</p>
<p className="text-sm mt-2">我可以协助写作、润色、摘要或回答问题。</p>
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className={cn("flex gap-3", msg.role === "user" ? "flex-row-reverse" : "")}>
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}>
{msg.role === "user" ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={cn(
"group relative max-w-[85%] rounded-lg p-3 text-sm",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
msg.role === "system" && "bg-destructive/10 text-destructive w-full max-w-full"
)}>
{/* Markdown Rendering for Assistant */}
{msg.role === "assistant" ? (
<div
className="prose dark:prose-invert prose-sm max-w-none break-words [&>p]:mb-2 [&>ul]:list-disc [&>ul]:pl-4 [&>ol]:list-decimal [&>ol]:pl-4"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(marked.parse(msg.content) as string) }}
/>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
{/* Assistant Actions */}
{msg.role === "assistant" && !isLoading && (
<div className="absolute -bottom-6 left-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<button
onClick={() => handleInsert(msg.content)}
className="p-1 text-xs bg-background border rounded shadow hover:bg-muted flex items-center gap-1"
title="插入到光标位置"
>
<FileText size={12} /> 插入
</button>
<button
onClick={() => navigator.clipboard.writeText(msg.content)}
className="p-1 text-xs bg-background border rounded shadow hover:bg-muted"
title="复制"
>
<Copy size={12} />
</button>
</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 border-t bg-muted/20">
<div className="flex items-center justify-between mb-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={useContext}
onChange={(e) => setUseContext(e.target.checked)}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
关联上下文
</label>
<button
onClick={() => setMessages([])}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
title="清空对话"
>
<Eraser size={12} /> 清空
</button>
</div>
<div className="relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="输入消息..."
className="w-full resize-none rounded-md border bg-background p-3 pr-10 text-sm focus:outline-none focus:ring-1 focus:ring-primary min-h-[80px]"
/>
<button
onClick={isLoading ? handleStop : handleSend}
disabled={!input.trim() && !isLoading}
className={cn(
"absolute right-2 bottom-2 p-2 rounded-md hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all",
isLoading ? "bg-red-500 text-white" : "bg-primary text-primary-foreground"
)}
title={isLoading ? "停止生成" : "发送"}
>
{isLoading ? <div className="h-4 w-4 bg-current rounded-sm animate-pulse" /> : <Send size={16} />}
</button>
</div>
</div>
</div>
);
}