From 878018ca111d6f3fe29f9ff96ae574ecb4f0228f Mon Sep 17 00:00:00 2001 From: virtus Date: Tue, 19 May 2026 00:15:19 +0700 Subject: [PATCH] refactor: Streamline EPUB handling with new split modes and improved error management - Removed legacy AI Tool references and unnecessary fields from the README and various components. - Introduced new EPUB split modes (toc, regex, tag) to enhance flexibility in chapter extraction. - Updated import and chapter management components to utilize the new EPUB split functionality. - Improved error handling in the login API route for better user feedback. - Cleaned up unused files and optimized the overall code structure for maintainability. --- README.md | 5 - app/api/auth/login/route.ts | 28 +- app/mod/ai-tool/page.tsx | 1106 ------------------------ app/mod/chuong/chapter-client.tsx | 140 ++- app/mod/import/import-batch-client.tsx | 62 +- app/mod/import/import-client.tsx | 53 +- app/mod/truyen/novel-client.tsx | 171 ++-- lib/auth-context.tsx | 6 +- lib/epub-split.ts | 31 + lib/mod-ai-tools.ts | 25 - test-ai.js | 68 -- tsconfig.tsbuildinfo | 2 +- 12 files changed, 370 insertions(+), 1327 deletions(-) delete mode 100644 app/mod/ai-tool/page.tsx create mode 100644 lib/epub-split.ts delete mode 100644 lib/mod-ai-tools.ts delete mode 100644 test-ai.js diff --git a/README.md b/README.md index b4b0dc5..3742801 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,6 @@ READER_API_ORIGIN="http://localhost:8000" GOOGLE_CLIENT_ID="your_google_client_id" GOOGLE_CLIENT_SECRET="your_google_client_secret" -# AI Tool cho MOD (LLM + web search) -OPENAI_API_KEY="your_openai_api_key" -# Tùy chọn, mặc định: gpt-4o-mini-search-preview -OPENAI_WEB_MODEL="gpt-4o-mini-search-preview" - # Cloudflare R2 (lưu ảnh bìa) R2_ACCOUNT_ID="your_cloudflare_account_id" R2_ACCESS_KEY_ID="your_r2_access_key_id" diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 9307fa9..0368c33 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -36,8 +36,19 @@ export async function POST(req: NextRequest) { }) if (!upstream.ok) { - const message = await upstream.text() - return NextResponse.json({ detail: message || "Authentication failed" }, { status: upstream.status }) + const raw = await upstream.text() + let detail = "Authentication failed" + try { + const parsed = JSON.parse(raw) as { detail?: unknown } + if (typeof parsed.detail === "string") { + detail = parsed.detail + } else if (raw.trim()) { + detail = raw.trim() + } + } catch { + if (raw.trim()) detail = raw.trim() + } + return NextResponse.json({ detail }, { status: upstream.status }) } const data = (await upstream.json()) as MobileLoginResponse @@ -66,6 +77,19 @@ export async function POST(req: NextRequest) { return response } catch (error) { console.error("/api/auth/login failed", error) + const cause = error instanceof Error ? error.cause : null + const code = + cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code || "") + : "" + if (code === "ECONNREFUSED" || (error instanceof Error && error.message.includes("fetch failed"))) { + return NextResponse.json( + { + detail: `Không kết nối được reader-api tại ${readerApiOrigin}. Kiểm tra READER_API_ORIGIN và đảm bảo API đang chạy (ví dụ cổng 18080).`, + }, + { status: 503 }, + ) + } return NextResponse.json({ detail: "Internal Server Error" }, { status: 500 }) } } diff --git a/app/mod/ai-tool/page.tsx b/app/mod/ai-tool/page.tsx deleted file mode 100644 index f9ac816..0000000 --- a/app/mod/ai-tool/page.tsx +++ /dev/null @@ -1,1106 +0,0 @@ -"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[] { - 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))} - /> -
-
- -
- -