diff --git a/app.py b/app.py index db5afa6..f369ce6 100644 --- a/app.py +++ b/app.py @@ -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() diff --git a/config/security.json b/config/security.json index 52d56fb..99febea 100644 --- a/config/security.json +++ b/config/security.json @@ -1,3 +1,3 @@ { - "settings_password": "TiniX@123" -} \ No newline at end of file + "settings_password": "" +} diff --git a/core/config.py b/core/config.py index 3bb0566..441eb25 100644 --- a/core/config.py +++ b/core/config.py @@ -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) diff --git a/core/config_api.py b/core/config_api.py index f534c40..927793e 100644 --- a/core/config_api.py +++ b/core/config_api.py @@ -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}") diff --git a/data/create_draft.json b/data/create_draft.json new file mode 100644 index 0000000..08685bf --- /dev/null +++ b/data/create_draft.json @@ -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} \ No newline at end of file diff --git a/data/tinix_story.db-shm b/data/tinix_story.db-shm index fe9ac28..c715616 100644 Binary files a/data/tinix_story.db-shm and b/data/tinix_story.db-shm differ diff --git a/data/tinix_story.db-wal b/data/tinix_story.db-wal index e69de29..7f851f8 100644 Binary files a/data/tinix_story.db-wal and b/data/tinix_story.db-wal differ diff --git a/docker-compose.yml b/docker-compose.yml index 5f1a57b..6e64f19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -136,4 +138,4 @@ volumes: networks: default: - name: ai-novel-network \ No newline at end of file + name: ai-novel-network diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d94683..055b0b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } diff --git a/frontend/src/app/create/page.tsx b/frontend/src/app/create/page.tsx index b8951f0..ec4d849 100644 --- a/frontend/src/app/create/page.tsx +++ b/frontend/src/app/create/page.tsx @@ -1,50 +1,163 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { api } from "@/services/api"; +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 +}; + +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 [genres, setGenres] = useState([]); + const initial = useRef(loadDraft()).current; + + const [genres, setGenres] = useState([]); + const [subGenres, setSubGenres] = useState([]); + const [styles, setStyles] = useState([]); + const [loading, setLoading] = useState(false); - const [formData, setFormData] = useState({ - title: "", - genre: "", - character_setting: "", - world_setting: "", - plot_idea: "", - total_chapters: 20 - }); - const [outline, setOutline] = useState(""); + const [formData, setFormData] = useState(initial.form); + const [outline, setOutline] = useState(initial.outline); const [statusMsg, setStatusMsg] = useState(""); + // 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(() => { - // Fetch genres on mount api.getGenres().then((data) => { - setGenres(data || []); - if (data && data.length > 0) { - setFormData((prev) => ({ ...prev, genre: data[0].name })); + 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); - }); + }).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); setStatusMsg("Generating outline. Please wait..."); setOutline(""); - + 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 || ""); setStatusMsg(res.message || "Success"); } catch (err: any) { @@ -54,70 +167,158 @@ 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 (
-
-

Create Story

-

Design your novel settings and generate an outline.

+
+
+

Create Story

+

Design your novel settings and generate an outline.

+
+
{/* Form Column */}
-
- - setFormData({...formData, title: e.target.value})} - placeholder="Enter your novel title..." - /> -
-
-
+ {subGenres.length > 0 && ( +
+ +
+ {subGenres.map((sg) => { + const active = formData.sub_genres.includes(sg); + return ( + + ); + })} +
+
+ )} +
- -