Files
Next_Knowledge_Base/components/ai/ai-batch-import-dialog.tsx
T
jiawei 7d38fa9bdd
knowledge-base / deploy (push) Successful in 16s
feat: 微调
2026-06-23 21:15:33 +08:00

239 lines
8.2 KiB
TypeScript

"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 pl-2 pr-6">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-5 text-primary" />
AI 批量整理
</DialogTitle>
<DialogDescription className="pb-2">
粘贴多个题目 / 面试题 / 知识点,AI 会整理成结构化数据。插入前可预览、编辑并勾选。
</DialogDescription>
</DialogHeader>
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 px-2">
<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>
)
}