239 lines
8.2 KiB
TypeScript
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>
|
|
)
|
|
}
|