297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { Send, Loader2, Copy, RefreshCw, Trash2, Plus, MessageSquare, X, Sparkles } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { cn } from "@/lib/utils"
|
|
import { useAISettings } from "./ai-settings-provider"
|
|
import { Markdown } from "./markdown"
|
|
import { requestOpenAICompatible } from "@/lib/ai/client"
|
|
import { validateForRequest } from "@/lib/ai/settings"
|
|
import { buildChatSystemPrompt } from "@/lib/ai/prompts"
|
|
import {
|
|
addMessage,
|
|
clearMessages,
|
|
createConversation,
|
|
deleteLastAssistantMessage,
|
|
fetchMessages,
|
|
getLatestConversation,
|
|
type AIConversation,
|
|
} from "@/lib/ai/conversations"
|
|
import type { ChatMessage } from "@/lib/ai/types"
|
|
import type { KnowledgeItem } from "@/lib/types"
|
|
|
|
type UIMessage = { role: "user" | "assistant"; content: string }
|
|
|
|
export function AIChatPanel({ item }: { item: KnowledgeItem }) {
|
|
const { settings } = useAISettings()
|
|
const [conversation, setConversation] = useState<AIConversation | null>(null)
|
|
const [messages, setMessages] = useState<UIMessage[]>([])
|
|
const [input, setInput] = useState("")
|
|
const [loading, setLoading] = useState(false)
|
|
const [booting, setBooting] = useState(true)
|
|
const controllerRef = useRef<AbortController | null>(null)
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
|
|
// load latest conversation + messages on mount / item change
|
|
useEffect(() => {
|
|
let active = true
|
|
setBooting(true)
|
|
setMessages([])
|
|
setConversation(null)
|
|
;(async () => {
|
|
try {
|
|
const conv = await getLatestConversation(item.id)
|
|
if (!active) return
|
|
if (conv) {
|
|
setConversation(conv)
|
|
const rows = await fetchMessages(conv.id)
|
|
if (!active) return
|
|
setMessages(rows.filter((r) => r.role !== "system").map((r) => ({ role: r.role as "user" | "assistant", content: r.content })))
|
|
}
|
|
} catch (e) {
|
|
console.log("[v0] load conversation error:", e)
|
|
} finally {
|
|
if (active) setBooting(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
active = false
|
|
controllerRef.current?.abort()
|
|
}
|
|
}, [item.id])
|
|
|
|
useEffect(() => {
|
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
|
}, [messages, loading])
|
|
|
|
async function ensureConversation(): Promise<AIConversation> {
|
|
if (conversation) return conversation
|
|
const conv = await createConversation(item.id, item.title)
|
|
setConversation(conv)
|
|
return conv
|
|
}
|
|
|
|
async function runCompletion(history: UIMessage[]): Promise<string> {
|
|
const chatMessages: ChatMessage[] = history.map((m) => ({ role: m.role, content: m.content }))
|
|
const ac = new AbortController()
|
|
controllerRef.current = ac
|
|
return requestOpenAICompatible({
|
|
baseUrl: settings.baseUrl,
|
|
apiKey: settings.apiKey,
|
|
model: settings.model,
|
|
systemPrompt: buildChatSystemPrompt(item),
|
|
messages: chatMessages,
|
|
temperature: settings.temperature,
|
|
maxTokens: settings.maxTokens,
|
|
signal: ac.signal,
|
|
})
|
|
}
|
|
|
|
async function handleSend() {
|
|
const err = validateForRequest(settings)
|
|
if (err) {
|
|
toast.error(err)
|
|
return
|
|
}
|
|
const text = input.trim()
|
|
if (!text || loading) return
|
|
|
|
const userMsg: UIMessage = { role: "user", content: text }
|
|
const next = [...messages, userMsg]
|
|
setMessages(next)
|
|
setInput("")
|
|
setLoading(true)
|
|
try {
|
|
const conv = await ensureConversation()
|
|
await addMessage(conv.id, "user", text)
|
|
const reply = await runCompletion(next)
|
|
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
|
|
await addMessage(conv.id, "assistant", reply)
|
|
} catch (e) {
|
|
if ((e as Error).name === "AbortError") {
|
|
toast.message("已取消回复")
|
|
setMessages((prev) => prev.slice(0, -1)) // remove the user msg we optimistically added? keep it instead
|
|
return
|
|
}
|
|
toast.error((e as Error).message)
|
|
setMessages((prev) => [...prev, { role: "assistant", content: `[出错] ${(e as Error).message}` }])
|
|
} finally {
|
|
setLoading(false)
|
|
controllerRef.current = null
|
|
}
|
|
}
|
|
|
|
async function handleRegenerate() {
|
|
if (loading) return
|
|
// find last user message
|
|
const lastUserIdx = [...messages].reverse().findIndex((m) => m.role === "user")
|
|
if (lastUserIdx === -1) return
|
|
const historyEnd = messages.length - lastUserIdx // index just after last user message
|
|
const history = messages.slice(0, historyEnd)
|
|
setMessages(history)
|
|
setLoading(true)
|
|
try {
|
|
const conv = await ensureConversation()
|
|
await deleteLastAssistantMessage(conv.id)
|
|
const reply = await runCompletion(history)
|
|
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
|
|
await addMessage(conv.id, "assistant", reply)
|
|
} catch (e) {
|
|
if ((e as Error).name === "AbortError") return
|
|
toast.error((e as Error).message)
|
|
} finally {
|
|
setLoading(false)
|
|
controllerRef.current = null
|
|
}
|
|
}
|
|
|
|
async function handleNewConversation() {
|
|
controllerRef.current?.abort()
|
|
try {
|
|
const conv = await createConversation(item.id, item.title)
|
|
setConversation(conv)
|
|
setMessages([])
|
|
toast.success("已新建会话")
|
|
} catch (e) {
|
|
toast.error("新建会话失败")
|
|
console.log("[v0] new conversation error:", e)
|
|
}
|
|
}
|
|
|
|
async function handleClear() {
|
|
if (!conversation) {
|
|
setMessages([])
|
|
return
|
|
}
|
|
try {
|
|
await clearMessages(conversation.id)
|
|
setMessages([])
|
|
toast.success("已清空当前会话")
|
|
} catch (e) {
|
|
toast.error("清空失败")
|
|
console.log("[v0] clear error:", e)
|
|
}
|
|
}
|
|
|
|
function copyMessage(content: string) {
|
|
navigator.clipboard.writeText(content)
|
|
toast.success("已复制")
|
|
}
|
|
|
|
const hasAssistant = messages.some((m) => m.role === "assistant")
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/40 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="flex items-center gap-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">
|
|
<Sparkles className="size-3.5 text-primary" />
|
|
AI 追问
|
|
</h4>
|
|
<div className="flex gap-1">
|
|
<Button variant="ghost" size="sm" onClick={handleNewConversation} className="h-7 gap-1 px-2 text-xs">
|
|
<Plus className="size-3.5" />
|
|
新建
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClear}
|
|
disabled={messages.length === 0}
|
|
className="h-7 gap-1 px-2 text-xs"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
清空
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={scrollRef} className="flex max-h-72 flex-col gap-3 overflow-y-auto">
|
|
{booting ? (
|
|
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
加载会话...
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center text-sm text-muted-foreground">
|
|
<MessageSquare className="size-6" />
|
|
针对「{item.title}」向 AI 提问,开始复习对话
|
|
</div>
|
|
) : (
|
|
messages.map((m, i) => (
|
|
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
|
|
<div
|
|
className={cn(
|
|
"group relative max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
|
|
m.role === "user"
|
|
? "bg-primary/15 text-foreground"
|
|
: "border border-border bg-secondary/50 text-foreground",
|
|
)}
|
|
>
|
|
{m.role === "assistant" ? (
|
|
<Markdown content={m.content} />
|
|
) : (
|
|
<p className="whitespace-pre-wrap break-words">{m.content}</p>
|
|
)}
|
|
{m.role === "assistant" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => copyMessage(m.content)}
|
|
aria-label="复制回复"
|
|
className="absolute -right-2 -top-2 hidden rounded-md border border-border bg-background p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
|
>
|
|
<Copy className="size-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
{loading && (
|
|
<div className="flex justify-start">
|
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/50 px-3 py-2 text-sm text-muted-foreground">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
AI 正在思考...
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hasAssistant && !loading && (
|
|
<div>
|
|
<Button variant="ghost" size="sm" onClick={handleRegenerate} className="h-7 gap-1 px-2 text-xs">
|
|
<RefreshCw className="size-3.5" />
|
|
重新生成
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-2">
|
|
<Textarea
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}}
|
|
placeholder="输入你的问题... (Ctrl/Cmd + Enter 发送)"
|
|
className="min-h-10 flex-1 bg-input/60 text-sm"
|
|
/>
|
|
{loading ? (
|
|
<Button variant="outline" size="icon" onClick={() => controllerRef.current?.abort()} aria-label="取消">
|
|
<X className="size-4" />
|
|
</Button>
|
|
) : (
|
|
<Button size="icon" onClick={handleSend} disabled={!input.trim()} aria-label="发送">
|
|
<Send className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|