"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" 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[] { const result: string[] = [] const seen = new Set() 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 (

{label} - hiện tại

{left || "(trống)"}

{label} - AI đề xuất

{right || "(trống)"}

) } export default function ModAIToolPage() { const [keyword, setKeyword] = useState("") const [searching, setSearching] = useState(false) const [searchError, setSearchError] = useState("") const [novels, setNovels] = useState([]) const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(false) const [enrichingNovelId, setEnrichingNovelId] = useState(null) const [provider, setProvider] = useState("") const [model, setModel] = useState("") const [attempts, setAttempts] = useState([]) const [genres, setGenres] = useState([]) const [genreQuery, setGenreQuery] = useState("") const [comparisonNovel, setComparisonNovel] = useState(null) const [suggestion, setSuggestion] = useState(null) const [editableSuggestion, setEditableSuggestion] = useState(null) const [suggestions, setSuggestions] = useState([]) 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 | null, changedKeys: [] as string[] } } const changedKeys: string[] = [] const payload: Record = { 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 = { 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 => { const wanted = dedupeGenreNames(names) if (wanted.length === 0) return [] let workingGenres = [...genres] let hasGenreListChanged = false const resolvedByKey = new Map() 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 & { 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 = { ...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 (

Công cụ AI

Tìm truyện trong database trước. Sau đó bấm "Bổ sung thông tin" để AI tìm và đề xuất metadata.

Thứ tự provider hiện tại: Google AI {">"} DeepSeek (OpenRouter đang tạm dừng)

{(provider || model) && (

Provider được dùng: {provider || "không rõ"} {model ? `| Model: ${model}` : ""}

)}
{(!isAutoEnriching && !isGlobalEnriching) ? ( <> ) : ( )}
{searching && } setKeyword(e.target.value)} placeholder="Nhập tên truyện, tác giả, slug..." className="pl-8 pr-8" disabled={isAutoEnriching} />

Ô tìm kiếm này là live search, kết quả sẽ tự động cập nhật khi bạn nhập.

{autoEnrichLogs.length > 0 && (

Tiến trình bổ sung hàng loạt:

{autoEnrichLogs.map((log) => (
{log.title}
{log.status === "pending" && Chờ xử lý...} {log.status === "processing" && Đang phân tích...} {log.status === "success" && {log.message || "Thành công"}} {log.status === "failed" && {log.message || "Lỗi"}}
))}
)} {searchError && (
{searchError}
)}
{novels.length === 0 && !searching ? (
{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."}
) : ( novels.map((novel) => (
{novel.coverUrl ? {novel.title} : null}

{novel.title}

Tác giả: {novel.authorName}

{normalizeDisplayText(novel.description) || "(Không có mô tả)"}

)) )}
{hasMore && (
)}
{attempts.length > 0 && (

Trạng thái model/provider

{attempts.map((item, idx) => (
{item.provider} {item.model} {item.status === "success" ? "Thành công" : item.status === "failed" ? "Thất bại" : "Bỏ qua"} {typeof item.latencyMs === "number" && {item.latencyMs}ms} {item.message && - {item.message}}
))}
)} {enrichError && (
{enrichError}
)} {applyError && (
{applyError}
)} {applySuccess && (
{applySuccess}
)} {comparisonNovel && editableSuggestion && (

So sánh thông tin trước/sau bổ sung

Bạn có thể chỉnh tay dữ liệu AI trước khi cập nhật

Hiện có {changedPayload.changedKeys.length} trường thay đổi so với dữ liệu hiện tại.

setEditableSuggestion((prev) => (prev ? { ...prev, title: e.target.value } : prev))} />
setEditableSuggestion((prev) => (prev ? { ...prev, originalTitle: e.target.value } : prev))} />
setEditableSuggestion((prev) => (prev ? { ...prev, authorName: e.target.value } : prev))} />
setEditableSuggestion((prev) => (prev ? { ...prev, originalAuthorName: e.target.value } : prev))} />
setEditableSuggestion((prev) => (prev ? { ...prev, coverUrl: e.target.value } : prev))} />