fix: tự động chạy ai

This commit is contained in:
2026-07-10 07:24:34 +07:00
parent c3a69d145b
commit f4322ff815
16 changed files with 529 additions and 164 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ def create_main_ui():
from ui.projects_tab import build_projects_tab
from ui.settings_tab import build_settings_tab
build_create_tab()
build_create_tab(app)
build_continue_tab()
build_rewrite_tab()
build_polish_tab()
+1 -1
View File
@@ -1,3 +1,3 @@
{
"settings_password": "TiniX@123"
"settings_password": ""
}
+5 -2
View File
@@ -433,11 +433,14 @@ class ConfigManager:
success, msg = self.save()
return success, msg if not success else t("config.backend_add_success")
def update_backend(self, name: str, **kwargs) -> tuple[bool, str]:
def update_backend(self, name: str, new_name: str = None, **kwargs) -> tuple[bool, str]:
"""Cập nhật cấu hình phụ trợ"""
for backend in self.backends:
if backend.name == name:
for key, value in kwargs.items():
updates = dict(kwargs)
if new_name is not None:
updates["name"] = new_name
for key, value in updates.items():
if hasattr(backend, key):
setattr(backend, key, value)
+2 -2
View File
@@ -101,11 +101,11 @@ class ConfigAPIManager:
}
@staticmethod
def update_backend(name: str, **kwargs) -> Dict[str, Any]:
def update_backend(name: str, new_name: str = None, **kwargs) -> Dict[str, Any]:
"""Cập nhật cấu hình backend"""
try:
config = get_config()
success, msg = config.update_backend(name, **kwargs)
success, msg = config.update_backend(name, new_name=new_name, **kwargs)
if success:
logger.info(f"Backend updated: {name}")
+1
View File
@@ -0,0 +1 @@
{"genre": null, "sub_genres": null, "style": null, "title": "", "suggest_title_prompt": "", "num_main": 2, "num_sub": 3, "character": "", "suggest_char_prompt": null, "world": "", "suggest_world_prompt": null, "plot": "", "suggest_plot_prompt": null, "custom_outline": null, "total_chapters": 20, "memory_type": "Toàn văn", "memory_chapters": 3, "use_reflection": false, "outline": null}
Binary file not shown.
Binary file not shown.
+6 -4
View File
@@ -9,12 +9,14 @@ services:
container_name: ai-novel-generator
restart: unless-stopped
ports:
- "8000:8000"
- "7860:7860" # Gradio Web UI
- "8000:8000" # FastAPI
environment:
# Cấu hình cơ bản
- MODE=production
- HOST=0.0.0.0
- PORT=8000
- NOVEL_TOOL_PORT=7860
- CONCURRENCY=4
# Cấu hình log
@@ -81,7 +83,7 @@ services:
container_name: ai-novel-redis
restart: unless-stopped
ports:
- "6379:6379"
- "6380:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
@@ -99,8 +101,8 @@ services:
container_name: ai-novel-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "9980:80"
- "9443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
-12
View File
@@ -73,7 +73,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1796,7 +1795,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1856,7 +1854,6 @@
"integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0",
@@ -2382,7 +2379,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2726,7 +2722,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3304,7 +3299,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3490,7 +3484,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5715,7 +5708,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5725,7 +5717,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6427,7 +6418,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6590,7 +6580,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6866,7 +6855,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+304 -49
View File
@@ -1,34 +1,146 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { api } from "@/services/api";
export default function CreatePage() {
const [genres, setGenres] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
const DRAFT_KEY = "tinix_create_draft";
const DEFAULT_FORM = {
title: "",
genre: "",
sub_genres: [] as string[],
style: "",
character_setting: "",
world_setting: "",
plot_idea: "",
custom_outline_prompt: "",
total_chapters: 20
});
const [outline, setOutline] = useState<string>("");
};
function loadDraft(): { form: typeof DEFAULT_FORM; outline: string } {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
form: { ...DEFAULT_FORM, ...(parsed.form || {}) },
outline: parsed.outline || ""
};
}
} catch {
// ignore corrupted draft
}
return { form: { ...DEFAULT_FORM }, outline: "" };
}
export default function CreatePage() {
const initial = useRef(loadDraft()).current;
const [genres, setGenres] = useState<string[]>([]);
const [subGenres, setSubGenres] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState(initial.form);
const [outline, setOutline] = useState<string>(initial.outline);
const [statusMsg, setStatusMsg] = useState<string>("");
useEffect(() => {
// Fetch genres on mount
api.getGenres().then((data) => {
setGenres(data || []);
if (data && data.length > 0) {
setFormData((prev) => ({ ...prev, genre: data[0].name }));
}
}).catch(err => {
console.error("Error fetching genres:", err);
// Per-field AI suggestion UI state
const [suggestPrompt, setSuggestPrompt] = useState({
title: "",
char: "",
world: "",
plot: ""
});
const [suggesting, setSuggesting] = useState<{ [k: string]: boolean }>({});
// Persist to localStorage on every change (fixes reload content loss)
useEffect(() => {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: formData, outline }));
}, [formData, outline]);
// Fetch genres / styles on mount (do NOT overwrite persisted selections)
useEffect(() => {
api.getGenres().then((data) => {
const names = (data || []).map((g: any) => g.name ?? g);
setGenres(names);
if (!formData.genre && names.length > 0) {
setFormData((prev) => ({ ...prev, genre: names[0] }));
}
}).catch((err) => console.error("Error fetching genres:", err));
api.getStyles().then((data) => {
const names = (data || []).map((s: any) => s.name ?? s);
setStyles(names);
if (!formData.style && names.length > 0) {
setFormData((prev) => ({ ...prev, style: names[0] }));
}
}).catch((err) => console.error("Error fetching styles:", err));
}, []);
// Load sub-genres when genre changes
useEffect(() => {
if (!formData.genre) return;
api.getSubGenres(formData.genre).then((data) => {
const names = (data || []).map((s: any) => s.name ?? s);
setSubGenres(names);
setFormData((prev) => ({
...prev,
sub_genres: prev.sub_genres.filter((sg) => names.includes(sg))
}));
}).catch(() => setSubGenres([]));
}, [formData.genre]);
const toggleSubGenre = (sg: string) => {
setFormData((prev) => {
const has = prev.sub_genres.includes(sg);
return {
...prev,
sub_genres: has
? prev.sub_genres.filter((x) => x !== sg)
: [...prev.sub_genres, sg]
};
});
};
const handleSuggest = async (
type: 'title' | 'char' | 'world' | 'plot'
) => {
setSuggesting((s) => ({ ...s, [type]: true }));
setStatusMsg("");
try {
const res = await api.suggest({
type,
genre: formData.genre,
sub_genres: formData.sub_genres,
title: formData.title,
character_setting: formData.character_setting,
world_setting: formData.world_setting,
custom_prompt: suggestPrompt[type],
num_main_chars: 2,
num_sub_chars: 3
});
const content = res.content || "";
if (type === 'title') {
// suggestion returns a list of titles -> pick the first
const first = content.split("\n").map((s: string) => s.trim()).filter(Boolean)[0] || content;
setFormData((prev) => ({ ...prev, title: first }));
} else if (type === 'char') {
setFormData((prev) => ({ ...prev, character_setting: content }));
} else if (type === 'world') {
setFormData((prev) => ({ ...prev, world_setting: content }));
} else if (type === 'plot') {
setFormData((prev) => ({ ...prev, plot_idea: content }));
}
setStatusMsg(res.message || "Gợi ý đã được điền.");
} catch (err: any) {
setStatusMsg("Lỗi gợi ý: " + err.message);
} finally {
setSuggesting((s) => ({ ...s, [type]: false }));
}
};
const handleGenerateOutline = async () => {
try {
setLoading(true);
@@ -38,11 +150,12 @@ export default function CreatePage() {
const res = await api.generateOutline({
title: formData.title,
genre: formData.genre,
sub_genres: [],
sub_genres: formData.sub_genres,
total_chapters: formData.total_chapters,
character_setting: formData.character_setting,
world_setting: formData.world_setting,
plot_idea: formData.plot_idea
plot_idea: formData.plot_idea,
custom_outline_prompt: formData.custom_outline_prompt
});
setOutline(res.content || "");
@@ -54,26 +167,32 @@ export default function CreatePage() {
}
};
const handleClearDraft = () => {
localStorage.removeItem(DRAFT_KEY);
setFormData({ ...DEFAULT_FORM, genre: formData.genre, style: formData.style });
setOutline("");
setSuggestPrompt({ title: "", char: "", world: "", plot: "" });
setStatusMsg("");
};
return (
<div className="flex flex-col flex-1 p-8">
<div className="mb-8">
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Create Story</h1>
<p className="text-zinc-500 mt-2">Design your novel settings and generate an outline.</p>
</div>
<button
onClick={handleClearDraft}
className="text-xs px-3 py-2 rounded-md border border-border-glass text-zinc-400 hover:text-white hover:border-brand-primary transition-colors"
>
Clear draft
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Form Column */}
<div className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Novel Title</label>
<input
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
placeholder="Enter your novel title..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Genre</label>
<select
@@ -81,39 +200,121 @@ export default function CreatePage() {
value={formData.genre}
onChange={(e) => setFormData({ ...formData, genre: e.target.value })}
>
{genres.map(g => (
<option key={g.name} value={g.name}>{g.name}</option>
{genres.length === 0 && <option value="">-- no genres --</option>}
{genres.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</div>
{subGenres.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Character Settings</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={formData.character_setting}
onChange={(e) => setFormData({...formData, character_setting: e.target.value})}
<label className="text-sm font-medium text-zinc-300">Sub-genres</label>
<div className="flex flex-wrap gap-2">
{subGenres.map((sg) => {
const active = formData.sub_genres.includes(sg);
return (
<button
key={sg}
onClick={() => toggleSubGenre(sg)}
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
active
? "bg-brand-primary text-black border-brand-primary"
: "border-border-glass text-zinc-400 hover:text-white"
}`}
>
{sg}
</button>
);
})}
</div>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Writing Style</label>
<select
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={formData.style}
onChange={(e) => setFormData({ ...formData, style: e.target.value })}
>
{styles.length === 0 && <option value="">-- no styles --</option>}
{styles.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<AiField
label="Novel Title"
placeholder="Enter your novel title..."
value={formData.title}
onChange={(v) => setFormData({ ...formData, title: v })}
promptValue={suggestPrompt.title}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, title: v }))}
onSuggest={() => handleSuggest("title")}
suggesting={suggesting.title}
promptPlaceholder="Optional prompt for AI title suggestion..."
/>
<AiField
label="Character Settings"
placeholder="Describe main characters, personalities..."
value={formData.character_setting}
onChange={(v) => setFormData({ ...formData, character_setting: v })}
promptValue={suggestPrompt.char}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, char: v }))}
onSuggest={() => handleSuggest("char")}
suggesting={suggesting.char}
promptPlaceholder="Optional prompt for AI character suggestion..."
textarea
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">World Setting</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={formData.world_setting}
onChange={(e) => setFormData({...formData, world_setting: e.target.value})}
<AiField
label="World Setting"
placeholder="Describe the world, magic system, laws..."
value={formData.world_setting}
onChange={(v) => setFormData({ ...formData, world_setting: v })}
promptValue={suggestPrompt.world}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, world: v }))}
onSuggest={() => handleSuggest("world")}
suggesting={suggesting.world}
promptPlaceholder="Optional prompt for AI world suggestion..."
textarea
/>
<AiField
label="Plot Idea"
placeholder="Main conflict, goals, and ending idea..."
value={formData.plot_idea}
onChange={(v) => setFormData({ ...formData, plot_idea: v })}
promptValue={suggestPrompt.plot}
onPromptChange={(v) => setSuggestPrompt((p) => ({ ...p, plot: v }))}
onSuggest={() => handleSuggest("plot")}
suggesting={suggesting.plot}
promptPlaceholder="Optional prompt for AI plot suggestion..."
textarea
/>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Custom Outline Prompt (optional)</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[80px] focus:border-brand-primary outline-none text-white"
value={formData.custom_outline_prompt}
onChange={(e) => setFormData({ ...formData, custom_outline_prompt: e.target.value })}
placeholder="Extra instructions for the outline..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">Plot Idea</label>
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={formData.plot_idea}
onChange={(e) => setFormData({...formData, plot_idea: e.target.value})}
placeholder="Main conflict, goals, and ending idea..."
<label className="text-sm font-medium text-zinc-300">Total Chapters</label>
<input
type="number"
min={1}
max={200}
className="w-32 bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={formData.total_chapters}
onChange={(e) => setFormData({ ...formData, total_chapters: Number(e.target.value) })}
/>
</div>
@@ -147,3 +348,57 @@ export default function CreatePage() {
</div>
);
}
interface AiFieldProps {
label: string;
placeholder: string;
value: string;
onChange: (v: string) => void;
promptValue: string;
onPromptChange: (v: string) => void;
onSuggest: () => void;
suggesting: boolean;
promptPlaceholder: string;
textarea?: boolean;
}
function AiField({
label, placeholder, value, onChange,
promptValue, onPromptChange, onSuggest, suggesting, promptPlaceholder, textarea
}: AiFieldProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-300">{label}</label>
{textarea ? (
<textarea
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm min-h-[100px] focus:border-brand-primary outline-none text-white"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
) : (
<input
className="w-full bg-background-card border border-border-glass rounded-md p-2 text-sm focus:border-brand-primary outline-none text-white"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
)}
<div className="flex gap-2 items-center">
<input
className="flex-1 bg-background-card border border-border-glass rounded-md p-2 text-xs focus:border-brand-primary outline-none text-zinc-300"
value={promptValue}
onChange={(e) => onPromptChange(e.target.value)}
placeholder={promptPlaceholder}
/>
<button
onClick={onSuggest}
disabled={suggesting}
className="px-3 py-2 text-xs rounded-md bg-brand-primary text-black font-medium hover:bg-brand-secondary transition-colors disabled:opacity-50 whitespace-nowrap"
>
{suggesting ? "AI..." : "AI gợi ý"}
</button>
</div>
</div>
);
}
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
+36 -1
View File
@@ -20,6 +20,18 @@ export interface OutlineReq {
custom_outline_prompt?: string;
}
export interface SuggestionReq {
type: 'title' | 'char' | 'world' | 'plot';
genre: string;
sub_genres?: string[];
title?: string;
character_setting?: string;
world_setting?: string;
custom_prompt?: string;
num_main_chars?: number;
num_sub_chars?: number;
}
export const api = {
// Check health
async checkHealth() {
@@ -48,7 +60,30 @@ export const api = {
return res.json();
},
// Generate outline
// Get sub-genres for a genre
async getSubGenres(genre: string) {
const res = await fetch(`${API_BASE_URL}/sub-genres/by-genre/${encodeURIComponent(genre)}`);
if (!res.ok) throw new Error('Failed to fetch sub-genres');
return res.json();
},
// Get writing styles
async getStyles() {
const res = await fetch(`${API_BASE_URL}/styles`);
if (!res.ok) throw new Error('Failed to fetch styles');
return res.json();
},
// Get AI suggestion (title / char / world / plot)
async suggest(data: SuggestionReq) {
const res = await fetch(`${API_BASE_URL}/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Suggestion failed');
return res.json();
},
async generateOutline(data: OutlineReq) {
const res = await fetch(`${API_BASE_URL}/generate-outline`, {
method: 'POST',
+1 -1
View File
@@ -599,7 +599,7 @@ async def config_add_backend(req: BackendReq):
@app.put("/config/backends/{name}")
async def config_update_backend(name: str, req: BackendReq):
result = ConfigAPIManager.update_backend(
name, name=req.name, type=req.type, base_url=req.base_url,
name, new_name=req.name, type=req.type, base_url=req.base_url,
api_key=req.api_key, model=req.model, timeout=req.timeout
)
if result["success"]:
+8
View File
@@ -0,0 +1,8 @@
server {
listen 8000;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
+139 -68
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import json
import re
import traceback
import os
from locales.i18n import t
from services.novel_generator import OutlineParser
@@ -15,7 +16,48 @@ import logging
logger = logging.getLogger(__name__)
def build_create_tab():
# ==================== DRAFT PERSISTENCE ====================
DRAFT_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "data", "create_draft.json"
)
DRAFT_FIELDS = [
"genre", "sub_genres", "style", "title", "suggest_title_prompt",
"num_main", "num_sub", "character", "suggest_char_prompt",
"world", "suggest_world_prompt", "plot", "suggest_plot_prompt",
"custom_outline", "total_chapters", "memory_type", "memory_chapters",
"use_reflection", "outline",
]
def _save_create_draft(d: dict):
try:
os.makedirs(os.path.dirname(DRAFT_PATH), exist_ok=True)
with open(DRAFT_PATH, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False)
except Exception as e:
logger.error(f"Save create draft failed: {e}")
def _load_create_draft() -> dict:
try:
if os.path.exists(DRAFT_PATH):
with open(DRAFT_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Load create draft failed: {e}")
return {}
def _clear_create_draft():
try:
if os.path.exists(DRAFT_PATH):
os.remove(DRAFT_PATH)
except Exception as e:
logger.error(f"Clear create draft failed: {e}")
def build_create_tab(demo=None):
"""Xây dựng giao diện Tab Sáng tác từ đầu"""
with gr.Tab(t("tabs.create")):
gr.Markdown(f"### 🪄 {t('create.header')}")
@@ -74,51 +116,51 @@ def build_create_tab():
character_input = gr.Textbox(
label=t("create.char_setting"),
placeholder=t("create.char_setting_placeholder"),
lines=5, interactive=False
lines=5, interactive=True
)
with gr.Group():
with gr.Row():
num_main_chars = gr.Number(label=t("create.num_main_chars"), value=2, minimum=1, maximum=10, step=1, scale=1)
num_sub_chars = gr.Number(label=t("create.num_sub_chars"), value=3, minimum=0, maximum=20, step=1, scale=1)
with gr.Row():
suggest_char_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=False, scale=4)
suggest_char_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm", scale=1, interactive=False)
suggest_char_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=True, scale=4)
suggest_char_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm", scale=1, interactive=True)
suggest_char_status = gr.Textbox(show_label=False, interactive=False, visible=False)
with gr.Column():
world_input = gr.Textbox(
label=t("create.world_setting"),
placeholder=t("create.world_setting_placeholder"),
lines=5, interactive=False
lines=5, interactive=True
)
with gr.Group():
with gr.Row():
suggest_world_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=False, scale=4)
suggest_world_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm", scale=1, interactive=False)
suggest_world_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=True, scale=4)
suggest_world_btn = gr.Button(t("create.suggest_btn"), variant="secondary", size="sm", scale=1, interactive=True)
suggest_world_status = gr.Textbox(show_label=False, interactive=False, visible=False)
plot_input = gr.Textbox(
label=t("create.plot_idea"),
placeholder=t("create.plot_idea_placeholder"),
lines=3, interactive=False
lines=3, interactive=True
)
with gr.Group():
with gr.Row():
suggest_plot_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=False, scale=4)
suggest_plot_btn = gr.Button(t("create.suggest_plot_btn"), variant="secondary", size="sm", scale=1, interactive=False)
suggest_plot_prompt = gr.Textbox(show_label=False, placeholder=t("create.custom_prompt_placeholder"), lines=1, interactive=True, scale=4)
suggest_plot_btn = gr.Button(t("create.suggest_plot_btn"), variant="secondary", size="sm", scale=1, interactive=True)
suggest_plot_status = gr.Textbox(show_label=False, interactive=False, visible=False)
with gr.Accordion("📝 3. Dàn ý truyện", open=False):
custom_outline_prompt = gr.Textbox(
label="Yêu cầu riêng/Chỉ dẫn bổ sung cho Dàn ý (Tùy chọn)",
placeholder="VD: Không có nữ chính, kết cục mở, nvc là người tu ma nhưng tính cách hài hước...",
lines=2, interactive=False
lines=2, interactive=True
)
with gr.Row():
total_chapters = gr.Number(
label=t("create.chapter_count"), value=20, minimum=1, maximum=200, step=1, scale=1, interactive=False
label=t("create.chapter_count"), value=20, minimum=1, maximum=200, step=1, scale=1, interactive=True
)
generate_outline_btn = gr.Button(t("create.gen_outline_btn"), variant="primary", size="lg", scale=3, interactive=False)
generate_outline_btn = gr.Button(t("create.gen_outline_btn"), variant="primary", size="lg", scale=3, interactive=True)
outline_output = gr.Textbox(label=t("create.outline_display"), lines=15, interactive=True)
outline_status = gr.Textbox(label=t("create.gen_status"), interactive=False)
@@ -132,8 +174,8 @@ def build_create_tab():
with gr.Column(scale=1):
use_reflection_checkbox = gr.Checkbox(label="Bật chế độ Tự kiểm duyệt (Self-Reflection)", value=False, info="AI tự đọc lại và sửa lỗi nháp. Tốn gấp đôi thời gian & Token.")
with gr.Row():
auto_generate_btn = gr.Button(t("create.start_gen_btn"), variant="primary", size="lg", interactive=False)
stop_btn = gr.Button(t("create.pause_gen_btn"), variant="stop", size="lg", interactive=False)
auto_generate_btn = gr.Button(t("create.start_gen_btn"), variant="primary", size="lg", interactive=True)
stop_btn = gr.Button(t("create.pause_gen_btn"), variant="stop", size="lg", interactive=True)
with gr.Row():
with gr.Column(scale=1):
@@ -182,7 +224,10 @@ def build_create_tab():
nm = int(num_main) if num_main else 2
ns = int(num_sub) if num_sub else 3
content, msg = gen.suggest_content("char", title, genre, sub_genres, "", "", custom_prompt, nm, ns)
yield gr.update(value=content if content else msg), gr.update(visible=False), gr.update(interactive=True)
if content:
yield gr.update(value=content), gr.update(visible=False), gr.update(interactive=True)
else:
yield gr.update(), gr.update(value=f"{msg}", visible=True), gr.update(interactive=True)
except Exception as e:
traceback.print_exc()
yield gr.update(), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True)
@@ -192,7 +237,10 @@ def build_create_tab():
try:
gen = app_state.get_generator()
content, msg = gen.suggest_content("world", title, genre, sub_genres, "", "", custom_prompt)
yield gr.update(value=content if content else msg), gr.update(visible=False), gr.update(interactive=True)
if content:
yield gr.update(value=content), gr.update(visible=False), gr.update(interactive=True)
else:
yield gr.update(), gr.update(value=f"{msg}", visible=True), gr.update(interactive=True)
except Exception as e:
traceback.print_exc()
yield gr.update(), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True)
@@ -202,7 +250,10 @@ def build_create_tab():
try:
gen = app_state.get_generator()
content, msg = gen.suggest_content("plot", title, genre, sub_genres, char_setting, world_setting, custom_prompt)
yield gr.update(value=content if content else msg), gr.update(visible=False), gr.update(interactive=True)
if content:
yield gr.update(value=content), gr.update(visible=False), gr.update(interactive=True)
else:
yield gr.update(), gr.update(value=f"{msg}", visible=True), gr.update(interactive=True)
except Exception as e:
traceback.print_exc()
yield gr.update(), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True)
@@ -218,6 +269,7 @@ def build_create_tab():
def on_auto_generate(title, genre, sub_genres, char_setting, world_setting, plot_idea, outline_text, mem_type, mem_chaps, use_reflection, selected_style, progress=gr.Progress()):
"""Tự động tạo toàn bộ tiểu thuyết"""
_clear_create_draft()
gen = app_state.get_generator()
# Cập nhật style vào config hiện tại
@@ -327,6 +379,75 @@ def build_create_tab():
app_state.stop_requested = True
return "⏸️ Đang dừng..."
# ==================== DRAFT PERSISTENCE ====================
draft_inputs = [
genre_dropdown, sub_genre_dropdown, create_style_dropdown,
title_input, suggest_title_prompt, num_main_chars, num_sub_chars,
character_input, suggest_char_prompt, world_input,
suggest_world_prompt, plot_input, suggest_plot_prompt,
custom_outline_prompt, total_chapters, memory_type,
memory_chapters, use_reflection_checkbox, outline_output,
]
def on_draft_change(*values):
d = dict(zip(DRAFT_FIELDS, values))
_save_create_draft(d)
return
for _comp in draft_inputs:
_comp.change(fn=on_draft_change, inputs=draft_inputs, outputs=[])
def on_load_draft():
d = _load_create_draft()
title = d.get("title", "")
character = d.get("character", "")
world = d.get("world", "")
plot = d.get("plot", "")
outline = d.get("outline", "")
return (
d.get("genre"),
d.get("sub_genres"),
d.get("style"),
title,
d.get("suggest_title_prompt"),
d.get("num_main", 2),
d.get("num_sub", 3),
d.get("character", ""),
gr.update(interactive=True),
gr.update(interactive=True),
d.get("world", ""),
gr.update(interactive=True),
gr.update(interactive=True),
d.get("plot", ""),
gr.update(interactive=True),
gr.update(interactive=True),
d.get("custom_outline", ""),
d.get("total_chapters", 20),
gr.update(interactive=True),
d.get("outline", ""),
gr.update(interactive=True),
gr.update(interactive=True),
d.get("memory_type", "Toàn văn"),
d.get("memory_chapters", 3),
bool(d.get("use_reflection", False)),
)
if demo is not None:
demo.load(
fn=on_load_draft,
inputs=None,
outputs=[
genre_dropdown, sub_genre_dropdown, create_style_dropdown,
title_input, suggest_title_prompt, num_main_chars, num_sub_chars,
character_input, suggest_char_prompt, suggest_char_btn,
world_input, suggest_world_prompt, suggest_world_btn,
plot_input, suggest_plot_prompt, suggest_plot_btn,
custom_outline_prompt, total_chapters, generate_outline_btn,
outline_output, auto_generate_btn, stop_btn,
memory_type, memory_chapters, use_reflection_checkbox,
],
)
# Bind events
suggest_title_btn.click(
fn=on_suggest_title,
@@ -370,53 +491,3 @@ def build_create_tab():
outputs=[chapter_content_display]
)
stop_btn.click(fn=on_stop, outputs=[generation_progress])
# Flow Enforcement Events
title_input.change(
fn=lambda t: [
gr.update(interactive=bool(t)),
gr.update(interactive=bool(t)),
gr.update(interactive=bool(t))
],
inputs=[title_input],
outputs=[character_input, suggest_char_prompt, suggest_char_btn]
)
character_input.change(
fn=lambda c: [
gr.update(interactive=bool(c)),
gr.update(interactive=bool(c)),
gr.update(interactive=bool(c))
],
inputs=[character_input],
outputs=[world_input, suggest_world_prompt, suggest_world_btn]
)
world_input.change(
fn=lambda w: [
gr.update(interactive=bool(w)),
gr.update(interactive=bool(w)),
gr.update(interactive=bool(w))
],
inputs=[world_input],
outputs=[plot_input, suggest_plot_prompt, suggest_plot_btn]
)
plot_input.change(
fn=lambda p: [
gr.update(interactive=bool(p)),
gr.update(interactive=bool(p)),
gr.update(interactive=bool(p))
],
inputs=[plot_input],
outputs=[total_chapters, custom_outline_prompt, generate_outline_btn]
)
outline_output.change(
fn=lambda o: [
gr.update(interactive=bool(o)),
gr.update(interactive=bool(o))
],
inputs=[outline_output],
outputs=[auto_generate_btn, stop_btn]
)
+1 -1
View File
@@ -151,7 +151,7 @@ def build_settings_tab():
# If name was changed, it requires updating that as well but the update_backend method modifies existing object's properties in place based on its previous name
result = ConfigAPIManager.update_backend(
old_name,
name=new_name.strip(),
new_name=new_name.strip(),
type=btype,
base_url=url,
api_key=key,