refactor: Streamline EPUB handling with new split modes and improved error management
Build and Push Reader Image / docker (push) Successful in 1m32s
Build and Push Reader Image / docker (push) Successful in 1m32s
- 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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,12 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search, BookOpen } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
appendEpubSplitFormFields,
|
||||
DEFAULT_EPUB_CHAPTER_TAG,
|
||||
EPUB_HTML_TAG_PRESETS,
|
||||
type EpubSplitMode,
|
||||
} from "@/lib/epub-split"
|
||||
import Link from "next/link"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
@@ -58,11 +64,12 @@ interface Chapter {
|
||||
interface EpubPreviewData {
|
||||
preview: true
|
||||
fileName: string
|
||||
splitMode: "toc" | "regex"
|
||||
splitMode: EpubSplitMode
|
||||
detectedStructureType: "light_novel" | "standard"
|
||||
parserInfo?: {
|
||||
splitMode: string
|
||||
chapterRegexUsed?: string
|
||||
chapterTagUsed?: string
|
||||
regexPreset?: string
|
||||
sourceSections: number
|
||||
chaptersDetected: number
|
||||
@@ -116,6 +123,8 @@ function ChapterManager() {
|
||||
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
|
||||
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
|
||||
const [optRenumber, setOptRenumber] = useState(true)
|
||||
const [optNormalizeTitles, setOptNormalizeTitles] = useState(true)
|
||||
const [optNormalizeGenericOnly, setOptNormalizeGenericOnly] = useState(true)
|
||||
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
|
||||
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
|
||||
|
||||
@@ -147,9 +156,11 @@ function ChapterManager() {
|
||||
const [openEpubAppend, setOpenEpubAppend] = useState(false)
|
||||
const [epubFile, setEpubFile] = useState<File | null>(null)
|
||||
const epubInputRef = useRef<HTMLInputElement>(null)
|
||||
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("regex")
|
||||
const [epubSplitMode, setEpubSplitMode] = useState<EpubSplitMode>("regex")
|
||||
const [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi")
|
||||
const [epubCustomRegex, setEpubCustomRegex] = useState("")
|
||||
const [epubTagPreset, setEpubTagPreset] = useState("a")
|
||||
const [epubCustomTag, setEpubCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
|
||||
const [appendingEpub, setAppendingEpub] = useState(false)
|
||||
const [epubPreviewData, setEpubPreviewData] = useState<EpubPreviewData | null>(null)
|
||||
|
||||
@@ -160,6 +171,13 @@ function ChapterManager() {
|
||||
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
|
||||
}
|
||||
|
||||
const getEpubSourceTag = () => {
|
||||
if (epubTagPreset === "custom") {
|
||||
return epubCustomTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
|
||||
}
|
||||
return EPUB_HTML_TAG_PRESETS.find((preset) => preset.id === epubTagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
|
||||
}
|
||||
|
||||
const handleEpubAppendPreview = async () => {
|
||||
if (!epubFile || !novelId) return
|
||||
|
||||
@@ -170,8 +188,10 @@ function ChapterManager() {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", epubFile)
|
||||
formData.append("splitMode", epubSplitMode)
|
||||
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
|
||||
appendEpubSplitFormFields(formData, epubSplitMode, {
|
||||
chapterRegex: getEpubSourceRegex(),
|
||||
chapterTag: getEpubSourceTag(),
|
||||
})
|
||||
formData.append("chapterRegexPreset", epubRegexPreset)
|
||||
formData.append("appendTargetNovelId", novelId)
|
||||
formData.append("preview", "true")
|
||||
@@ -209,8 +229,10 @@ function ChapterManager() {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", epubFile)
|
||||
formData.append("splitMode", epubSplitMode)
|
||||
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
|
||||
appendEpubSplitFormFields(formData, epubSplitMode, {
|
||||
chapterRegex: getEpubSourceRegex(),
|
||||
chapterTag: getEpubSourceTag(),
|
||||
})
|
||||
formData.append("chapterRegexPreset", epubRegexPreset)
|
||||
formData.append("appendTargetNovelId", novelId)
|
||||
|
||||
@@ -256,7 +278,7 @@ function ChapterManager() {
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Xóa thất bại")
|
||||
if (!res.ok) throw new Error(data.error || data.detail || "Xóa thất bại")
|
||||
|
||||
toast.success(`Đã xóa thành công ${data.deletedCount} chương!`, { id: toastId })
|
||||
setOpenBulkDelete(false)
|
||||
@@ -429,7 +451,7 @@ function ChapterManager() {
|
||||
const handlePreviewOptimize = async () => {
|
||||
if (!novelId) return
|
||||
|
||||
if (!optRemovePrefix && !optRenumber) {
|
||||
if (!optRemovePrefix && !optRenumber && !optNormalizeTitles) {
|
||||
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
|
||||
return
|
||||
}
|
||||
@@ -446,6 +468,36 @@ function ChapterManager() {
|
||||
setOptimizeSourceChapters(allChapters)
|
||||
let newChapters = [...allChapters]
|
||||
|
||||
if (optNormalizeTitles) {
|
||||
const previewRes = await fetch("/api/mod/chuong/normalize-titles/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
novelId,
|
||||
overwriteGenericOnly: optNormalizeGenericOnly,
|
||||
}),
|
||||
})
|
||||
const previewData = await previewRes.json().catch(() => ({}))
|
||||
if (!previewRes.ok) {
|
||||
throw new Error(previewData.detail || previewData.error || "Không thể tạo gợi ý tiêu đề chương")
|
||||
}
|
||||
const titleById = new Map<string, string>(
|
||||
(previewData.items || []).map((item: { id: string; suggestedTitle: string }) => [
|
||||
item.id,
|
||||
item.suggestedTitle,
|
||||
]),
|
||||
)
|
||||
newChapters = newChapters.map((ch) => {
|
||||
const key = ch._id || ch.id
|
||||
const suggested = key ? titleById.get(key) : undefined
|
||||
return suggested ? { ...ch, title: suggested } : ch
|
||||
})
|
||||
const changeCount = Number(previewData.changeCount || 0)
|
||||
if (changeCount === 0) {
|
||||
toast.info("Không tìm thấy tiêu đề chương nào cần chuẩn hóa theo bộ lọc hiện tại")
|
||||
}
|
||||
}
|
||||
|
||||
if (optRenumber) {
|
||||
newChapters.sort((a, b) => a.number - b.number)
|
||||
newChapters = newChapters.map((ch, idx) => ({
|
||||
@@ -636,10 +688,10 @@ function ChapterManager() {
|
||||
<h3 className="font-semibold text-sm border-b pb-2">Giải Thuật Tách Chương</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<Label className="w-1/4">Phiên bản Text</Label>
|
||||
<RadioGroup value={epubSplitMode} onValueChange={(v: "toc" | "regex") => {
|
||||
<RadioGroup value={epubSplitMode} onValueChange={(v: EpubSplitMode) => {
|
||||
setEpubSplitMode(v)
|
||||
setEpubPreviewData(null)
|
||||
}} className="flex items-center gap-4">
|
||||
}} className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="toc" id="toc_mode_a" />
|
||||
<Label htmlFor="toc_mode_a" className="cursor-pointer font-normal">Mục lục (TOC)</Label>
|
||||
@@ -648,9 +700,50 @@ function ChapterManager() {
|
||||
<RadioGroupItem value="regex" id="regex_mode_a" />
|
||||
<Label htmlFor="regex_mode_a" className="cursor-pointer font-normal">Quy tắc (Regex)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="tag" id="tag_mode_a" />
|
||||
<Label htmlFor="tag_mode_a" className="cursor-pointer font-normal">Thẻ HTML</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{epubSplitMode === "tag" && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Thẻ HTML tách chương</Label>
|
||||
<Select value={epubTagPreset} onValueChange={(v) => {
|
||||
setEpubTagPreset(v)
|
||||
setEpubPreviewData(null)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Tùy chỉnh tên thẻ...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{epubTagPreset === "custom" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Tên thẻ (ví dụ: a, h2)</Label>
|
||||
<Input
|
||||
value={epubCustomTag}
|
||||
onChange={(e) => {
|
||||
setEpubCustomTag(e.target.value)
|
||||
setEpubPreviewData(null)
|
||||
}}
|
||||
placeholder="a"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{epubSplitMode === "regex" && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -696,12 +789,15 @@ function ChapterManager() {
|
||||
<div className="text-xs space-y-1 text-right">
|
||||
<p><span className="text-muted-foreground">Flow file HTML:</span> {epubPreviewData.parserInfo.sourceSections}</p>
|
||||
<p><span className="text-muted-foreground">Phát hiện:</span> {epubPreviewData.parserInfo.chaptersDetected} chương</p>
|
||||
{epubPreviewData.parserInfo.chapterTagUsed && (
|
||||
<p><span className="text-muted-foreground">Thẻ HTML:</span> <{epubPreviewData.parserInfo.chapterTagUsed}></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{epubPreviewData.chaptersPreview.length === 0 ? (
|
||||
<div className="p-4 text-center border border-dashed rounded bg-muted/40 text-muted-foreground text-sm">
|
||||
Không tách được chương nào. Xem lại Tùy chọn Tách / Mẫu Regex.
|
||||
Không tách được chương nào. Xem lại chế độ tách (TOC / Regex / Thẻ HTML).
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-2 bg-card">
|
||||
@@ -730,7 +826,7 @@ function ChapterManager() {
|
||||
<DialogFooter className="mt-auto pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setOpenEpubAppend(false)} disabled={appendingEpub}>Huỷ</Button>
|
||||
{!epubPreviewData ? (
|
||||
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub}>
|
||||
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub || (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim()) || (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim())}>
|
||||
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Xem trước dữ liệu
|
||||
</Button>
|
||||
@@ -887,6 +983,26 @@ function ChapterManager() {
|
||||
<p className="text-sm text-muted-foreground mt-1">Sắp xếp và gán lại số chương liên tục từ 1 đến N để sửa lỗi nhảy cóc</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" checked={optNormalizeTitles} onChange={(e) => setOptNormalizeTitles(e.target.checked)} />
|
||||
<div>
|
||||
<p className="font-medium text-base">Chuẩn hóa tiêu đề từ nội dung đầu chương</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Ví dụ: dòng <span className="line-through">Chương 8</span> + dòng <strong>Tất Cả Đều Là Ảo Giác</strong> → tiêu đề <strong>Tất Cả Đều Là Ảo Giác</strong>
|
||||
</p>
|
||||
{optNormalizeTitles && (
|
||||
<label className="mt-3 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded"
|
||||
checked={optNormalizeGenericOnly}
|
||||
onChange={(e) => setOptNormalizeGenericOnly(e.target.checked)}
|
||||
/>
|
||||
Chỉ sửa tiêu đề dạng "Chương N" (giữ tiêu đề đã đặt tay)
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto border rounded-lg my-4 custom-scrollbar">
|
||||
|
||||
@@ -6,6 +6,12 @@ import { Loader2 } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
appendEpubSplitFormFields,
|
||||
DEFAULT_EPUB_CHAPTER_TAG,
|
||||
EPUB_HTML_TAG_PRESETS,
|
||||
type EpubSplitMode,
|
||||
} from "@/lib/epub-split"
|
||||
|
||||
/** Đồng bộ với `MOD_EPUB_MAX_CHAPTERS` trên reader-api. */
|
||||
const BATCH_IMPORT_MAX_CHAPTERS = 4000
|
||||
@@ -46,10 +52,17 @@ function normalizeNovelTitle(raw: string): string {
|
||||
}
|
||||
|
||||
export function ImportBatchClient() {
|
||||
const [splitMode, setSplitMode] = useState<"toc" | "regex">("toc")
|
||||
const [splitMode, setSplitMode] = useState<EpubSplitMode>("toc")
|
||||
const [chapterStartPattern, setChapterStartPattern] = useState(
|
||||
"^\\s*(?:[#>*\\-\\[]\\s*)*(?:ch(?:u\\.?|ương|uong)?|chapter|hồi|hoi|quyển|quyen|phần|phan|tập|tap)\\s*\\d+(?:[\\.:\\-\\)]\\s*|\\s+).+$",
|
||||
)
|
||||
const [tagPreset, setTagPreset] = useState("a")
|
||||
const [customTag, setCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
|
||||
|
||||
const getChapterTag = () => {
|
||||
if (tagPreset === "custom") return customTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
|
||||
return EPUB_HTML_TAG_PRESETS.find((p) => p.id === tagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
|
||||
}
|
||||
const [replaceExisting, setReplaceExisting] = useState(false)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
@@ -142,8 +155,10 @@ export function ImportBatchClient() {
|
||||
|
||||
const formAi = new FormData()
|
||||
formAi.append("file", file)
|
||||
formAi.append("splitMode", splitMode)
|
||||
if (splitMode === "regex") formAi.append("chapterRegex", chapterStartPattern)
|
||||
appendEpubSplitFormFields(formAi, splitMode, {
|
||||
chapterRegex: chapterStartPattern,
|
||||
chapterTag: getChapterTag(),
|
||||
})
|
||||
formAi.append("title", title)
|
||||
formAi.append("authorName", author)
|
||||
|
||||
@@ -184,8 +199,10 @@ export function ImportBatchClient() {
|
||||
const formParse = new FormData()
|
||||
formParse.append("file", file)
|
||||
formParse.append("preview", "true")
|
||||
formParse.append("splitMode", splitMode)
|
||||
if (splitMode === "regex") formParse.append("chapterRegex", chapterStartPattern)
|
||||
appendEpubSplitFormFields(formParse, splitMode, {
|
||||
chapterRegex: chapterStartPattern,
|
||||
chapterTag: getChapterTag(),
|
||||
})
|
||||
formParse.append("enforceMaxChapters", "true")
|
||||
|
||||
const r3 = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: formParse, signal })
|
||||
@@ -220,15 +237,17 @@ export function ImportBatchClient() {
|
||||
fileName: displayPath,
|
||||
ok: false,
|
||||
resolvedTitle: title,
|
||||
error: "Bước 3: không tách được chương với cấu hình TOC/Regex hiện tại",
|
||||
error: "Bước 3: không tách được chương với cấu hình TOC/Regex/Thẻ HTML hiện tại",
|
||||
}
|
||||
}
|
||||
|
||||
const formImport = new FormData()
|
||||
formImport.append("file", file)
|
||||
formImport.append("preview", "false")
|
||||
formImport.append("splitMode", splitMode)
|
||||
if (splitMode === "regex") formImport.append("chapterRegex", chapterStartPattern)
|
||||
appendEpubSplitFormFields(formImport, splitMode, {
|
||||
chapterRegex: chapterStartPattern,
|
||||
chapterTag: getChapterTag(),
|
||||
})
|
||||
formImport.append("title", title)
|
||||
formImport.append("authorName", author)
|
||||
formImport.append("status", status)
|
||||
@@ -348,11 +367,12 @@ export function ImportBatchClient() {
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
value={splitMode}
|
||||
onChange={(e) => setSplitMode(e.target.value as "toc" | "regex")}
|
||||
onChange={(e) => setSplitMode(e.target.value as EpubSplitMode)}
|
||||
disabled={running}
|
||||
>
|
||||
<option value="toc">TOC</option>
|
||||
<option value="regex">Regex tiếng Việt</option>
|
||||
<option value="tag">Thẻ HTML</option>
|
||||
</select>
|
||||
{splitMode === "regex" && (
|
||||
<Input
|
||||
@@ -363,6 +383,30 @@ export function ImportBatchClient() {
|
||||
placeholder="Regex bắt đầu chương"
|
||||
/>
|
||||
)}
|
||||
{splitMode === "tag" && (
|
||||
<>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
value={tagPreset}
|
||||
onChange={(e) => setTagPreset(e.target.value)}
|
||||
disabled={running}
|
||||
>
|
||||
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
<option value="custom">Tùy chỉnh tên thẻ...</option>
|
||||
</select>
|
||||
{tagPreset === "custom" && (
|
||||
<Input
|
||||
className="max-w-[120px] font-mono text-xs"
|
||||
value={customTag}
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
disabled={running}
|
||||
placeholder="a, h2"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={replaceExisting} onChange={(e) => setReplaceExisting(e.target.checked)} disabled={running} />
|
||||
|
||||
@@ -7,6 +7,12 @@ import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
appendEpubSplitFormFields,
|
||||
DEFAULT_EPUB_CHAPTER_TAG,
|
||||
EPUB_HTML_TAG_PRESETS,
|
||||
type EpubSplitMode,
|
||||
} from "@/lib/epub-split"
|
||||
|
||||
type AssetItem = {
|
||||
id: string
|
||||
@@ -49,8 +55,15 @@ export function ImportClient() {
|
||||
const [selectedGenreIds, setSelectedGenreIds] = useState<string[]>([])
|
||||
const [genreQuery, setGenreQuery] = useState("")
|
||||
const [addingGenre, setAddingGenre] = useState(false)
|
||||
const [splitMode, setSplitMode] = useState<"toc" | "regex">("toc")
|
||||
const [splitMode, setSplitMode] = useState<EpubSplitMode>("toc")
|
||||
const [chapterStartPattern, setChapterStartPattern] = useState("^\\s*(?:[#>*\\-\\[]\\s*)*(?:ch(?:u\\.?|ương|uong)?|chapter|hồi|hoi|quyển|quyen|phần|phan|tập|tap)\\s*\\d+(?:[\\.:\\-\\)]\\s*|\\s+).+$")
|
||||
const [tagPreset, setTagPreset] = useState("a")
|
||||
const [customTag, setCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
|
||||
|
||||
const getChapterTag = () => {
|
||||
if (tagPreset === "custom") return customTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
|
||||
return EPUB_HTML_TAG_PRESETS.find((p) => p.id === tagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
|
||||
}
|
||||
const [replaceExisting, setReplaceExisting] = useState(false)
|
||||
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
@@ -242,8 +255,10 @@ export function ImportClient() {
|
||||
const form = new FormData()
|
||||
form.append("file", epubFile)
|
||||
form.append("preview", "true")
|
||||
form.append("splitMode", splitMode)
|
||||
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
|
||||
appendEpubSplitFormFields(form, splitMode, {
|
||||
chapterRegex: chapterStartPattern,
|
||||
chapterTag: getChapterTag(),
|
||||
})
|
||||
form.append("title", title)
|
||||
form.append("authorName", author)
|
||||
const res = await fetch("/api/mod/epub/ai-suggest", { method: "POST", credentials: "include", body: form })
|
||||
@@ -291,8 +306,10 @@ export function ImportClient() {
|
||||
const form = new FormData()
|
||||
form.append("file", epubFile)
|
||||
form.append("preview", "true")
|
||||
form.append("splitMode", splitMode)
|
||||
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
|
||||
appendEpubSplitFormFields(form, splitMode, {
|
||||
chapterRegex: chapterStartPattern,
|
||||
chapterTag: getChapterTag(),
|
||||
})
|
||||
const res = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: form })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.detail || "Parse preview thất bại")
|
||||
@@ -306,7 +323,7 @@ export function ImportClient() {
|
||||
const total = Number(data?.novel?.totalChapters || data?.chapterCount || 0)
|
||||
setChapterCount(total)
|
||||
if (total <= 0) {
|
||||
setParseError("Không tách được chương từ EPUB này với cấu hình hiện tại. Thử đổi TOC/Regex rồi parse lại.")
|
||||
setParseError("Không tách được chương từ EPUB này với cấu hình hiện tại. Thử đổi TOC / Regex / Thẻ HTML rồi parse lại.")
|
||||
}
|
||||
toast.success("Đã tạo preview chương")
|
||||
} catch (error) {
|
||||
@@ -324,8 +341,10 @@ export function ImportClient() {
|
||||
const form = new FormData()
|
||||
form.append("file", epubFile)
|
||||
form.append("preview", "false")
|
||||
form.append("splitMode", splitMode)
|
||||
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
|
||||
appendEpubSplitFormFields(form, splitMode, {
|
||||
chapterRegex: chapterStartPattern,
|
||||
chapterTag: getChapterTag(),
|
||||
})
|
||||
form.append("title", title)
|
||||
form.append("originalTitle", originalTitle)
|
||||
form.append("authorName", author)
|
||||
@@ -351,7 +370,7 @@ export function ImportClient() {
|
||||
setPreviewItems([])
|
||||
setChapterCount(0)
|
||||
setParseError("")
|
||||
}, [splitMode, chapterStartPattern])
|
||||
}, [splitMode, chapterStartPattern, tagPreset, customTag])
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 3 && asset) {
|
||||
@@ -523,13 +542,27 @@ export function ImportClient() {
|
||||
<section className="space-y-3 rounded-xl border p-4">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 p-3">
|
||||
<label className="text-sm font-medium">Tách chương:</label>
|
||||
<select className="rounded border px-2 py-1 text-sm" value={splitMode} onChange={(e) => setSplitMode(e.target.value as "toc" | "regex")}>
|
||||
<select className="rounded border px-2 py-1 text-sm" value={splitMode} onChange={(e) => setSplitMode(e.target.value as EpubSplitMode)}>
|
||||
<option value="toc">TOC (lọc intro/mục lục)</option>
|
||||
<option value="regex">Regex tiếng Việt</option>
|
||||
<option value="tag">Thẻ HTML</option>
|
||||
</select>
|
||||
{splitMode === "regex" && (
|
||||
<Input value={chapterStartPattern} onChange={(e) => setChapterStartPattern(e.target.value)} placeholder="Regex bắt đầu chương" />
|
||||
)}
|
||||
{splitMode === "tag" && (
|
||||
<>
|
||||
<select className="rounded border px-2 py-1 text-sm" value={tagPreset} onChange={(e) => setTagPreset(e.target.value)}>
|
||||
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
<option value="custom">Tùy chỉnh tên thẻ...</option>
|
||||
</select>
|
||||
{tagPreset === "custom" && (
|
||||
<Input value={customTag} onChange={(e) => setCustomTag(e.target.value)} placeholder="Ví dụ: a, h2" className="max-w-[120px]" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Preview chapters</h2>
|
||||
|
||||
@@ -18,7 +18,13 @@ import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { MOD_AI_PREFILL_STORAGE_KEY, type AINovelPrefillPayload } from "@/lib/mod-ai-tools"
|
||||
import {
|
||||
appendEpubSplitFormFields,
|
||||
DEFAULT_EPUB_CHAPTER_TAG,
|
||||
EPUB_HTML_TAG_PRESETS,
|
||||
type EpubSplitMode,
|
||||
splitModeLabel,
|
||||
} from "@/lib/epub-split"
|
||||
|
||||
interface Novel {
|
||||
id: string
|
||||
@@ -37,13 +43,14 @@ interface Genre {
|
||||
|
||||
interface EpubPreviewData {
|
||||
fileName: string
|
||||
splitMode: "toc" | "regex"
|
||||
splitMode: EpubSplitMode
|
||||
detectedStructureType: "standard" | "light_novel"
|
||||
hasCoverFromEpub?: boolean
|
||||
coverPreviewDataUrl?: string | null
|
||||
parserInfo?: {
|
||||
splitMode: "toc" | "regex"
|
||||
splitMode: EpubSplitMode
|
||||
chapterRegexUsed?: string | null
|
||||
chapterTagUsed?: string | null
|
||||
regexPreset?: string | null
|
||||
sourceSections: number
|
||||
chaptersDetected: number
|
||||
@@ -125,9 +132,11 @@ export function NovelClient() {
|
||||
const [epubTitle, setEpubTitle] = useState("")
|
||||
const [epubAuthorName, setEpubAuthorName] = useState("")
|
||||
const [epubDescription, setEpubDescription] = useState("")
|
||||
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("toc")
|
||||
const [epubSplitMode, setEpubSplitMode] = useState<EpubSplitMode>("toc")
|
||||
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong_hoi")
|
||||
const [epubCustomRegex, setEpubCustomRegex] = useState("")
|
||||
const [epubTagPreset, setEpubTagPreset] = useState<string>("a")
|
||||
const [epubCustomTag, setEpubCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
|
||||
const epubInputRef = useRef<HTMLInputElement>(null)
|
||||
const epubFolderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -167,7 +176,6 @@ export function NovelClient() {
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [bulkProgress, setBulkProgress] = useState<Record<string, BulkUploadProgressItem>>({})
|
||||
const [bulkDuplicateHandling, setBulkDuplicateHandling] = useState<BulkDuplicateHandling>("ask")
|
||||
const [pendingAIPrefill, setPendingAIPrefill] = useState<AINovelPrefillPayload | null>(null)
|
||||
|
||||
const getSelectedChapterRegex = () => {
|
||||
if (epubRegexPreset === "custom") {
|
||||
@@ -177,6 +185,13 @@ export function NovelClient() {
|
||||
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
|
||||
}
|
||||
|
||||
const getSelectedChapterTag = () => {
|
||||
if (epubTagPreset === "custom") {
|
||||
return epubCustomTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
|
||||
}
|
||||
return EPUB_HTML_TAG_PRESETS.find((preset) => preset.id === epubTagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
|
||||
}
|
||||
|
||||
const normalizeEpubFiles = (files: File[]) => {
|
||||
return files.filter((file) => file.name.toLowerCase().endsWith(".epub"))
|
||||
}
|
||||
@@ -338,79 +353,6 @@ export function NovelClient() {
|
||||
setGenreQuery("")
|
||||
}
|
||||
|
||||
const applyAIPrefillToAddForm = (prefill: AINovelPrefillPayload) => {
|
||||
const nextTitle = prefill.title?.trim() || ""
|
||||
const nextAuthor = prefill.authorName?.trim() || ""
|
||||
|
||||
setTitle(nextTitle)
|
||||
setOriginalTitle((prefill.originalTitle || nextTitle).trim())
|
||||
setAuthorName(nextAuthor)
|
||||
setOriginalAuthorName((prefill.originalAuthorName || nextAuthor).trim())
|
||||
setDescription((prefill.description || "").trim())
|
||||
setCoverUrl((prefill.coverUrl || "").trim())
|
||||
setGenreQuery("")
|
||||
|
||||
const validStatus = ["Đang ra", "Hoàn thành", "Tạm ngưng"].includes(prefill.status || "")
|
||||
? (prefill.status as "Đang ra" | "Hoàn thành" | "Tạm ngưng")
|
||||
: "Đang ra"
|
||||
setStatus(validStatus)
|
||||
|
||||
const suggestedGenres = Array.isArray(prefill.genresSuggested) ? prefill.genresSuggested : []
|
||||
if (suggestedGenres.length === 0 || genres.length === 0) {
|
||||
setSelectedGenres([])
|
||||
return
|
||||
}
|
||||
|
||||
const byName = new Map(genres.map((genre) => [normalizeGenreName(genre.name), genre.id]))
|
||||
const pickedIds: string[] = []
|
||||
const missing: string[] = []
|
||||
|
||||
for (const name of suggestedGenres) {
|
||||
const id = byName.get(normalizeGenreName(name))
|
||||
if (!id) {
|
||||
missing.push(name)
|
||||
continue
|
||||
}
|
||||
if (!pickedIds.includes(id)) pickedIds.push(id)
|
||||
}
|
||||
|
||||
setSelectedGenres(pickedIds)
|
||||
|
||||
if (missing.length > 0) {
|
||||
toast.info(`The loai goi y chua co san: ${missing.slice(0, 4).join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const raw = window.localStorage.getItem(MOD_AI_PREFILL_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
|
||||
window.localStorage.removeItem(MOD_AI_PREFILL_STORAGE_KEY)
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AINovelPrefillPayload
|
||||
setPendingAIPrefill(parsed)
|
||||
setOpenAdd(true)
|
||||
} catch {
|
||||
toast.error("Du lieu AI Tool khong hop le")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingAIPrefill) return
|
||||
|
||||
const needsGenres = Array.isArray(pendingAIPrefill.genresSuggested)
|
||||
&& pendingAIPrefill.genresSuggested.length > 0
|
||||
&& genres.length === 0
|
||||
|
||||
if (needsGenres) return
|
||||
|
||||
applyAIPrefillToAddForm(pendingAIPrefill)
|
||||
setPendingAIPrefill(null)
|
||||
toast.success("Da nap du lieu de xuat tu AI Tool")
|
||||
}, [pendingAIPrefill, genres])
|
||||
|
||||
const filteredNovels = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
if (!keyword) return novels
|
||||
@@ -739,7 +681,7 @@ export function NovelClient() {
|
||||
const requestEpubPreview = async (
|
||||
file: File,
|
||||
options?: {
|
||||
splitMode?: "toc" | "regex"
|
||||
splitMode?: EpubSplitMode
|
||||
regexPreset?: string
|
||||
regexInput?: string
|
||||
preserveEditedMetadata?: boolean
|
||||
@@ -758,11 +700,12 @@ export function NovelClient() {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("preview", "true")
|
||||
formData.append("splitMode", splitMode)
|
||||
|
||||
appendEpubSplitFormFields(formData, splitMode, {
|
||||
chapterRegex: regexInput,
|
||||
chapterTag: getSelectedChapterTag(),
|
||||
})
|
||||
if (splitMode === "regex") {
|
||||
formData.append("chapterRegexPreset", regexPreset)
|
||||
formData.append("chapterRegex", regexInput)
|
||||
}
|
||||
|
||||
const res = await fetch("/api/mod/epub", {
|
||||
@@ -873,6 +816,10 @@ export function NovelClient() {
|
||||
toast.error("Vui lòng nhập regex tùy chỉnh trước khi tải lên")
|
||||
return
|
||||
}
|
||||
if (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim()) {
|
||||
toast.error("Vui lòng nhập tên thẻ HTML trước khi tải lên")
|
||||
return
|
||||
}
|
||||
|
||||
setUploadingEpub(true)
|
||||
const formData = new FormData()
|
||||
@@ -880,12 +827,13 @@ export function NovelClient() {
|
||||
formData.append("title", epubTitle)
|
||||
formData.append("authorName", epubAuthorName)
|
||||
formData.append("description", epubDescription)
|
||||
formData.append("splitMode", epubSplitMode)
|
||||
appendEpubSplitFormFields(formData, epubSplitMode, {
|
||||
chapterRegex: epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex(),
|
||||
chapterTag: getSelectedChapterTag(),
|
||||
})
|
||||
|
||||
if (epubSplitMode === "regex") {
|
||||
const selectedRegex = epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex()
|
||||
formData.append("chapterRegexPreset", epubRegexPreset)
|
||||
formData.append("chapterRegex", selectedRegex)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1286,7 +1234,8 @@ export function NovelClient() {
|
||||
<>
|
||||
<p>
|
||||
<span className="font-semibold">Parser:</span>{" "}
|
||||
{epubPreviewData.parserInfo.splitMode === "regex" ? "Regex" : "TOC"}
|
||||
{splitModeLabel(epubPreviewData.parserInfo.splitMode)}
|
||||
{epubPreviewData.parserInfo.chapterTagUsed ? ` (${epubPreviewData.parserInfo.chapterTagUsed})` : ""}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold">Nguồn phân tích:</span>{" "}
|
||||
@@ -1310,6 +1259,12 @@ export function NovelClient() {
|
||||
{epubPreviewData.parserInfo.chapterRegexUsed}
|
||||
</p>
|
||||
)}
|
||||
{epubPreviewData.parserInfo.chapterTagUsed && (
|
||||
<p>
|
||||
<span className="font-semibold">Thẻ HTML dùng:</span>{" "}
|
||||
<{epubPreviewData.parserInfo.chapterTagUsed}>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1321,14 +1276,31 @@ export function NovelClient() {
|
||||
<label className="text-xs font-medium text-muted-foreground">Chế độ tách chương</label>
|
||||
<select
|
||||
value={epubSplitMode}
|
||||
onChange={(e) => setEpubSplitMode(e.target.value as "toc" | "regex")}
|
||||
onChange={(e) => setEpubSplitMode(e.target.value as EpubSplitMode)}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="toc">Theo TOC trong EPUB</option>
|
||||
<option value="regex">Theo Regex</option>
|
||||
<option value="tag">Theo thẻ HTML</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{epubSplitMode === "tag" && (
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Thẻ tách chương</label>
|
||||
<select
|
||||
value={epubTagPreset}
|
||||
onChange={(e) => setEpubTagPreset(e.target.value)}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
<option value="custom">Tự nhập tên thẻ</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{epubSplitMode === "regex" && (
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Preset Regex</label>
|
||||
@@ -1365,12 +1337,35 @@ export function NovelClient() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{epubSplitMode === "tag" && (
|
||||
<>
|
||||
{epubTagPreset !== "custom" ? (
|
||||
<div className="rounded-md bg-muted/40 p-2 text-xs">
|
||||
Mỗi thẻ <{getSelectedChapterTag()}> mở đầu một chương. Tiêu đề lấy từ nội dung trong thẻ (nếu có).
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Tên thẻ HTML</label>
|
||||
<Input
|
||||
value={epubCustomTag}
|
||||
onChange={(e) => setEpubCustomTag(e.target.value)}
|
||||
placeholder="Ví dụ: a, h2, h1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleReparseEpub}
|
||||
disabled={previewingEpub || (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim())}
|
||||
disabled={
|
||||
previewingEpub
|
||||
|| (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim())
|
||||
|| (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim())
|
||||
}
|
||||
>
|
||||
{previewingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Phân tích lại theo cấu hình
|
||||
|
||||
@@ -68,6 +68,9 @@ async function initializeGoogleIdentity(clientId: string): Promise<void> {
|
||||
|
||||
if (googleInitializedClientId === clientId) return
|
||||
|
||||
const hostname = typeof window !== "undefined" ? window.location.hostname : ""
|
||||
const isLocalHost = hostname === "localhost" || hostname === "127.0.0.1"
|
||||
|
||||
// Re-initialize with new clientId (or first time)
|
||||
googleApi.initialize({
|
||||
client_id: clientId,
|
||||
@@ -86,7 +89,8 @@ async function initializeGoogleIdentity(clientId: string): Promise<void> {
|
||||
},
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
use_fedcm_for_prompt: true,
|
||||
// FedCM on localhost often yields tokens backend cannot verify; production keeps FedCM.
|
||||
use_fedcm_for_prompt: !isLocalHost,
|
||||
})
|
||||
|
||||
googleInitializedClientId = clientId
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export type EpubSplitMode = "toc" | "regex" | "tag"
|
||||
|
||||
export const EPUB_HTML_TAG_PRESETS = [
|
||||
{ id: "a", name: "Thẻ <a> (anchor / mục lục liên kết)", tag: "a" },
|
||||
{ id: "h2", name: "Thẻ <h2>", tag: "h2" },
|
||||
{ id: "h1", name: "Thẻ <h1>", tag: "h1" },
|
||||
{ id: "h3", name: "Thẻ <h3>", tag: "h3" },
|
||||
{ id: "p", name: "Thẻ <p>", tag: "p" },
|
||||
] as const
|
||||
|
||||
export const DEFAULT_EPUB_CHAPTER_TAG = "a"
|
||||
|
||||
export function splitModeLabel(mode: string | undefined): string {
|
||||
if (mode === "regex") return "Regex"
|
||||
if (mode === "tag") return "Thẻ HTML"
|
||||
return "TOC"
|
||||
}
|
||||
|
||||
export function appendEpubSplitFormFields(
|
||||
form: FormData,
|
||||
splitMode: EpubSplitMode,
|
||||
options?: { chapterRegex?: string; chapterTag?: string },
|
||||
) {
|
||||
form.append("splitMode", splitMode)
|
||||
if (splitMode === "regex" && options?.chapterRegex) {
|
||||
form.append("chapterRegex", options.chapterRegex)
|
||||
}
|
||||
if (splitMode === "tag") {
|
||||
form.append("chapterTag", (options?.chapterTag || DEFAULT_EPUB_CHAPTER_TAG).trim() || DEFAULT_EPUB_CHAPTER_TAG)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export const MOD_AI_PREFILL_STORAGE_KEY = "mod:ai-tool:novel-prefill"
|
||||
export const MOD_AI_MODEL_STORAGE_KEY = "mod:ai-tool:model"
|
||||
export const MOD_AI_WEB_DEFAULT_MODEL = "gpt-4o-mini-search-preview"
|
||||
|
||||
export const MOD_AI_WEB_MODEL_OPTIONS = [
|
||||
{
|
||||
value: "gpt-4o-mini-search-preview",
|
||||
label: "gpt-4o-mini-search-preview (nhanh)",
|
||||
},
|
||||
{
|
||||
value: "gpt-4o-search-preview",
|
||||
label: "gpt-4o-search-preview (chat luong cao)",
|
||||
},
|
||||
] as const
|
||||
|
||||
export type AINovelPrefillPayload = {
|
||||
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[]
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
require('dotenv').config({ path: '.env' });
|
||||
|
||||
async function fetchJsonWithTimeout(url, init, timeoutMs = 25000) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 500)}`);
|
||||
}
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = "Please reply with { \"ready\": true } in JSON";
|
||||
|
||||
async function tryGoogle() {
|
||||
const apiKey = process.env.GOOGLE_AI_KEY;
|
||||
if (!apiKey) return console.log("Google: No key");
|
||||
try {
|
||||
const data = await fetchJsonWithTimeout(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
tools: [{ googleSearch: {} }],
|
||||
generationConfig: { temperature: 0.2, maxOutputTokens: 1400 },
|
||||
}),
|
||||
}
|
||||
);
|
||||
console.log("Google Success:", JSON.stringify(data).slice(0, 100));
|
||||
} catch(e) { console.log("Google Error:", e.stack || e.message); }
|
||||
}
|
||||
|
||||
async function tryDeepSeek() {
|
||||
const apiKey = process.env.DEEKSEEK_KEY || process.env.DEEPSEEK_KEY;
|
||||
if (!apiKey) return console.log("DeepSeek: No key");
|
||||
try {
|
||||
const data = await fetchJsonWithTimeout(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
temperature: 0.2,
|
||||
max_tokens: 500,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
}
|
||||
);
|
||||
console.log("DeepSeek Success:", JSON.stringify(data).slice(0,100));
|
||||
} catch(e) { console.log("DeepSeek Error:", e.stack || e.message); }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await tryGoogle();
|
||||
await tryDeepSeek();
|
||||
}
|
||||
run();
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user