@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Loader2, Sparkles, X, Database, AlertTriangle } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAISettings } from "./ai-settings-provider"
|
||||
import { PromptEditor } from "./prompt-editor"
|
||||
import { AIResultPreview, type DraftRow } from "./ai-result-preview"
|
||||
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||
import { validateForRequest } from "@/lib/ai/settings"
|
||||
import { BATCH_IMPORT_PROMPT } from "@/lib/ai/prompts"
|
||||
import { safeParseAIJson } from "@/lib/ai/json"
|
||||
import type { KnowledgeItem, KnowledgeItemInput } from "@/lib/types"
|
||||
|
||||
export function AIBatchImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onInserted,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onInserted: (items: KnowledgeItem[]) => void
|
||||
}) {
|
||||
const { settings } = useAISettings()
|
||||
const [rawInput, setRawInput] = useState("")
|
||||
const [customPrompt, setCustomPrompt] = useState(BATCH_IMPORT_PROMPT)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [inserting, setInserting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [rawResponse, setRawResponse] = useState<string | null>(null)
|
||||
const [rows, setRows] = useState<DraftRow[]>([])
|
||||
const [controller, setController] = useState<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setError(null)
|
||||
setRawResponse(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectedCount = useMemo(() => rows.filter((r) => r._selected).length, [rows])
|
||||
|
||||
async function handleGenerate() {
|
||||
const err = validateForRequest(settings)
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
if (!rawInput.trim()) {
|
||||
toast.error("请先粘贴题目内容")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setRawResponse(null)
|
||||
setRows([])
|
||||
const ac = new AbortController()
|
||||
setController(ac)
|
||||
try {
|
||||
const content = await requestOpenAICompatible({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
model: settings.model,
|
||||
systemPrompt: settings.systemPrompt,
|
||||
customPrompt,
|
||||
messages: [{ role: "user", content: rawInput }],
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
signal: ac.signal,
|
||||
})
|
||||
const parsed = safeParseAIJson(content)
|
||||
if (!parsed.ok) {
|
||||
setError(parsed.error)
|
||||
setRawResponse(parsed.raw)
|
||||
return
|
||||
}
|
||||
setRows(
|
||||
parsed.items.map((it, i) => ({
|
||||
...it,
|
||||
_id: `${Date.now()}-${i}`,
|
||||
_selected: true,
|
||||
})),
|
||||
)
|
||||
toast.success(`已生成 ${parsed.items.length} 条`)
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") {
|
||||
toast.message("已取消生成")
|
||||
return
|
||||
}
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setController(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
controller?.abort()
|
||||
}
|
||||
|
||||
async function handleInsert() {
|
||||
const selected = rows.filter((r) => r._selected)
|
||||
if (selected.length === 0) {
|
||||
toast.error("请至少勾选一条")
|
||||
return
|
||||
}
|
||||
setInserting(true)
|
||||
try {
|
||||
const { createItems } = await import("@/lib/knowledge")
|
||||
const inputs: KnowledgeItemInput[] = selected.map((r) => ({
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
content: r.content,
|
||||
code_snippet: r.code_snippet,
|
||||
tags: r.tags,
|
||||
category: r.category,
|
||||
difficulty: r.difficulty,
|
||||
mastery: r.mastery,
|
||||
is_favorite: false,
|
||||
source_url: r.source_url,
|
||||
notes: r.notes,
|
||||
}))
|
||||
const created = await createItems(inputs)
|
||||
onInserted(created)
|
||||
toast.success(`成功插入 ${created.length} 条知识点`)
|
||||
// remove inserted rows from preview
|
||||
setRows((prev) => prev.filter((r) => !r._selected))
|
||||
} catch (e) {
|
||||
toast.error(`插入失败:${(e as Error).message}`)
|
||||
console.log("[v0] batch insert error:", e)
|
||||
} finally {
|
||||
setInserting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (loading ? null : onOpenChange(o))}>
|
||||
<DialogContent className="glass-panel flex max-h-[92vh] flex-col gap-0 overflow-hidden sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-primary" />
|
||||
AI 批量整理
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
粘贴多个题目 / 面试题 / 知识点,AI 会整理成结构化数据。插入前可预览、编辑并勾选。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
<span className="text-primary">model</span> {settings.model || "—"}
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<span className="text-primary">base</span>
|
||||
<span className="truncate">{settings.baseUrl || "—"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rawInput">题目内容</Label>
|
||||
<Textarea
|
||||
id="rawInput"
|
||||
value={rawInput}
|
||||
onChange={(e) => setRawInput(e.target.value)}
|
||||
placeholder={"1. 什么是闭包?\n2. Vue2 和 Vue3 的区别?\n3. Promise.all 和 Promise.race 的区别?\n4. 实现防抖函数"}
|
||||
className="min-h-28 bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PromptEditor value={customPrompt} onChange={setCustomPrompt} rows={5} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
{loading ? (
|
||||
<Button variant="outline" onClick={handleCancel} className="gap-1.5">
|
||||
<X className="size-4" />
|
||||
取消生成
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleGenerate} disabled={!rawInput.trim()} className="ai-gradient gap-1.5">
|
||||
<Sparkles className="size-4" />
|
||||
生成
|
||||
</Button>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
AI 整理中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="neon-error grid gap-2 rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="size-4" />
|
||||
{error}
|
||||
</div>
|
||||
{rawResponse && (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs opacity-80">原始返回内容(可复制修复):</span>
|
||||
<Textarea readOnly value={rawResponse} className="min-h-24 bg-background/40 font-mono text-xs" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && <AIResultPreview rows={rows} onChange={setRows} />}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<p className="font-mono text-xs text-muted-foreground">所有内容确认后才会写入数据库</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading || inserting}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInsert}
|
||||
disabled={selectedCount === 0 || inserting}
|
||||
className={cn("gap-1.5")}
|
||||
>
|
||||
{inserting ? <Loader2 className="size-4 animate-spin" /> : <Database className="size-4" />}
|
||||
插入选中 ({selectedCount})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight, Copy, Download } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DIFFICULTIES, DIFFICULTY_META } from "@/lib/types"
|
||||
import { AI_CATEGORY_ENUM, type AIDraftItem } from "@/lib/ai/types"
|
||||
|
||||
export type DraftRow = AIDraftItem & { _id: string; _selected: boolean }
|
||||
|
||||
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
|
||||
const CATEGORY_OPTIONS = AI_CATEGORY_ENUM.map((c) => ({ value: c, label: c }))
|
||||
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
|
||||
|
||||
export function AIResultPreview({
|
||||
rows,
|
||||
onChange,
|
||||
}: {
|
||||
rows: DraftRow[]
|
||||
onChange: (rows: DraftRow[]) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
function patch(id: string, patch: Partial<DraftRow>) {
|
||||
onChange(rows.map((r) => (r._id === id ? { ...r, ...patch } : r)))
|
||||
}
|
||||
|
||||
function toggleAll(value: boolean) {
|
||||
onChange(rows.map((r) => ({ ...r, _selected: value })))
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
const items = rows.map(({ _id, _selected, ...rest }) => rest)
|
||||
navigator.clipboard.writeText(JSON.stringify({ items }, null, 2))
|
||||
toast.success("已复制 JSON")
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
const items = rows.map(({ _id, _selected, ...rest }) => rest)
|
||||
const blob = new Blob([JSON.stringify({ items }, null, 2)], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `knowledge-items-${Date.now()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const allSelected = rows.length > 0 && rows.every((r) => r._selected)
|
||||
const selectedCount = rows.filter((r) => r._selected).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} />
|
||||
全选 · 已选 {selectedCount}/{rows.length}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={copyJson} className="gap-1.5">
|
||||
<Copy className="size-3.5" />
|
||||
复制 JSON
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportJson} className="gap-1.5">
|
||||
<Download className="size-3.5" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row) => {
|
||||
const open = expanded[row._id]
|
||||
return (
|
||||
<div
|
||||
key={row._id}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card/60 transition-colors",
|
||||
row._selected ? "border-primary/40" : "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Checkbox checked={row._selected} onCheckedChange={(v) => patch(row._id, { _selected: !!v })} />
|
||||
<Input
|
||||
value={row.title}
|
||||
onChange={(e) => patch(row._id, { title: e.target.value })}
|
||||
className="h-8 flex-1 bg-input/60 text-sm font-medium"
|
||||
/>
|
||||
<Badge variant="outline" className="hidden font-mono text-[10px] sm:inline-flex">
|
||||
{row.category}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => ({ ...e, [row._id]: !e[row._id] }))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={open ? "收起" : "展开"}
|
||||
>
|
||||
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="grid gap-3 border-t border-border p-3">
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">摘要</span>
|
||||
<Input
|
||||
value={row.summary}
|
||||
onChange={(e) => patch(row._id, { summary: e.target.value })}
|
||||
className="bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">正文</span>
|
||||
<Textarea
|
||||
value={row.content}
|
||||
onChange={(e) => patch(row._id, { content: e.target.value })}
|
||||
className="min-h-20 bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{row.code_snippet && (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">代码片段</span>
|
||||
<Textarea
|
||||
value={row.code_snippet}
|
||||
onChange={(e) => patch(row._id, { code_snippet: e.target.value })}
|
||||
className="min-h-20 bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">分类</span>
|
||||
<Select
|
||||
value={row.category}
|
||||
onValueChange={(v) => patch(row._id, { category: v })}
|
||||
items={CATEGORY_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-input/60 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">难度</span>
|
||||
<Select
|
||||
value={row.difficulty}
|
||||
onValueChange={(v) => patch(row._id, { difficulty: v as AIDraftItem["difficulty"] })}
|
||||
items={DIFFICULTY_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-input/60 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{row.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{row.tags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="font-mono text-xs">
|
||||
#{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Loader2, RotateCcw, Trash2, Zap, CheckCircle2, XCircle } from "lucide-react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAISettings } from "./ai-settings-provider"
|
||||
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
|
||||
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||
import { validateForRequest } from "@/lib/ai/settings"
|
||||
|
||||
type TestState = { status: "idle" | "loading" | "ok" | "error"; message: string }
|
||||
|
||||
export function AISettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const { settings, setSettings } = useAISettings()
|
||||
const [draft, setDraft] = useState<AISettings>(settings)
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [test, setTest] = useState<TestState>({ status: "idle", message: "" })
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(settings)
|
||||
setTest({ status: "idle", message: "" })
|
||||
}
|
||||
}, [open, settings])
|
||||
|
||||
function update<K extends keyof AISettings>(key: K, value: AISettings[K]) {
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSettings(draft)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setDraft({ ...DEFAULT_AI_SETTINGS })
|
||||
setTest({ status: "idle", message: "" })
|
||||
}
|
||||
|
||||
function handleClearKey() {
|
||||
update("apiKey", "")
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
const err = validateForRequest(draft)
|
||||
if (err) {
|
||||
setTest({ status: "error", message: err })
|
||||
return
|
||||
}
|
||||
setTest({ status: "loading", message: "正在测试连接..." })
|
||||
try {
|
||||
const reply = await requestOpenAICompatible({
|
||||
baseUrl: draft.baseUrl,
|
||||
apiKey: draft.apiKey,
|
||||
model: draft.model,
|
||||
messages: [{ role: "user", content: "ping,请只回复 ok" }],
|
||||
temperature: 0,
|
||||
maxTokens: 16,
|
||||
})
|
||||
setTest({ status: "ok", message: `连接成功 · 模型回复:${reply.slice(0, 40)}` })
|
||||
} catch (e) {
|
||||
setTest({ status: "error", message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="glass-panel flex max-h-[90vh] flex-col gap-0 overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="size-5 text-primary" />
|
||||
AI 设置
|
||||
</DialogTitle>
|
||||
<DialogDescription>配置 OpenAI-compatible 接口。API Key 仅保存在本地浏览器,不会写入数据库。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
value={draft.baseUrl}
|
||||
onChange={(e) => update("baseUrl", e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="apiKey">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showKey ? "text" : "password"}
|
||||
value={draft.apiKey}
|
||||
onChange={(e) => update("apiKey", e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="bg-input/60 pr-10 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? "隐藏" : "显示"}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={handleClearKey} className="gap-1.5">
|
||||
<Trash2 className="size-4" />
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Input
|
||||
id="model"
|
||||
value={draft.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
placeholder="gpt-4o-mini / deepseek-chat / qwen-plus"
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="temp">Temperature</Label>
|
||||
<Input
|
||||
id="temp"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={draft.temperature}
|
||||
onChange={(e) => update("temperature", Number(e.target.value))}
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="maxTokens">Max Tokens</Label>
|
||||
<Input
|
||||
id="maxTokens"
|
||||
type="number"
|
||||
min="1"
|
||||
value={draft.maxTokens}
|
||||
onChange={(e) => update("maxTokens", Number(e.target.value))}
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="systemPrompt">System Prompt</Label>
|
||||
<Textarea
|
||||
id="systemPrompt"
|
||||
value={draft.systemPrompt}
|
||||
onChange={(e) => update("systemPrompt", e.target.value)}
|
||||
className="min-h-24 bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor="stream" className="cursor-pointer">
|
||||
启用流式输出 (stream)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">部分接口需要关闭以兼容</p>
|
||||
</div>
|
||||
<Switch id="stream" checked={draft.stream} onCheckedChange={(v) => update("stream", v)} />
|
||||
</div>
|
||||
|
||||
{test.status !== "idle" && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||
test.status === "ok" && "neon-success",
|
||||
test.status === "error" && "neon-error",
|
||||
test.status === "loading" && "border-border bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{test.status === "loading" && <Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />}
|
||||
{test.status === "ok" && <CheckCircle2 className="mt-0.5 size-4 shrink-0" />}
|
||||
{test.status === "error" && <XCircle className="mt-0.5 size-4 shrink-0" />}
|
||||
<span className="break-words">{test.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
||||
<RotateCcw className="size-4" />
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleTest} disabled={test.status === "loading"} className="gap-1.5">
|
||||
{test.status === "loading" ? <Loader2 className="size-4 animate-spin" /> : <Zap className="size-4" />}
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSave}>保存设置</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { loadAISettings, saveAISettings } from "@/lib/ai/settings"
|
||||
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
|
||||
|
||||
type Ctx = {
|
||||
settings: AISettings
|
||||
setSettings: (next: AISettings) => void
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
const AISettingsContext = createContext<Ctx | null>(null)
|
||||
|
||||
export function AISettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettingsState] = useState<AISettings>(DEFAULT_AI_SETTINGS)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSettingsState(loadAISettings())
|
||||
setLoaded(true)
|
||||
}, [])
|
||||
|
||||
const setSettings = useCallback((next: AISettings) => {
|
||||
setSettingsState(next)
|
||||
saveAISettings(next)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AISettingsContext.Provider value={{ settings, setSettings, loaded }}>{children}</AISettingsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAISettings() {
|
||||
const ctx = useContext(AISettingsContext)
|
||||
if (!ctx) throw new Error("useAISettings must be used within AISettingsProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* 统一的 AI Markdown 渲染组件,适配深色科技风主题。
|
||||
* 用于面试回答版与 AI 追问的回复内容。
|
||||
*/
|
||||
export function Markdown({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-relaxed break-words",
|
||||
// 段落与标题
|
||||
"[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
|
||||
"[&_h1]:mt-3 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold",
|
||||
"[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold",
|
||||
"[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold",
|
||||
// 列表
|
||||
"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
|
||||
"[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
|
||||
"[&_li]:my-0.5 [&_li]:marker:text-muted-foreground",
|
||||
// 强调
|
||||
"[&_strong]:font-semibold [&_strong]:text-foreground",
|
||||
"[&_em]:italic",
|
||||
// 链接
|
||||
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
|
||||
// 行内代码
|
||||
"[&_code]:rounded [&_code]:bg-secondary/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
|
||||
// 代码块
|
||||
"[&_pre]:my-2 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-secondary/40 [&_pre]:p-3",
|
||||
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground",
|
||||
// 引用
|
||||
"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
|
||||
// 表格
|
||||
"[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs",
|
||||
"[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
|
||||
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
|
||||
// 分隔线
|
||||
"[&_hr]:my-3 [&_hr]:border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function PromptEditor({
|
||||
label = "自定义提示词 (customPrompt)",
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 6,
|
||||
}: {
|
||||
label?: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
}) {
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(value)
|
||||
toast.success("已复制 Prompt")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{label}</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{ minHeight: `${rows * 1.6}rem` }}
|
||||
className="bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user