273 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|