fix: tự động chạy ai
This commit is contained in:
@@ -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,3 +1,3 @@
|
||||
{
|
||||
"settings_password": "TiniX@123"
|
||||
"settings_password": ""
|
||||
}
|
||||
+5
-2
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
-12
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,66 +167,154 @@ 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
|
||||
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.genre}
|
||||
onChange={(e) => setFormData({...formData, genre: e.target.value})}
|
||||
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";
|
||||
|
||||
|
||||
@@ -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
@@ -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"]:
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user