Files
Next_Knowledge_Base/components/ai/ai-chat-panel.tsx
T
jiawei 73b9c9fb7a
knowledge-base / deploy (push) Failing after 11s
feat: 初版
2026-06-23 19:09:11 +08:00

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>
)
}