@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user