Files
reader/app/mod/ai-tool/page.tsx
T

1107 lines
44 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState, useRef } from "react"
import { Bot, Loader2, Search, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { toast } from "sonner"
// Trang này được đặt dưới /mod layout đã kiểm soát quyền ở server side.
type NovelRow = {
id: string
title: string
originalTitle?: string | null
slug: string
authorName: string
originalAuthorName?: string | null
description: string
coverUrl?: string | null
status: string
updatedAt: string
}
type AISuggestion = {
title: string
originalTitle?: string
authorName: string
originalAuthorName?: string
description: string
coverUrl?: string
status?: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested?: string[]
confidence?: number
source?: string
sourceUrl?: string
firstPublishYear?: number
}
type AttemptStatus = {
provider: "google" | "openrouter" | "deepseek"
model: string
status: "success" | "failed" | "skipped"
message?: string
latencyMs?: number
}
type Genre = {
id: string
name: string
}
type EnrichResponse = {
novel: {
id: string
title: string
originalTitle?: string | null
slug: string
authorName: string
originalAuthorName?: string | null
description: string
coverUrl?: string | null
status: string
updatedAt: string
genres: string[]
}
provider: string
model: string
attempts: AttemptStatus[]
results: AISuggestion[]
}
type EditableSuggestion = {
title: string
originalTitle: string
authorName: string
originalAuthorName: string
description: string
coverUrl: string
status: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
genresSuggested: string[]
}
function normalizeDisplayText(value?: string | null): string {
const trimmed = (value || "").trim()
if (!trimmed) return ""
const normalized = trimmed
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim()
if (["chua co gioi thieu", "khong co gioi thieu", "khong co mo ta", "chua co mo ta"].includes(normalized)) {
return "Chưa có giới thiệu"
}
return trimmed
}
function normalizeStatus(value?: string | null): "Đang ra" | "Hoàn thành" | "Tạm ngưng" {
const text = normalizeDisplayText(value)
if (text === "Hoàn thành") return "Hoàn thành"
if (text === "Tạm ngưng") return "Tạm ngưng"
return "Đang ra"
}
function normalizeGenreKey(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim()
}
function dedupeGenreNames(values: Array<string | null | undefined>): string[] {
const result: string[] = []
const seen = new Set<string>()
for (const value of values) {
const name = normalizeDisplayText(value)
if (!name) continue
const key = normalizeGenreKey(name)
if (!key || seen.has(key)) continue
seen.add(key)
result.push(name)
}
return result
}
function areGenreSetsEqual(a: string[], b: string[]): boolean {
const left = dedupeGenreNames(a).map((name) => normalizeGenreKey(name)).sort()
const right = dedupeGenreNames(b).map((name) => normalizeGenreKey(name)).sort()
if (left.length !== right.length) return false
return left.every((item, idx) => item === right[idx])
}
function createEditableSuggestion(novel: EnrichResponse["novel"], ai: AISuggestion): EditableSuggestion {
const aiGenres = Array.isArray(ai.genresSuggested) ? ai.genresSuggested : []
const mergedGenres = dedupeGenreNames(aiGenres.length > 0 ? aiGenres : novel.genres)
return {
title: normalizeDisplayText(ai.title) || normalizeDisplayText(novel.title),
originalTitle: normalizeDisplayText(ai.originalTitle) || normalizeDisplayText(novel.originalTitle),
authorName: normalizeDisplayText(ai.authorName) || normalizeDisplayText(novel.authorName),
originalAuthorName: normalizeDisplayText(ai.originalAuthorName) || normalizeDisplayText(novel.originalAuthorName),
description: normalizeDisplayText(ai.description) || normalizeDisplayText(novel.description),
coverUrl: normalizeDisplayText(ai.coverUrl) || normalizeDisplayText(novel.coverUrl),
status: normalizeStatus(ai.status || novel.status),
genresSuggested: mergedGenres,
}
}
function FieldDiff({ label, current, suggested }: { label: string; current?: string | null; suggested?: string | null }) {
const left = normalizeDisplayText(current)
const right = normalizeDisplayText(suggested)
const changed = left !== right
return (
<div className="grid gap-2 rounded-md border p-3 sm:grid-cols-2">
<div>
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label} - hiện tại</p>
<p className="mt-1 text-sm">{left || "(trống)"}</p>
</div>
<div>
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label} - AI đ xuất</p>
<p className={`mt-1 text-sm ${changed ? "text-primary font-medium" : ""}`}>{right || "(trống)"}</p>
</div>
</div>
)
}
export default function ModAIToolPage() {
const [keyword, setKeyword] = useState("")
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState("")
const [novels, setNovels] = useState<NovelRow[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [enrichingNovelId, setEnrichingNovelId] = useState<string | null>(null)
const [provider, setProvider] = useState("")
const [model, setModel] = useState("")
const [attempts, setAttempts] = useState<AttemptStatus[]>([])
const [genres, setGenres] = useState<Genre[]>([])
const [genreQuery, setGenreQuery] = useState("")
const [comparisonNovel, setComparisonNovel] = useState<EnrichResponse["novel"] | null>(null)
const [suggestion, setSuggestion] = useState<AISuggestion | null>(null)
const [editableSuggestion, setEditableSuggestion] = useState<EditableSuggestion | null>(null)
const [suggestions, setSuggestions] = useState<AISuggestion[]>([])
const [enrichError, setEnrichError] = useState("")
const [applyingSuggestion, setApplyingSuggestion] = useState(false)
const [applyError, setApplyError] = useState("")
const [applySuccess, setApplySuccess] = useState("")
const [missingFilter, setMissingFilter] = useState(false)
const [autoEnrichLogs, setAutoEnrichLogs] = useState<{ id: string, title: string, status: "pending" | "processing" | "success" | "failed", message?: string }[]>([])
const [isAutoEnriching, setIsAutoEnriching] = useState(false)
const [testingConnection, setTestingConnection] = useState(false)
const [isGlobalEnriching, setIsGlobalEnriching] = useState(false)
const autoEnrichingRef = useRef(false)
const globalEnrichingRef = useRef(false)
const trimmedKeyword = keyword.trim()
const canSearch = trimmedKeyword.length >= 1 || missingFilter
const matchedGenres = useMemo(() => {
const q = normalizeGenreKey(genreQuery)
if (!q) return [] as Genre[]
return genres
.filter((genre) => normalizeGenreKey(genre.name).includes(q))
.slice(0, 12)
}, [genreQuery, genres])
const selectedGenreNames = useMemo(() => {
if (!editableSuggestion) return []
return dedupeGenreNames(editableSuggestion.genresSuggested)
}, [editableSuggestion])
const changedPayload = useMemo(() => {
if (!comparisonNovel || !editableSuggestion) {
return { payload: null as Record<string, unknown> | null, changedKeys: [] as string[] }
}
const changedKeys: string[] = []
const payload: Record<string, unknown> = { id: comparisonNovel.id }
const currentTitle = normalizeDisplayText(comparisonNovel.title)
const currentOriginalTitle = normalizeDisplayText(comparisonNovel.originalTitle)
const currentAuthorName = normalizeDisplayText(comparisonNovel.authorName)
const currentOriginalAuthorName = normalizeDisplayText(comparisonNovel.originalAuthorName)
const currentDescription = normalizeDisplayText(comparisonNovel.description)
const currentCoverUrl = normalizeDisplayText(comparisonNovel.coverUrl)
const currentStatus = normalizeStatus(comparisonNovel.status)
const currentGenres = dedupeGenreNames(comparisonNovel.genres)
const nextGenres = dedupeGenreNames(editableSuggestion.genresSuggested)
if (editableSuggestion.title.trim() !== currentTitle) {
payload.title = editableSuggestion.title.trim()
changedKeys.push("title")
}
if (editableSuggestion.originalTitle.trim() !== currentOriginalTitle) {
payload.originalTitle = editableSuggestion.originalTitle.trim()
changedKeys.push("originalTitle")
}
if (editableSuggestion.authorName.trim() !== currentAuthorName) {
payload.authorName = editableSuggestion.authorName.trim()
changedKeys.push("authorName")
}
if (editableSuggestion.originalAuthorName.trim() !== currentOriginalAuthorName) {
payload.originalAuthorName = editableSuggestion.originalAuthorName.trim()
changedKeys.push("originalAuthorName")
}
if (editableSuggestion.description.trim() !== currentDescription) {
payload.description = editableSuggestion.description.trim()
changedKeys.push("description")
}
if (editableSuggestion.coverUrl.trim() !== currentCoverUrl) {
payload.coverUrl = editableSuggestion.coverUrl.trim()
changedKeys.push("coverUrl")
}
if (editableSuggestion.status !== currentStatus) {
payload.status = editableSuggestion.status
changedKeys.push("status")
}
if (!areGenreSetsEqual(currentGenres, nextGenres)) {
changedKeys.push("genres")
}
return { payload, changedKeys }
}, [comparisonNovel, editableSuggestion])
useEffect(() => {
const fetchGenres = async () => {
try {
const res = await fetch("/api/mod/the-loai", { cache: "no-store" })
const data = await res.json().catch(() => [])
if (!res.ok) return
setGenres(Array.isArray(data) ? data : [])
} catch {
// ignore fetch errors here; genre editor can still work with manual names.
}
}
void fetchGenres()
}, [])
useEffect(() => {
if (!canSearch) {
setSearching(false)
setSearchError("")
setNovels([])
setPage(1)
setHasMore(false)
return
}
const controller = new AbortController()
const timeoutId = window.setTimeout(async () => {
setSearching(true)
setSearchError("")
try {
const res = await fetch(`/api/mod/ai-tools/novel-search?q=${encodeURIComponent(trimmedKeyword)}&missing=${missingFilter}&page=1`, {
signal: controller.signal,
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Không thể tìm truyện trong database")
}
const rows = Array.isArray(data) ? data : data.novels || []
setNovels(rows)
setHasMore(!!data.hasMore)
setPage(1)
} catch (error: any) {
if (controller.signal.aborted) return
setSearchError(error?.message || "Không thể tìm truyện trong database")
setNovels([])
setHasMore(false)
} finally {
if (!controller.signal.aborted) {
setSearching(false)
}
}
}, 250)
return () => {
controller.abort()
window.clearTimeout(timeoutId)
}
}, [canSearch, trimmedKeyword, missingFilter])
const handleLoadMore = async () => {
if (!canSearch || !hasMore || searching) return
setSearching(true)
try {
const nextPage = page + 1
const res = await fetch(`/api/mod/ai-tools/novel-search?q=${encodeURIComponent(trimmedKeyword)}&missing=${missingFilter}&page=${nextPage}`)
if (!res.ok) throw new Error("Lỗi tải thêm")
const data = await res.json()
const rows = Array.isArray(data) ? data : data.novels || []
setNovels(prev => [...prev, ...rows])
setHasMore(!!data.hasMore)
setPage(nextPage)
} catch (error: any) {
toast.error(error.message || "Không thể tải thêm dòng.")
} finally {
setSearching(false)
}
}
const handleEnrich = async (novelId: string) => {
setEnrichingNovelId(novelId)
setEnrichError("")
setApplyError("")
setApplySuccess("")
setGenreQuery("")
setAttempts([])
try {
const res = await fetch(`/api/mod/ai-tools/novel-enrich?novelId=${encodeURIComponent(novelId)}`)
const data = (await res.json()) as EnrichResponse & { error?: string }
if (!res.ok) {
throw new Error(data.error || "Không thể bổ sung thông tin")
}
const rows = Array.isArray(data.results) ? data.results : []
const best = rows[0] || null
setComparisonNovel(data.novel)
setSuggestions(rows)
setSuggestion(best)
setEditableSuggestion(best ? createEditableSuggestion(data.novel, best) : null)
setProvider(data.provider || "")
setModel(data.model || "")
setAttempts(Array.isArray(data.attempts) ? data.attempts : [])
if (!best) {
setEnrichError("AI không trả về kết quả hợp lệ")
}
} catch (error: any) {
setEnrichError(error?.message || "Không thể bổ sung thông tin")
} finally {
setEnrichingNovelId(null)
}
}
const processNovelList = async (novelList: NovelRow[]) => {
const logs = novelList.map(n => ({ id: n.id, title: n.title, status: "pending" as const }))
setAutoEnrichLogs(logs)
setEnrichError("")
const BATCH_SIZE = 5
for (let i = 0; i < novelList.length; i += BATCH_SIZE) {
if (!autoEnrichingRef.current) break
const batch = novelList.slice(i, i + BATCH_SIZE)
setAutoEnrichLogs(prev => prev.map(l => batch.some(b => b.id === l.id) ? { ...l, status: "processing" } : l))
try {
const batchRes = await fetch("/api/mod/ai-tools/novel-enrich-batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novelIds: batch.map(n => n.id) })
})
const batchData = await batchRes.json()
if (!batchRes.ok) throw new Error(batchData.error || "Lỗi giao tiếp AI Batch")
const results = batchData.results || []
for (const novel of batch) {
try {
const best = results.find((r: any) => r.id === novel.id)
if (!best) throw new Error("AI không phân tích được truyện này (có thể do lỗi cấu trúc)")
const fullNovel = batchData.sourceNovels?.find((n: any) => n.id === novel.id) || novel
const editable = createEditableSuggestion(fullNovel, best)
const currentTitle = normalizeDisplayText(fullNovel.title)
const currentOriginalTitle = normalizeDisplayText(fullNovel.originalTitle)
const currentAuthorName = normalizeDisplayText(fullNovel.authorName)
const currentOriginalAuthorName = normalizeDisplayText(fullNovel.originalAuthorName)
const currentDescription = normalizeDisplayText(fullNovel.description)
const currentCoverUrl = normalizeDisplayText(fullNovel.coverUrl)
const currentStatus = normalizeStatus(fullNovel.status)
const currentGenres = dedupeGenreNames(fullNovel.genres?.map((g: any) => g.genre?.name || g.name))
const nextGenres = dedupeGenreNames(editable.genresSuggested)
const payload: Record<string, unknown> = { id: novel.id }
let hasChanges = false
if (editable.title.trim() !== currentTitle) { payload.title = editable.title.trim(); hasChanges = true }
if (editable.originalTitle.trim() !== currentOriginalTitle) { payload.originalTitle = editable.originalTitle.trim(); hasChanges = true }
if (editable.authorName.trim() !== currentAuthorName) { payload.authorName = editable.authorName.trim(); hasChanges = true }
if (editable.originalAuthorName.trim() !== currentOriginalAuthorName) { payload.originalAuthorName = editable.originalAuthorName.trim(); hasChanges = true }
if (editable.description.trim() !== currentDescription) { payload.description = editable.description.trim(); hasChanges = true }
if (editable.coverUrl.trim() !== currentCoverUrl) { payload.coverUrl = editable.coverUrl.trim(); hasChanges = true }
if (editable.status !== currentStatus) { payload.status = editable.status; hasChanges = true }
if (!areGenreSetsEqual(currentGenres, nextGenres)) { payload.genres = true; hasChanges = true }
if (!hasChanges) {
setAutoEnrichLogs(prev => prev.map(l => l.id === novel.id ? { ...l, status: "success", message: "Không cần thay đổi" } : l))
continue
}
if (payload.genres) {
payload.genreIds = await resolveGenreIdsFromNames(editable.genresSuggested)
delete payload.genres
}
const updateRes = await fetch("/api/mod/truyen", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!updateRes.ok) {
const errData = await updateRes.json().catch(() => ({}))
throw new Error(errData?.error || "Lưu DB thất bại")
}
setAutoEnrichLogs(prev => prev.map(l => l.id === novel.id ? { ...l, status: "success", message: "Đã cập nhật" } : l))
} catch (innerErr: any) {
setAutoEnrichLogs(prev => prev.map(l => l.id === novel.id ? { ...l, status: "failed", message: innerErr.message } : l))
}
}
} catch (err: any) {
setAutoEnrichLogs(prev => prev.map(l => batch.some(b => b.id === l.id) && l.status === "processing" ? { ...l, status: "failed", message: err.message } : l))
}
}
}
const handleStartAutoEnrich = async () => {
if (novels.length === 0) return
setIsAutoEnriching(true)
autoEnrichingRef.current = true
await processNovelList(novels)
setIsAutoEnriching(false)
autoEnrichingRef.current = false
}
const handleStartGlobalAutoEnrich = async () => {
setIsGlobalEnriching(true)
globalEnrichingRef.current = true
setIsAutoEnriching(true)
autoEnrichingRef.current = true
while (globalEnrichingRef.current) {
try {
const res = await fetch("/api/mod/ai-tools/novel-search?missing=true")
if (!res.ok) throw new Error("Chủ động dừng hoặc lỗi truy xuất.")
const data = await res.json()
const fetchedNovels = Array.isArray(data) ? data : data.novels || []
if (!fetchedNovels || fetchedNovels.length === 0) {
toast.success("Tuyệt vời! Đã bù đắp xong toàn bộ truyện bị thiếu thông tin.")
break
}
setNovels(fetchedNovels)
await processNovelList(fetchedNovels)
} catch (err: any) {
toast.error("Tiến trình tự động toàn cục bị dừng: " + err.message)
break
}
}
setIsGlobalEnriching(false)
globalEnrichingRef.current = false
setIsAutoEnriching(false)
autoEnrichingRef.current = false
}
const handleStopAutoEnrich = () => {
autoEnrichingRef.current = false
globalEnrichingRef.current = false
setIsAutoEnriching(false)
setIsGlobalEnriching(false)
}
const handleTestConnection = async () => {
if (testingConnection) return
setTestingConnection(true)
toast.info("Đang kiểm tra kết nối DeepSeek...")
try {
const res = await fetch("/api/mod/ai-tools/test-connection")
const data = await res.json()
if (res.ok) {
toast.success(`OK! ${data.message}`)
} else {
toast.error(data.error || "Thất bại")
}
} catch (err: any) {
toast.error(err.message || "Lỗi Network")
} finally {
setTestingConnection(false)
}
}
const handleAddGenreName = (name: string) => {
const cleaned = normalizeDisplayText(name)
if (!cleaned) return
setEditableSuggestion((prev) => {
if (!prev) return prev
return {
...prev,
genresSuggested: dedupeGenreNames([...prev.genresSuggested, cleaned]),
}
})
setGenreQuery("")
}
const handleRemoveGenreName = (name: string) => {
const target = normalizeGenreKey(name)
if (!target) return
setEditableSuggestion((prev) => {
if (!prev) return prev
return {
...prev,
genresSuggested: prev.genresSuggested.filter((item) => normalizeGenreKey(item) !== target),
}
})
}
const resolveGenreIdsFromNames = async (names: string[]): Promise<string[]> => {
const wanted = dedupeGenreNames(names)
if (wanted.length === 0) return []
let workingGenres = [...genres]
let hasGenreListChanged = false
const resolvedByKey = new Map<string, string>()
for (const genreName of wanted) {
const key = normalizeGenreKey(genreName)
const existed = workingGenres.find((item) => normalizeGenreKey(item.name) === key)
if (existed) {
resolvedByKey.set(key, existed.id)
continue
}
const createRes = await fetch("/api/mod/the-loai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: genreName, description: "" }),
})
const createData = await createRes.json().catch(() => ({} as Partial<Genre> & { error?: string }))
if (createRes.ok && typeof createData?.id === "string") {
const createdGenre: Genre = {
id: createData.id,
name: typeof createData.name === "string" ? createData.name : genreName,
}
workingGenres = [...workingGenres, createdGenre]
hasGenreListChanged = true
resolvedByKey.set(key, createdGenre.id)
}
}
const unresolved = wanted.filter((name) => !resolvedByKey.has(normalizeGenreKey(name)))
if (unresolved.length > 0) {
const refreshRes = await fetch("/api/mod/the-loai", { cache: "no-store" })
const refreshData = await refreshRes.json().catch(() => [])
if (refreshRes.ok && Array.isArray(refreshData)) {
workingGenres = refreshData
hasGenreListChanged = true
for (const name of unresolved) {
const key = normalizeGenreKey(name)
const matched = workingGenres.find((item) => normalizeGenreKey(item.name) === key)
if (matched) {
resolvedByKey.set(key, matched.id)
}
}
}
}
if (hasGenreListChanged) {
setGenres(workingGenres)
}
const missing = wanted.filter((name) => !resolvedByKey.has(normalizeGenreKey(name)))
if (missing.length > 0) {
throw new Error(`Không thể xử lý thể loại: ${missing.slice(0, 3).join(", ")}`)
}
return wanted
.map((name) => resolvedByKey.get(normalizeGenreKey(name)) || "")
.filter(Boolean)
}
const handleApplySuggestion = async () => {
if (!comparisonNovel || !editableSuggestion || !changedPayload.payload) return
if (changedPayload.changedKeys.length === 0) {
setApplySuccess("Không có trường nào thay đổi để cập nhật.")
setApplyError("")
return
}
setApplyingSuggestion(true)
setApplyError("")
setApplySuccess("")
try {
const payload: Record<string, unknown> = { ...changedPayload.payload }
if (changedPayload.changedKeys.includes("genres")) {
payload.genreIds = await resolveGenreIdsFromNames(editableSuggestion.genresSuggested)
}
const res = await fetch("/api/mod/truyen", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data?.error || "Không thể cập nhật truyện")
}
setComparisonNovel((prev) => {
if (!prev) return prev
return {
...prev,
title: editableSuggestion.title,
originalTitle: editableSuggestion.originalTitle,
authorName: editableSuggestion.authorName,
originalAuthorName: editableSuggestion.originalAuthorName,
description: editableSuggestion.description,
coverUrl: editableSuggestion.coverUrl,
status: editableSuggestion.status,
genres: dedupeGenreNames(editableSuggestion.genresSuggested),
updatedAt: new Date().toISOString(),
}
})
setNovels((prev) =>
prev.map((row) =>
row.id === comparisonNovel.id
? {
...row,
title: editableSuggestion.title,
originalTitle: editableSuggestion.originalTitle,
authorName: editableSuggestion.authorName,
originalAuthorName: editableSuggestion.originalAuthorName,
description: editableSuggestion.description,
coverUrl: editableSuggestion.coverUrl,
status: editableSuggestion.status,
updatedAt: new Date().toISOString(),
}
: row
)
)
setApplySuccess(`Đã cập nhật ${changedPayload.changedKeys.length} trường thay đổi.`)
} catch (error: any) {
setApplyError(error?.message || "Không thể cập nhật truyện")
} finally {
setApplyingSuggestion(false)
}
}
return (
<div className="space-y-6">
<div className="rounded-xl border bg-card p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-bold">Công cụ AI</h1>
</div>
<p className="text-sm text-muted-foreground">
Tìm truyện trong database trước. Sau đó bấm "Bổ sung thông tin" đ AI tìm đ xuất metadata.
</p>
<p className="mt-2 text-xs text-muted-foreground">
Thứ tự provider hiện tại: Google AI {">"} DeepSeek (OpenRouter đang tạm dừng)
</p>
{(provider || model) && (
<p className="mt-2 text-xs text-primary">
Provider đưc dùng: {provider || "không rõ"} {model ? `| Model: ${model}` : ""}
</p>
)}
</div>
<div className="rounded-xl border bg-card p-5 shadow-sm space-y-4">
<label className="text-sm font-medium">Tìm truyện trong database</label>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-4 mb-2 border-b pb-4 border-border/50">
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-primary" checked={missingFilter} onChange={(e) => setMissingFilter(e.target.checked)} />
Chỉ hiện truyện thiếu thông tin
</label>
<div className="flex items-center gap-2 ml-auto">
<Button variant="outline" size="sm" onClick={handleTestConnection} disabled={testingConnection || isAutoEnriching || isGlobalEnriching}>
{testingConnection ? <Loader2 className="mr-2 h-4 w-4 animate-spin"/> : <Sparkles className="mr-2 h-4 w-4" />}
Test API DeepSeek
</Button>
{(!isAutoEnriching && !isGlobalEnriching) ? (
<>
<Button variant="outline" size="sm" onClick={handleStartAutoEnrich} disabled={novels.length === 0}>
<Bot className="mr-2 h-4 w-4" /> Bổ sung Trang này
</Button>
<Button variant="default" size="sm" onClick={handleStartGlobalAutoEnrich} disabled={!missingFilter}>
<Sparkles className="mr-2 h-4 w-4" /> Tự đng toàn bộ
</Button>
</>
) : (
<Button variant="destructive" size="sm" onClick={handleStopAutoEnrich}>
Dừng Bổ sung {isGlobalEnriching ? "Toàn bộ" : ""}
</Button>
)}
</div>
</div>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
{searching && <Loader2 className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />}
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="Nhập tên truyện, tác giả, slug..."
className="pl-8 pr-8"
disabled={isAutoEnriching}
/>
</div>
<p className="text-xs text-muted-foreground">Ô tìm kiếm này live search, kết quả sẽ tự đng cập nhật khi bạn nhập.</p>
</div>
{autoEnrichLogs.length > 0 && (
<div className="mb-4 rounded-md border p-3 bg-muted/30">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Tiến trình bổ sung hàng loạt:</h3>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setAutoEnrichLogs([])}>Xóa log</Button>
</div>
<div className="max-h-48 overflow-y-auto space-y-1 text-xs custom-scrollbar pr-2">
{autoEnrichLogs.map((log) => (
<div key={log.id} className="flex items-center justify-between py-1.5 border-b last:border-0 border-border/50">
<span className="font-medium truncate w-[60%]" title={log.title}>{log.title}</span>
<div className="flex items-center gap-2 shrink-0">
{log.status === "pending" && <span className="text-muted-foreground">Chờ xử ...</span>}
{log.status === "processing" && <span className="text-blue-500 flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin"/> Đang phân tích...</span>}
{log.status === "success" && <span className="text-emerald-600 font-medium">{log.message || "Thành công"}</span>}
{log.status === "failed" && <span className="text-destructive font-medium">{log.message || "Lỗi"}</span>}
</div>
</div>
))}
</div>
</div>
)}
{searchError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{searchError}
</div>
)}
<div className="space-y-3">
{novels.length === 0 && !searching ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
{canSearch ? "Không tìm thấy truyện phù hợp." : "Nhập từ khóa hoặc tick chọn Lọc truyện thiếu thông tin để bắt đầu."}
</div>
) : (
novels.map((novel) => (
<div key={novel.id} className="rounded-lg border p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="h-20 w-14 shrink-0 overflow-hidden rounded-md border bg-muted">
{novel.coverUrl ? <img src={novel.coverUrl} alt={novel.title} className="h-full w-full object-cover" /> : null}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{novel.title}</p>
<p className="text-xs text-muted-foreground">Tác giả: {novel.authorName}</p>
<p className="text-xs text-muted-foreground line-clamp-2">{normalizeDisplayText(novel.description) || "(Không có mô tả)"}</p>
</div>
<Button
size="sm"
onClick={() => void handleEnrich(novel.id)}
disabled={enrichingNovelId === novel.id || isAutoEnriching}
className="shrink-0"
>
{enrichingNovelId === novel.id ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Bot className="mr-2 h-4 w-4" />}
Bổ sung thông tin
</Button>
</div>
</div>
))
)}
</div>
{hasMore && (
<div className="flex justify-center pt-4">
<Button variant="secondary" onClick={handleLoadMore} disabled={searching || isAutoEnriching || isGlobalEnriching}>
{searching ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Tải thêm (Page {page + 1})
</Button>
</div>
)}
</div>
{attempts.length > 0 && (
<div className="rounded-xl border bg-card p-5 shadow-sm">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Trạng thái model/provider</p>
<div className="space-y-2">
{attempts.map((item, idx) => (
<div key={`${item.provider}-${item.model}-${idx}`} className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full border px-2 py-0.5 bg-background">{item.provider}</span>
<span className="rounded-full border px-2 py-0.5 bg-background">{item.model}</span>
<span
className={`rounded-full px-2 py-0.5 font-semibold ${
item.status === "success"
? "bg-emerald-100 text-emerald-700"
: item.status === "failed"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}
>
{item.status === "success" ? "Thành công" : item.status === "failed" ? "Thất bại" : "Bỏ qua"}
</span>
{typeof item.latencyMs === "number" && <span className="text-muted-foreground">{item.latencyMs}ms</span>}
{item.message && <span className="text-muted-foreground">- {item.message}</span>}
</div>
))}
</div>
</div>
)}
{enrichError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{enrichError}
</div>
)}
{applyError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{applyError}
</div>
)}
{applySuccess && (
<div className="rounded-md border border-emerald-400/40 bg-emerald-500/10 p-3 text-sm text-emerald-700">
{applySuccess}
</div>
)}
{comparisonNovel && editableSuggestion && (
<div className="rounded-xl border bg-card p-5 shadow-sm space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">So sánh thông tin trước/sau bổ sung</h2>
<Button onClick={() => void handleApplySuggestion()} disabled={applyingSuggestion || changedPayload.changedKeys.length === 0}>
{applyingSuggestion ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Xác nhận cập nhật
</Button>
</div>
<div className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">Bạn thể chỉnh tay dữ liệu AI trước khi cập nhật</p>
<p className="text-xs text-muted-foreground">Hiện {changedPayload.changedKeys.length} trường thay đi so với dữ liệu hiện tại.</p>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="text-xs font-medium text-muted-foreground">Tên truyện</label>
<Input
value={editableSuggestion.title}
onChange={(e) => setEditableSuggestion((prev) => (prev ? { ...prev, title: e.target.value } : prev))}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Tên gốc</label>
<Input
value={editableSuggestion.originalTitle}
onChange={(e) => setEditableSuggestion((prev) => (prev ? { ...prev, originalTitle: e.target.value } : prev))}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Tác giả</label>
<Input
value={editableSuggestion.authorName}
onChange={(e) => setEditableSuggestion((prev) => (prev ? { ...prev, authorName: e.target.value } : prev))}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Tác giả gốc</label>
<Input
value={editableSuggestion.originalAuthorName}
onChange={(e) => setEditableSuggestion((prev) => (prev ? { ...prev, originalAuthorName: e.target.value } : prev))}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Trạng thái</label>
<select
value={editableSuggestion.status}
onChange={(e) =>
setEditableSuggestion((prev) =>
prev
? {
...prev,
status: (e.target.value as "Đang ra" | "Hoàn thành" | "Tạm ngưng"),
}
: prev
)
}
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
>
<option value="Đang ra">Đang ra</option>
<option value="Hoàn thành">Hoàn thành</option>
<option value="Tạm ngưng">Tạm ngưng</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Link nh bìa</label>
<Input
value={editableSuggestion.coverUrl}
onChange={(e) => setEditableSuggestion((prev) => (prev ? { ...prev, coverUrl: e.target.value } : prev))}
/>
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground"> tả</label>
<textarea
value={editableSuggestion.description}
onChange={(e) => setEditableSuggestion((prev) => (prev ? { ...prev, description: e.target.value } : prev))}
rows={6}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Thể loại</label>
<div className="flex gap-2">
<Input
value={genreQuery}
onChange={(e) => setGenreQuery(e.target.value)}
placeholder="Nhập thể loại rồi bấm Thêm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddGenreName(genreQuery)
}
}}
/>
<Button type="button" variant="secondary" onClick={() => handleAddGenreName(genreQuery)} disabled={!genreQuery.trim()}>
Thêm
</Button>
</div>
<div className="flex flex-wrap gap-1.5 rounded-md border bg-background p-2">
{selectedGenreNames.length === 0 ? (
<span className="text-xs text-muted-foreground">Chưa thể loại nào.</span>
) : (
selectedGenreNames.map((name) => (
<span key={name} className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs">
{name}
<button
type="button"
className="rounded px-1 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => handleRemoveGenreName(name)}
aria-label={`Xóa thể loại ${name}`}
>
x
</button>
</span>
))
)}
</div>
{genreQuery.trim().length > 0 && matchedGenres.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{matchedGenres.map((genre) => (
<button
key={genre.id}
type="button"
onClick={() => handleAddGenreName(genre.name)}
className="rounded-full border bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
>
{genre.name}
</button>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Khi cập nhật, hệ thống sẽ tự map thể loại theo dữ liệu hiện tự tạo thể loại mới nếu chưa tồn tại.
</p>
</div>
</div>
<FieldDiff label="Tên truyện" current={comparisonNovel.title} suggested={editableSuggestion.title} />
<FieldDiff label="Tên gốc" current={comparisonNovel.originalTitle} suggested={editableSuggestion.originalTitle} />
<FieldDiff label="Tác giả" current={comparisonNovel.authorName} suggested={editableSuggestion.authorName} />
<FieldDiff label="Tác giả gốc" current={comparisonNovel.originalAuthorName} suggested={editableSuggestion.originalAuthorName} />
<FieldDiff label="Trạng thái" current={comparisonNovel.status} suggested={editableSuggestion.status} />
<FieldDiff label="Mô tả" current={comparisonNovel.description} suggested={editableSuggestion.description} />
<FieldDiff
label="Thể loại"
current={dedupeGenreNames(comparisonNovel.genres).join(", ")}
suggested={dedupeGenreNames(editableSuggestion.genresSuggested).join(", ")}
/>
{Array.isArray(suggestion?.genresSuggested) && suggestion.genresSuggested.length > 0 && (
<div className="rounded-md border p-3">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Thể loại AI đ xuất</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{suggestion.genresSuggested.map((genre) => (
<span key={genre} className="rounded-full bg-muted px-2 py-0.5 text-xs">{genre}</span>
))}
</div>
</div>
)}
{suggestions.length > 1 && (
<div className="rounded-md border p-3">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Các kết quả AI khác</p>
<ul className="mt-2 space-y-1 text-sm text-muted-foreground">
{suggestions.slice(1).map((row, idx) => (
<li key={`${row.title}-${idx}`} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-2 py-1.5">
<span>
{row.title} - {row.authorName} ({row.confidence || 0}%)
</span>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!comparisonNovel) return
setSuggestion(row)
setEditableSuggestion(createEditableSuggestion(comparisonNovel, row))
setGenreQuery("")
setApplyError("")
setApplySuccess("")
}}
>
Dùng kết quả này
</Button>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)
}