feat: add SQLite write-ahead log and shared memory files.

This commit is contained in:
tinix-ai
2026-04-09 16:19:39 +07:00
parent f12cedf6d0
commit c3a69d145b
68 changed files with 9079 additions and 42 deletions
+2 -3
View File
@@ -1,7 +1,6 @@
# ============================================
# AI小说生成工具正式版V3.0
# 版权所有 © 2026 新疆幻城网安科技有限责任公司 (幻城科技)
# 作者:幻城
# TiniX Story 1.0
# Copyright © 2026 TiniX AI
# ============================================
# ============================================
+4 -10
View File
@@ -1,4 +1,4 @@
# Dockerfile cho công cụ sáng tác tiểu thuyết AI
# Dockerfile cho TiniX Story 1.0
# Dựa trên image chính thức của Python 3.11
FROM python:3.11-slim
@@ -14,28 +14,22 @@ ENV PYTHONPATH=/app
RUN apt-get update && apt-get install -y \
build-essential \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Sao chép tệp dependencies
COPY requirements.txt .
COPY requirements-dev.txt .
# Cài đặt dependencies Python
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -r requirements-dev.txt
# Tạo các thư mục cần thiết
RUN mkdir -p logs cache output data backups templates project_templates plugins
RUN mkdir -p logs data exports projects config
# Sao chép mã nguồn ứng dụng
COPY . .
# Thiết lập quyền truy cập
RUN chmod +x start.sh start.bat run.py
# Expose port
EXPOSE 8000
# Expose ports (FastAPI: 8000, Gradio: 7860)
EXPOSE 8000 7860
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
-7
View File
@@ -106,13 +106,6 @@ def main():
# Tạo UI
app = create_main_ui()
# Tải CSS
custom_css = ""
css_path = Path("custom.css")
if css_path.exists():
with open(css_path, 'r', encoding='utf-8') as f:
custom_css = f.read()
# Khởi động
logger.info(t("app.gradio_start", port=WEB_PORT))
app.queue(default_concurrency_limit=10).launch(
+3
View File
@@ -0,0 +1,3 @@
{
"settings_password": "TiniX@123"
}
+65
View File
@@ -0,0 +1,65 @@
import os
import json
import logging
from threading import Lock
logger = logging.getLogger(__name__)
CONFIG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config")
SECURITY_FILE = os.path.join(CONFIG_DIR, "security.json")
# Ensure config directory exists
os.makedirs(CONFIG_DIR, exist_ok=True)
_auth_lock = Lock()
def _load_security_data() -> dict:
with _auth_lock:
if not os.path.exists(SECURITY_FILE):
return {}
try:
with open(SECURITY_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to read security.json: {e}")
return {}
def _save_security_data(data: dict) -> bool:
with _auth_lock:
try:
with open(SECURITY_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
return True
except Exception as e:
logger.error(f"Failed to write security.json: {e}")
return False
def has_password() -> bool:
"""Kiểm tra xem hệ thống đã thiết lập mật khẩu chưa."""
data = _load_security_data()
pwd = data.get("settings_password", "")
return bool(pwd.strip())
def verify_password(pwd: str) -> bool:
"""Xác thực mật khẩu. Trả về True nếu đúng hoặc nếu hệ thống chưa yêu cầu mật khẩu."""
if not has_password():
return True
data = _load_security_data()
return data.get("settings_password", "") == pwd
def set_password(old_pwd: str, new_pwd: str) -> tuple[bool, str]:
"""Cập nhật mật khẩu mới."""
data = _load_security_data()
current_pwd = data.get("settings_password", "")
# Nếu đang có pass, phải nhập đúng pass cũ
if current_pwd and old_pwd != current_pwd:
return False, "Mật khẩu cũ không chính xác."
data["settings_password"] = new_pwd
if _save_security_data(data):
if not new_pwd:
return True, "Đã gỡ bỏ mật khẩu bảo vệ."
return True, "Cập nhật mật khẩu thành công."
return False, "Lỗi khi lưu mật khẩu, vui lòng xem log."
+3 -1
View File
@@ -227,7 +227,9 @@ class Backend:
"""Xác minh tính hợp lệ của cấu hình"""
if not self.name or not self.name.strip():
return False, t("config.backend_name_empty")
if self.type not in ["ollama", "openai", "claude", "other"]:
# Accepted types: all keys from API_PROVIDERS + legacy types
valid_types = set(API_PROVIDERS.keys()) | {"ollama", "openai", "claude", "other"}
if self.type not in valid_types:
return False, t("config.unsupported_type", type=self.type)
if not self.base_url or not self.base_url.strip().startswith(("http://", "https://")):
return False, t("config.base_url_invalid")
+104
View File
@@ -0,0 +1,104 @@
import asyncio
import uuid
import time
import logging
from typing import Dict, Any, List, Optional, Callable
from enum import Enum
from datetime import datetime
logger = logging.getLogger(__name__)
class TaskStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class Task:
def __init__(self, name: str, task_type: str, metadata: Dict[str, Any] = None):
self.id = str(uuid.uuid4())
self.name = name
self.type = task_type
self.status = TaskStatus.PENDING
self.progress = 0.0 # 0.0 to 100.0
self.message = "Initializing..."
self.metadata = metadata or {}
self.created_at = datetime.now().isoformat()
self.updated_at = datetime.now().isoformat()
self.result = None
self.error = None
self._stop_event = asyncio.Event()
def update(self, status: TaskStatus = None, progress: float = None, message: str = None, result: Any = None, error: str = None):
if status: self.status = status
if progress is not None: self.progress = progress
if message: self.message = message
if result: self.result = result
if error: self.error = error
self.updated_at = datetime.now().isoformat()
def cancel(self):
if self.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
self.status = TaskStatus.CANCELLED
self._stop_event.set()
self.message = "Cancelled by user"
def is_cancelled(self):
return self._stop_event.is_set()
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"type": self.type,
"status": self.status,
"progress": self.progress,
"message": self.message,
"created_at": self.created_at,
"updated_at": self.updated_at,
"result": self.result,
"error": self.error,
"metadata": self.metadata
}
class TaskManager:
def __init__(self):
self.tasks: Dict[str, Task] = {}
self._lock = asyncio.Lock()
async def create_task(self, name: str, task_type: str, metadata: Dict[str, Any] = None) -> Task:
async with self._lock:
task = Task(name, task_type, metadata)
self.tasks[task.id] = task
return task
def get_task(self, task_id: str) -> Optional[Task]:
return self.tasks.get(task_id)
def list_tasks(self, limit: int = 50) -> List[Dict]:
return [t.to_dict() for t in sorted(self.tasks.values(), key=lambda x: x.created_at, reverse=True)[:limit]]
async def run_task(self, task_id: str, coro_func: Callable, *args, **kwargs):
task = self.get_task(task_id)
if not task:
return
task.update(status=TaskStatus.RUNNING, message="Task started")
try:
# We pass the task object so the coroutine can update progress
await coro_func(task, *args, **kwargs)
if task.status == TaskStatus.RUNNING:
task.update(status=TaskStatus.COMPLETED, progress=100.0, message="Task completed successfully")
except asyncio.CancelledError:
task.update(status=TaskStatus.CANCELLED, message="Task was cancelled")
except Exception as e:
logger.exception(f"Error in task {task_id}")
task.update(status=TaskStatus.FAILED, message=f"Error: {str(e)}", error=str(e))
async def cleanup_old_tasks(self, max_age_seconds: int = 3600 * 24):
# NOT implemented yet, but good for production
pass
# Singleton instance
task_manager = TaskManager()
Binary file not shown.
View File
-1
View File
@@ -6,7 +6,6 @@ services:
build:
context: .
dockerfile: Dockerfile
target: production
container_name: ai-novel-generator
restart: unless-stopped
ports:
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'export',
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;
+6888
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"tauri": "tauri"
},
"dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/cli": "^2.10.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.6" }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3" }
tauri-plugin-log = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+16
View File
@@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "tinix-story",
"version": "0.1.0",
"identifier": "com.tinix.story",
"build": {
"frontendDist": "../out",
"devUrl": "http://localhost:3000",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "TiniX Story 1.0",
"width": 1200,
"height": 800,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState, useEffect } from "react";
import { api } from "@/services/api";
export default function ContinuePage() {
const [projects, setProjects] = useState<any[]>([]);
const [selectedId, setSelectedId] = useState("");
const [project, setProject] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
api.listProjects().then(setProjects).catch(console.error);
}, []);
const handleLoad = async (id: string) => {
setSelectedId(id);
setLoading(true);
try {
const data = await api.getProject(id);
setProject(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col flex-1 p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Continue Story</h1>
<p className="text-zinc-500 mt-2">Resume writing from where you left off.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Project List Sidebar */}
<div className="lg:col-span-1 space-y-4">
<h3 className="font-semibold text-zinc-300">Select Project</h3>
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
onClick={() => handleLoad(p.id)}
className={`w-full text-left p-3 rounded-lg border transition-all ${
selectedId === p.id
? "bg-brand-primary/20 border-brand-primary text-white"
: "bg-background-card border-border-glass text-zinc-400 hover:border-zinc-500"
}`}
>
<div className="font-medium truncate">{p.title}</div>
<div className="text-xs opacity-60">{p.genre}</div>
</button>
))}
</div>
</div>
{/* Content Area */}
<div className="lg:col-span-3 space-y-6">
{loading ? (
<div className="flex items-center justify-center h-64 text-zinc-500 animate-pulse">
Loading project data...
</div>
) : project ? (
<div className="space-y-6">
<div className="p-6 rounded-xl border border-border-glass bg-background-card">
<h2 className="text-2xl font-bold text-white mb-4">{project.title}</h2>
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="p-3 bg-black/30 rounded border border-border-glass">
<div className="text-zinc-500 text-xs uppercase mb-1">Chapters</div>
<div className="text-white font-semibold">{project.completed_count} / {project.chapters?.length}</div>
</div>
<div className="p-3 bg-black/30 rounded border border-border-glass">
<div className="text-zinc-500 text-xs uppercase mb-1">Total Words</div>
<div className="text-white font-semibold">{project.total_words?.toLocaleString()}</div>
</div>
<div className="p-3 bg-black/30 rounded border border-border-glass">
<div className="text-zinc-500 text-xs uppercase mb-1">Last Updated</div>
<div className="text-white font-semibold">{new Date(project.updated_at).toLocaleDateString()}</div>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold text-zinc-200">Chapter List</h3>
<div className="space-y-2">
{project.chapters?.map((ch: any) => (
<div key={ch.num} className="p-4 bg-background-card border border-border-glass rounded-lg flex items-center justify-between group hover:border-brand-primary/50 transition-colors">
<div>
<span className="text-zinc-500 mr-2">#{ch.num}</span>
<span className="text-white font-medium">{ch.title}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-zinc-500">{ch.word_count || 0} words</span>
<button
onClick={async () => {
try {
await api.startBulkGen({ project_id: selectedId, chapter_nums: [ch.num] });
alert("Generation task started. Check the Task Sidebar!");
} catch (err) {
alert("Failed to start task");
}
}}
className="px-4 py-1.5 bg-brand-primary/10 text-brand-primary border border-brand-primary/30 rounded hover:bg-brand-primary hover:text-black transition-all"
>
{ch.content ? "Rewrite" : "Write"}
</button>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-96 border-2 border-dashed border-border-glass rounded-2xl text-zinc-600">
<p>Please select a project from the sidebar to continue writing.</p>
</div>
)}
</div>
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState, useEffect } 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({
title: "",
genre: "",
character_setting: "",
world_setting: "",
plot_idea: "",
total_chapters: 20
});
const [outline, setOutline] = useState<string>("");
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);
});
}, []);
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: [],
total_chapters: formData.total_chapters,
character_setting: formData.character_setting,
world_setting: formData.world_setting,
plot_idea: formData.plot_idea
});
setOutline(res.content || "");
setStatusMsg(res.message || "Success");
} catch (err: any) {
setStatusMsg("Error: " + err.message);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col flex-1 p-8">
<div className="mb-8">
<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>
<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})}
>
{genres.map(g => (
<option key={g.name} value={g.name}>{g.name}</option>
))}
</select>
</div>
<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})}
placeholder="Describe main characters, personalities..."
/>
</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})}
placeholder="Describe the world, magic system, laws..."
/>
</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..."
/>
</div>
<button
disabled={loading || !formData.title || !formData.genre}
onClick={handleGenerateOutline}
className="w-full py-3 bg-brand-primary text-black font-semibold rounded-md hover:bg-brand-secondary transition-colors disabled:opacity-50"
>
{loading ? "Generating..." : "Generate Outline"}
</button>
</div>
{/* Outline / Results Column */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-foreground">Generated Outline</h2>
{statusMsg && (
<div className="p-3 bg-blue-900/30 border border-blue-500/30 text-blue-300 rounded-md text-sm">
{statusMsg}
</div>
)}
<textarea
className="w-full bg-black/40 border border-border-glass rounded-md p-4 text-sm h-[600px] font-mono text-zinc-300 focus:border-brand-primary outline-none resize-none"
value={outline}
readOnly
placeholder="Your generated outline will appear here..."
/>
</div>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+87
View File
@@ -0,0 +1,87 @@
@import "tailwindcss";
@theme {
--color-brand-primary: #8b5cf6;
--color-brand-secondary: #c084fc;
--color-brand-accent: #6366f1;
--color-background-deep: #09090b;
--color-background-card: rgba(24, 24, 27, 0.6);
--color-border-glass: rgba(255, 255, 255, 0.1);
}
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 9, 9, 11;
--background-end-rgb: 0, 0, 0;
--primary: #8b5cf6;
--secondary: #c084fc;
--accent: #6366f1;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
min-height: 100vh;
font-family: var(--font-geist-sans), Inter, sans-serif;
overflow-x: hidden;
}
/* Glassmorphism Utilities */
.glass {
background: var(--color-background-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
}
.glass-hover {
transition: all 0.3s ease;
}
.glass-hover:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
/* Animations */
@keyframes gradient-x {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient-x 6s ease infinite;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(139, 92, 246, 0.5);
}
/* Custom Selection */
::selection {
background: rgba(139, 92, 246, 0.3);
color: white;
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { TaskSidebar } from "@/components/layout/TaskSidebar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TiniX Story - AI Novel Creator",
description: "Create amazing stories with AI",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} antialiased dark`}
>
<body className="flex min-h-screen text-foreground selection:bg-brand-primary/30">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
<TaskSidebar />
</body>
</html>
);
}
+25
View File
@@ -0,0 +1,25 @@
export default function Home() {
return (
<div className="flex flex-col flex-1 p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-zinc-500 mt-2">Welcome to TiniX Story - Advanced AI Novel Generator.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="p-6 rounded-xl border border-border-glass bg-background-card">
<h3 className="font-semibold text-lg text-brand-secondary mb-2">Recent Projects</h3>
<p className="text-zinc-400 text-sm">No projects yet. Start by creating a new story!</p>
</div>
<div className="p-6 rounded-xl border border-border-glass bg-background-card">
<h3 className="font-semibold text-lg text-brand-primary mb-2">Capabilities</h3>
<ul className="text-zinc-400 text-sm space-y-2">
<li> AI Novel Generation</li>
<li>🔄 Smart Continue</li>
<li>📝 Advanced Rewrite</li>
</ul>
</div>
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState, useEffect } from "react";
import { api } from "@/services/api";
export default function SettingsPage() {
const [loading, setLoading] = useState(true);
const [config, setConfig] = useState<any>(null);
const [backends, setBackends] = useState<any[]>([]);
useEffect(() => {
async function loadSettings() {
try {
const [cfgData, backendsData] = await Promise.all([
api.getGenerationConfig(),
api.getBackends()
]);
setConfig(cfgData);
setBackends(backendsData);
} catch (err) {
console.error("Failed to load settings:", err);
} finally {
setLoading(false);
}
}
loadSettings();
}, []);
return (
<div className="flex flex-col flex-1 p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Settings</h1>
<p className="text-zinc-500 mt-2">Configure API backends and generation parameters.</p>
</div>
{loading ? (
<p className="text-zinc-400 animate-pulse">Loading configuration...</p>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Backends Section */}
<div className="p-6 rounded-xl border border-border-glass bg-background-card">
<h3 className="font-semibold text-lg text-brand-secondary mb-4">API Backends</h3>
<div className="space-y-4">
{backends.length === 0 ? (
<p className="text-sm text-zinc-400">No active backends configured.</p>
) : (
backends.map((b, i) => (
<div key={i} className="p-4 bg-black/40 border border-border-glass rounded-md">
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-white">{b.name}</h4>
<span className={`px-2 py-1 text-xs rounded-full ${b.enabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
{b.enabled ? "Active" : "Disabled"}
</span>
</div>
<p className="text-xs text-zinc-400">Type: {b.type} | Model: {b.model}</p>
</div>
))
)}
</div>
</div>
{/* Prompt/Generation Config Section */}
<div className="p-6 rounded-xl border border-border-glass bg-background-card space-y-6">
<h3 className="font-semibold text-lg text-brand-primary mb-2">Generation Defaults</h3>
{config && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs text-zinc-400">Temperature</label>
<p className="text-sm text-white">{config.temperature}</p>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">Target Words</label>
<p className="text-sm text-white">{config.chapter_target_words}</p>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">Writing Style</label>
<p className="text-sm text-white">{config.writing_style}</p>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">Tone</label>
<p className="text-sm text-white">{config.writing_tone}</p>
</div>
</div>
)}
<p className="text-xs text-zinc-500 mt-4 italic">
* Frontend editing of configuration is currently pending backend payload schemas.
</p>
</div>
</div>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { api } from "@/services/api";
export default function ToolsPage() {
const [text, setText] = useState("");
const [result, setResult] = useState("");
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<"rewrite" | "polish">("rewrite");
const handleProcess = async () => {
if (!text.trim()) return;
setLoading(true);
try {
if (mode === "rewrite") {
const res = await fetch('http://127.0.0.1:8000/rewrite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, style_template: "", use_reflection: false })
}).then(r => r.json());
setResult(res.content);
} else {
const res = await fetch('http://127.0.0.1:8000/polish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, polish_type: "general", use_reflection: false })
}).then(r => r.json());
setResult(res.content);
}
} catch (err) {
console.error(err);
setResult("Error processing text.");
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col flex-1 p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Writing Tools</h1>
<p className="text-zinc-500 mt-2">Rewrite or polish your content with AI precision.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Input Area */}
<div className="space-y-4">
<div className="flex bg-background-card p-1 rounded-lg border border-border-glass w-fit">
<button
onClick={() => setMode("rewrite")}
className={`px-4 py-2 rounded-md text-sm transition-all ${mode === "rewrite" ? "bg-brand-primary text-black font-semibold" : "text-zinc-400 hover:text-white"}`}
>
Rewrite
</button>
<button
onClick={() => setMode("polish")}
className={`px-4 py-2 rounded-md text-sm transition-all ${mode === "polish" ? "bg-brand-primary text-black font-semibold" : "text-zinc-400 hover:text-white"}`}
>
Polish
</button>
</div>
<textarea
className="w-full bg-background-card border border-border-glass rounded-xl p-4 text-sm h-[500px] focus:border-brand-primary outline-none resize-none text-white"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Paste your content here..."
/>
<button
onClick={handleProcess}
disabled={loading || !text}
className="w-full py-4 bg-brand-primary text-black font-bold rounded-xl hover:bg-brand-secondary transition-all disabled:opacity-50"
>
{loading ? "Processing..." : `Run ${mode === "rewrite" ? "Rewrite" : "Polish"}`}
</button>
</div>
{/* Output Area */}
<div className="space-y-4">
<h3 className="font-semibold text-zinc-300">AI Result</h3>
<div className="w-full bg-black/40 border border-border-glass rounded-xl p-6 text-sm h-[500px] overflow-y-auto text-zinc-300 relative">
{result ? (
<div className="whitespace-pre-wrap leading-relaxed">{result}</div>
) : (
<div className="flex items-center justify-center h-full text-zinc-600 italic">
Result will be displayed here...
</div>
)}
</div>
{result && (
<button
onClick={() => { navigator.clipboard.writeText(result); alert("Copied!"); }}
className="w-full py-2 bg-white/5 border border-border-glass text-zinc-300 rounded-lg hover:bg-white/10 transition-all"
>
Copy to Clipboard
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
const navigation = [
{ name: "Dashboard", href: "/" },
{ name: "Create New", href: "/create" },
{ name: "Continue Story", href: "/continue" },
{ name: "Writing Tools", href: "/tools" },
{ name: "Settings", href: "/settings" },
];
export function Sidebar() {
const pathname = usePathname();
return (
<div className="flex flex-col w-64 bg-background-card border-r border-border-glass h-screen sticky top-0">
<div className="flex items-center justify-center h-20 border-b border-border-glass">
<h1 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-brand-primary to-brand-secondary">
TiniX Story
</h1>
</div>
<nav className="flex-1 px-4 py-6 space-y-2">
{navigation.map((item) => {
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 ${
isActive
? "bg-brand-primary/20 text-brand-secondary border border-brand-primary/30"
: "text-zinc-400 hover:bg-white/5 hover:text-white"
}`}
>
{item.name}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-border-glass text-xs text-zinc-500 text-center">
TiniX Story v1.0
</div>
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useState, useEffect } from "react";
import { api } from "@/services/api";
export function TaskSidebar() {
const [tasks, setTasks] = useState<any[]>([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
let interval: any;
if (isOpen) {
const fetchTasks = async () => {
try {
const data = await api.listTasks();
setTasks(data || []);
} catch (err) {
console.error("Error fetching tasks:", err);
}
};
fetchTasks();
interval = setInterval(fetchTasks, 3000);
}
return () => clearInterval(interval);
}, [isOpen]);
const activeCount = tasks.filter(t => t.status === "running" || t.status === "pending").length;
return (
<div className={`fixed bottom-0 right-0 z-50 transition-all duration-300 ${isOpen ? "w-80 h-[500px]" : "w-12 h-12"}`}>
{/* Toggle Button */}
{!isOpen ? (
<button
onClick={() => setIsOpen(true)}
className="w-12 h-12 bg-brand-primary text-black rounded-tl-xl flex items-center justify-center shadow-lg hover:bg-brand-secondary transition-colors relative"
>
<span>📋</span>
{activeCount > 0 && (
<span className="absolute -top-1 -left-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center animate-bounce">
{activeCount}
</span>
)}
</button>
) : (
<div className="w-full h-full bg-background-card border-l border-t border-border-glass rounded-tl-2xl shadow-2xl flex flex-col overflow-hidden">
<div className="p-4 border-b border-border-glass flex justify-between items-center bg-white/5">
<h3 className="font-bold text-white flex items-center gap-2">
<span>📋</span> Task Queue
</h3>
<button onClick={() => setIsOpen(false)} className="text-zinc-500 hover:text-white"></button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{tasks.length === 0 && (
<p className="text-center text-zinc-500 text-sm mt-10">No recent tasks.</p>
)}
{tasks.map((task) => (
<div key={task.id} className="p-3 bg-black/30 border border-border-glass rounded-lg space-y-2">
<div className="flex justify-between items-start">
<div className="text-xs font-semibold text-brand-primary truncate max-w-[150px]">
{task.name}
</div>
<span className={`text-[10px] px-1.5 py-0.5 rounded uppercase font-bold ${
task.status === 'completed' ? 'bg-green-500/20 text-green-400' :
task.status === 'failed' ? 'bg-red-500/20 text-red-400' :
task.status === 'running' ? 'bg-blue-500/20 text-blue-400 animate-pulse' :
'bg-zinc-700 text-zinc-400'
}`}>
{task.status}
</span>
</div>
<div className="text-[11px] text-zinc-400 italic truncate">
{task.message}
</div>
{(task.status === 'running' || task.status === 'pending') && (
<div className="space-y-1">
<div className="w-full bg-white/5 h-1.5 rounded-full overflow-hidden">
<div
className="bg-brand-primary h-full transition-all duration-500"
style={{ width: `${task.progress}%` }}
></div>
</div>
<div className="flex justify-between text-[10px] text-zinc-500">
<span>{Math.round(task.progress)}%</span>
<button
onClick={() => api.cancelTask(task.id)}
className="text-red-400 hover:underline"
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
const API_BASE_URL = 'http://127.0.0.1:8000';
export interface ProjectCreateReq {
title: string;
genre: string;
sub_genres: string[];
character_setting?: string;
world_setting?: string;
plot_idea?: string;
}
export interface OutlineReq {
title: string;
genre: string;
sub_genres: string[];
total_chapters: number;
character_setting: string;
world_setting: string;
plot_idea: string;
custom_outline_prompt?: string;
}
export const api = {
// Check health
async checkHealth() {
const res = await fetch(`${API_BASE_URL}/health`);
if (!res.ok) throw new Error('Network error');
return res.json();
},
// Projects
async listProjects() {
const res = await fetch(`${API_BASE_URL}/projects`);
if (!res.ok) throw new Error('Failed to fetch projects');
return res.json();
},
async getProject(id: string) {
const res = await fetch(`${API_BASE_URL}/projects/${id}`);
if (!res.ok) throw new Error('Failed to fetch project');
return res.json();
},
// Get genres
async getGenres() {
const res = await fetch(`${API_BASE_URL}/genres`);
if (!res.ok) throw new Error('Failed to fetch genres');
return res.json();
},
// Generate outline
async generateOutline(data: OutlineReq) {
const res = await fetch(`${API_BASE_URL}/generate-outline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Outline generation failed');
return res.json();
},
// Create project
async createProject(data: ProjectCreateReq) {
const res = await fetch(`${API_BASE_URL}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Create project failed');
return res.json();
},
// Get generic config
async getGenerationConfig() {
const res = await fetch(`${API_BASE_URL}/config/generation`);
if (!res.ok) throw new Error('Failed to fetch config');
return res.json();
},
// Get backends
async getBackends() {
const res = await fetch(`${API_BASE_URL}/config/backends`);
if (!res.ok) throw new Error('Failed to fetch backends');
return res.json();
},
// Task Management
async listTasks() {
const res = await fetch(`${API_BASE_URL}/tasks`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
async startBulkGen(data: { project_id: string, chapter_nums: number[], use_reflection?: boolean }) {
const res = await fetch(`${API_BASE_URL}/tasks/generate-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to start bulk generation');
return res.json();
},
async cancelTask(taskId: string) {
const res = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE',
});
return res.json();
}
};
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -588,7 +588,7 @@
},
"prompts": {
"outline_system": "Bạn là nhà hoạch định dàn ý tiểu thuyết chuyên nghiệp, giỏi xây dựng khung truyện hấp dẫn.",
"outline_user": "Hãy tạo dàn ý đầy đủ cho một tiểu thuyết thể loại {genre}, tiêu đề: «{title}».\n\nThiết lập nhân vật: {character_setting}\n\nThế giới quan: {world_setting}\n\nCốt truyện chính: {plot_idea}\n\nYêu cầu phong cách: {style_desc}\n\nYêu cầu:\n1. Tổng cộng khoảng {total_chapters} chương\n2. Mỗi chương theo định dạng nghiêm ngặt: Chương X: Tiêu đề chương - Mô tả cốt truyện ngắn gọn (50-100 từ)\n3. Cốt truyện mạch lạc, có mở đầu - phát triển - cao trào - kết thúc, nhân vật phát triển hợp lý\n4. Chỉ xuất ra danh sách dàn ý, không có nội dung khác\n5. Dàn ý phải hấp dẫn, lôi cuốn, có yếu tố hồi hộp\n6. Phân bổ chương theo cấu trúc 3 hồi:\n - Hồi 1 (25% số chương): Giới thiệu, xây dựng thế giới, thiết lập xung đột\n - Hồi 2 (50% số chương): Phát triển, leo thang, bước ngoặt giữa truyện\n - Hồi 3 (25% số chương): Cao trào, giải quyết, kết thúc\n7. Mỗi chương phải có xung đột nhỏ hoặc tiến triển rõ ràng, không có chương 'chữ lót'",
"outline_user": "Hãy tạo dàn ý đầy đủ cho một tiểu thuyết thể loại {genre}, tiêu đề: «{title}».\n\nThiết lập nhân vật: {character_setting}\n\nThế giới quan: {world_setting}\n\nCốt truyện chính: {plot_idea}\n\nYêu cầu phong cách: {style_desc}\n\n{custom_prompt}Yêu cầu:\n1. Tổng cộng khoảng {total_chapters} chương\n2. Mỗi chương theo định dạng nghiêm ngặt: Chương X: Tiêu đề chương - Mô tả cốt truyện ngắn gọn (50-100 từ)\n3. Cốt truyện mạch lạc, có mở đầu - phát triển - cao trào - kết thúc, nhân vật phát triển hợp lý\n4. Chỉ xuất ra danh sách dàn ý, không có nội dung khác\n5. Dàn ý phải hấp dẫn, lôi cuốn, có yếu tố hồi hộp\n6. Phân bổ chương theo cấu trúc 3 hồi:\n - Hồi 1 (25% số chương): Giới thiệu, xây dựng thế giới, thiết lập xung đột\n - Hồi 2 (50% số chương): Phát triển, leo thang, bước ngoặt giữa truyện\n - Hồi 3 (25% số chương): Cao trào, giải quyết, kết thúc\n7. Mỗi chương phải có xung đột nhỏ hoặc tiến triển rõ ràng, không có chương 'chữ lót'",
"chapter_system": "Bạn là nhà văn tiểu thuyết dài kỳ xuất sắc, sáng tác những câu chuyện chạm đến trái tim. Hãy viết với phong cách tự nhiên của con người. Tránh các mẫu câu AI phổ biến như: bắt đầu đoạn bằng 'Tuy nhiên', 'Ngoài ra', 'Hơn nữa' liên tục; kết thúc chương quá gọn gàng hoặc giáo điều; sử dụng cụm từ 'một cách + tính từ' quá nhiều; liệt kê ba thứ liền nhau theo kiểu 'cảm thấy X, Y, và Z'. TUYỆT ĐỐI KHÔNG sử dụng ký tự tiếng Trung hoặc văn phong lạm dụng Hán Việt. Phải dùng 100% tiếng Việt thuần túy, mượt mà tự nhiên.",
"chapter_user": "Hãy viết Chương {chapter_num} của tiểu thuyết «{novel_title}».\n\nTiêu đề chương: {chapter_title}\nDàn ý chương này: {chapter_desc}\n\nThiết lập tổng thể:\nNhân vật: {character_setting}\nThế giới quan: {world_setting}\nCốt truyện chính: {plot_idea}\n\nYêu cầu phong cách: {style_desc}\n\nYêu cầu cụ thể:\n1. Nội dung khoảng {target_words} từ\n2. Cốt truyện tuân thủ nghiêm ngặt dàn ý chương, mạch lạc với toàn bộ sách\n3. Đối thoại tự nhiên mang cá tính riêng của từng nhân vật, miêu tả tâm lý tinh tế, miêu tả cảnh vật sinh động\n4. Kết thúc để lại yếu tố hồi hộp hoặc gợi mở cho chương tiếp\n5. Sử dụng kỹ thuật 'Show don't Tell': thể hiện cảm xúc qua hành động và chi tiết, không chỉ mô tả trực tiếp\n6. Cân bằng giữa hành động, đối thoại và miêu tả nội tâm\n7. Chỉ xuất ra nội dung chính, KHÔNG có tiêu đề chương, KHÔNG giải thích, KHÔNG có phần suy nghĩ/phân tích, KHÔNG có meta-talk.\n8. TUYỆT ĐỐI KHÔNG lặp lại bất kỳ đoạn văn hay lời dẫn nào đã xuất hiện ở phần trước.{continuity_prompt}{context_prompt}",
"continuity_prompt": "\n\n【Ôn lại trước đó】\n{previous_content}\n\n【Danh sách kiểm tra tính mạch lạc】\n✓ Đảm bảo hướng cốt truyện nhất quán với phần trước\n✓ Trạng thái và vị trí nhân vật tương ứng với phần trước\n✓ Các yếu tố hồi hộp đã có được hưởng ứng hoặc đẩy lên trong chương này\n✓ Phong cách đối thoại nhân vật giữ nhất quán\n✓ TUYỆT ĐỐI KHÔNG lặp lại nội dung đã có. Không bắt đầu bằng việc nhắc lại cảnh cũ.\n✓ Bắt đầu viết thẳng vào diễn biến mới từ điểm kết thúc của phần trước.\n✓ Yếu tố hồi hộp mới mở đường cho các chương sau\n✓ Kiểm tra tiến độ so với dàn ý tổng thể, đảm bảo không đi lệch hướng\n✓ Phát triển các tuyến truyện phụ song song với tuyến chính\n\nHãy tuân thủ nghiêm ngặt danh sách kiểm tra trên để đảm bảo nội dung mạch lạc và KHÔNG TRÙNG LẶP.",
+701
View File
@@ -0,0 +1,701 @@
"""
TiniX Story API Server
FastAPI wrapper cho toàn bộ service Python hiện có
"""
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
import json
import logging
import uvicorn
import os
from pathlib import Path
from datetime import datetime
# Import existing services
from services.api_client import get_api_client, reinit_api_client
from core.config import get_config, GenerationConfig, API_PROVIDERS
from core.config_api import ConfigAPIManager
from services.novel_generator import (
NovelGenerator, NovelProject, Chapter, OutlineParser,
get_preset_templates, get_generator,
get_cache_size, list_generation_caches, clear_generation_cache
)
from services.project_manager import ProjectManager
from services.genre_manager import GenreManager
from services.sub_genre_manager import SubGenreManager
from services.style_manager import StyleManager
from utils.exporter import export_to_docx, export_to_txt, export_to_markdown, export_to_html
from locales.i18n import t
from core.database import get_db
from core.state import app_state
from core.task_manager import task_manager, TaskStatus
logger = logging.getLogger(__name__)
app = FastAPI(title="TiniX Story API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==================== Request/Response Models ====================
class ProjectCreateReq(BaseModel):
title: str
genre: str
sub_genres: List[str] = []
character_setting: str = ""
world_setting: str = ""
plot_idea: str = ""
class SuggestionReq(BaseModel):
type: str # 'title', 'char', 'world', 'plot'
genre: str = ""
sub_genres: List[str] = []
title: str = ""
character_setting: str = ""
world_setting: str = ""
custom_prompt: str = ""
num_main_chars: int = 2
num_sub_chars: int = 3
class OutlineReq(BaseModel):
title: str
genre: str
sub_genres: List[str] = []
total_chapters: int = 20
character_setting: str
world_setting: str
plot_idea: str
custom_outline_prompt: str = ""
class ChapterGenReq(BaseModel):
use_reflection: bool = False
class BulkGenReq(BaseModel):
project_id: str
chapter_nums: List[int]
custom_prompt: str = ""
use_reflection: bool = False
class SaveChapterReq(BaseModel):
project_id: str
chapter_num: int
content: str
class RewriteReq(BaseModel):
text: str
style_template: str = ""
use_reflection: bool = False
class PolishReq(BaseModel):
text: str
polish_type: str = "general"
custom_requirements: str = ""
use_reflection: bool = False
class SummaryReq(BaseModel):
text: str
max_length: int = 200
class ExportReq(BaseModel):
project_id: str
format: str = "txt" # txt, docx, md, html
class GenParamsReq(BaseModel):
temperature: Optional[float] = None
top_p: Optional[float] = None
max_tokens: Optional[int] = None
chapter_target_words: Optional[int] = None
writing_style: Optional[str] = None
writing_tone: Optional[str] = None
character_development: Optional[str] = None
plot_complexity: Optional[str] = None
class BackendReq(BaseModel):
name: str
type: str = "openai"
base_url: str = ""
api_key: str = ""
model: str = ""
timeout: int = 120
retry_times: int = 3
enabled: bool = True
class GenreReq(BaseModel):
name: str
description: str = ""
class StyleReq(BaseModel):
name: str
description: str = ""
class UpdateOutlineReq(BaseModel):
project_id: str
outline_text: str
# ==================== Helpers ====================
def _project_to_dict(project: NovelProject) -> Dict:
"""Serialize NovelProject to JSON-safe dict"""
return {
"id": getattr(project, 'id', ''),
"title": project.title,
"genre": project.genre,
"sub_genres": project.sub_genres if isinstance(project.sub_genres, list) else [],
"character_setting": project.character_setting or "",
"world_setting": project.world_setting or "",
"plot_idea": project.plot_idea or "",
"created_at": project.created_at,
"updated_at": project.updated_at,
"chapters": [
{
"num": ch.num,
"title": ch.title,
"desc": ch.desc,
"content": ch.content or "",
"word_count": ch.word_count,
"generated_at": ch.generated_at
}
for ch in project.chapters
],
"completed_count": project.get_completed_count(),
"total_words": project.get_total_words(),
}
def _get_generator() -> NovelGenerator:
return app_state.get_generator()
# ==================== Health ====================
@app.get("/health")
async def health_check():
return {"status": "ok", "version": "1.0.0"}
# ==================== Projects ====================
@app.get("/projects")
async def list_projects():
return ProjectManager.list_projects()
@app.get("/projects/{project_id}")
async def get_project(project_id: str):
project, msg = ProjectManager.load_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=msg)
return _project_to_dict(project)
@app.post("/projects")
async def create_project(req: ProjectCreateReq):
project, msg = ProjectManager.create_project(
req.title, req.genre, req.sub_genres,
req.character_setting, req.world_setting, req.plot_idea
)
if not project:
raise HTTPException(status_code=400, detail=msg)
ProjectManager.save_project(project)
return _project_to_dict(project)
@app.put("/projects/{project_id}")
async def update_project(project_id: str, req: ProjectCreateReq):
project, msg = ProjectManager.load_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=msg)
project.title = req.title
project.genre = req.genre
project.sub_genres = req.sub_genres
project.character_setting = req.character_setting
project.world_setting = req.world_setting
project.plot_idea = req.plot_idea
ProjectManager.save_project(project)
return _project_to_dict(project)
@app.delete("/projects/{project_id}")
async def delete_project(project_id: str):
success, msg = ProjectManager.delete_project(project_id)
if not success:
raise HTTPException(status_code=404, detail=msg)
return {"message": msg}
@app.post("/projects/update-outline")
async def update_outline(req: UpdateOutlineReq):
project, msg = ProjectManager.load_project(req.project_id)
if not project:
raise HTTPException(status_code=404, detail=msg)
chapters, parse_msg = OutlineParser.parse(req.outline_text)
if not chapters:
raise HTTPException(status_code=400, detail=parse_msg)
# Preserve existing content for chapters that already have it
old_content = {ch.num: ch for ch in project.chapters}
for ch in chapters:
if ch.num in old_content and old_content[ch.num].content:
ch.content = old_content[ch.num].content
ch.word_count = old_content[ch.num].word_count
ch.generated_at = old_content[ch.num].generated_at
project.chapters = chapters
ProjectManager.save_project(project)
return _project_to_dict(project)
# ==================== AI Suggestions ====================
@app.post("/suggest")
async def suggest(req: SuggestionReq):
gen = _get_generator()
if req.type == "title":
content, msg = gen.suggest_title(req.genre, req.sub_genres, req.custom_prompt)
else:
content, msg = gen.suggest_content(
req.type, req.title, req.genre, req.sub_genres,
req.character_setting, req.world_setting, req.custom_prompt,
req.num_main_chars, req.num_sub_chars
)
return {"content": content, "message": msg}
@app.post("/generate-outline")
async def generate_outline(req: OutlineReq):
gen = _get_generator()
content, msg = gen.generate_outline(
req.title, req.genre, req.sub_genres, req.total_chapters,
req.character_setting, req.world_setting, req.plot_idea,
req.custom_outline_prompt
)
return {"content": content, "message": msg}
@app.post("/parse-outline")
async def parse_outline(data: Dict[str, str]):
text = data.get("text", "")
chapters, msg = OutlineParser.parse(text)
return {"chapters": [c.to_dict() for c in chapters], "message": msg}
# ==================== Chapter Generation ====================
@app.post("/generate-chapter")
async def generate_chapter(req: ChapterGenReq):
project, msg = ProjectManager.load_project(req.project_id)
if not project:
raise HTTPException(status_code=404, detail=msg)
chapter = next((c for c in project.chapters if c.num == req.chapter_num), None)
if not chapter:
raise HTTPException(status_code=404, detail=f"Chapter {req.chapter_num} not found")
prev_chapters = [c for c in project.chapters if c.num < req.chapter_num and c.content]
previous_content = ""
if prev_chapters:
sorted_prev = sorted(prev_chapters, key=lambda x: x.num)
previous_content = sorted_prev[-1].content[-3000:] if sorted_prev[-1].content else ""
gen = _get_generator()
content, gen_msg = gen.generate_chapter(
req.chapter_num, chapter.title, chapter.desc, project.title,
project.character_setting, project.world_setting, project.plot_idea,
project.genre, project.sub_genres, previous_content,
custom_prompt=req.custom_prompt, use_reflection=req.use_reflection
)
if content:
chapter.content = content
chapter.word_count = len(content)
chapter.generated_at = datetime.now().isoformat()
ProjectManager.save_project(project)
return {"content": content, "message": gen_msg, "word_count": len(content) if content else 0}
@app.post("/generate-chapter-stream")
async def generate_chapter_stream(req: ChapterGenReq):
project, msg = ProjectManager.load_project(req.project_id)
if not project:
raise HTTPException(status_code=404, detail=msg)
chapter = next((c for c in project.chapters if c.num == req.chapter_num), None)
if not chapter:
raise HTTPException(status_code=404, detail=f"Chapter {req.chapter_num} not found")
prev_chapters = [c for c in project.chapters if c.num < req.chapter_num and c.content]
previous_content = ""
if prev_chapters:
sorted_prev = sorted(prev_chapters, key=lambda x: x.num)
previous_content = sorted_prev[-1].content[-3000:] if sorted_prev[-1].content else ""
gen = _get_generator()
async def event_generator():
full_content = ""
for success, chunk in gen.generate_chapter_stream(
req.chapter_num, chapter.title, chapter.desc, project.title,
project.character_setting, project.world_setting, project.plot_idea,
project.genre, project.sub_genres, previous_content,
custom_prompt=req.custom_prompt, use_reflection=req.use_reflection
):
if success:
full_content += chunk
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
else:
yield f"data: {json.dumps({'error': chunk})}\n\n"
# Auto-save after streaming completes
if full_content:
chapter.content = full_content
chapter.word_count = len(full_content)
chapter.generated_at = datetime.now().isoformat()
ProjectManager.save_project(project)
yield "data: [DONE]\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.post("/save-chapter")
async def save_chapter(req: SaveChapterReq):
project, msg = ProjectManager.load_project(req.project_id)
if not project:
raise HTTPException(status_code=404, detail=msg)
found = False
for ch in project.chapters:
if ch.num == req.chapter_num:
ch.content = req.content
ch.word_count = len(req.content)
ch.generated_at = datetime.now().isoformat()
found = True
break
if not found:
raise HTTPException(status_code=404, detail=f"Chapter {req.chapter_num} not found")
ProjectManager.save_project(project)
return {"message": "Chapter saved", "word_count": len(req.content)}
# ==================== Writing Tools ====================
@app.post("/rewrite")
async def rewrite(req: RewriteReq):
gen = _get_generator()
content, msg = gen.rewrite_paragraph(req.text, req.style_template, req.use_reflection)
return {"content": content, "message": msg}
@app.post("/polish")
async def polish(req: PolishReq):
gen = _get_generator()
content, msg = gen.polish_text(req.text, req.polish_type, req.custom_requirements, req.use_reflection)
return {"content": content, "message": msg}
@app.post("/summary")
async def summary(req: SummaryReq):
gen = _get_generator()
content, msg = gen.generate_summary(req.text, req.max_length)
return {"content": content, "message": msg}
@app.post("/export")
async def export_project(req: ExportReq):
project, msg = ProjectManager.load_project(req.project_id)
if not project:
raise HTTPException(status_code=404, detail=msg)
full_text = f"# {project.title}\n\n"
for ch in project.chapters:
if ch.content:
full_text += f"## Chương {ch.num}: {ch.title}\n\n"
full_text += ch.content + "\n\n"
if len(full_text.strip()) < 50:
raise HTTPException(status_code=400, detail="No content to export")
export_map = {
"docx": export_to_docx,
"txt": export_to_txt,
"md": export_to_markdown,
"html": export_to_html,
}
exporter = export_map.get(req.format)
if not exporter:
raise HTTPException(status_code=400, detail=f"Unsupported format: {req.format}")
filepath, exp_msg = exporter(full_text, project.title)
if not filepath:
raise HTTPException(status_code=500, detail=exp_msg)
return FileResponse(filepath, filename=os.path.basename(filepath), media_type="application/octet-stream")
# ==================== Background Task Coroutines ====================
async def generate_bulk_task(task, req: BulkGenReq):
project, msg = ProjectManager.load_project(req.project_id)
if not project:
task.update(status=TaskStatus.FAILED, message=msg)
return
gen = _get_generator()
total = len(req.chapter_nums)
for i, ch_num in enumerate(req.chapter_nums):
if task.is_cancelled():
break
task.update(message=f"Generating chapter {ch_num} ({i+1}/{total})", progress=(i / total) * 100)
chapter = next((c for c in project.chapters if c.num == ch_num), None)
if not chapter:
logger.error(f"Chapter {ch_num} not found in project {req.project_id}")
continue
# Get context from previous chapters
prev_chapters = [c for c in project.chapters if c.num < ch_num and c.content]
previous_content = ""
if prev_chapters:
sorted_prev = sorted(prev_chapters, key=lambda x: x.num)
previous_content = sorted_prev[-1].content[-3000:] if sorted_prev[-1].content else ""
# Run generation
# Note: NovelGenerator.generate_chapter is currently sync.
# In a real async app we should make it async, but for now we run it in a thread if needed.
# Since this is already in a background task, it's okay for now.
content, gen_msg = await asyncio.to_thread(
gen.generate_chapter,
ch_num, chapter.title, chapter.desc, project.title,
project.character_setting, project.world_setting, project.plot_idea,
project.genre, project.sub_genres, previous_content,
custom_prompt=req.custom_prompt, use_reflection=req.use_reflection
)
if content:
chapter.content = content
chapter.word_count = len(content)
chapter.generated_at = datetime.now().isoformat()
ProjectManager.save_project(project)
else:
logger.error(f"Failed to generate chapter {ch_num}: {gen_msg}")
if not task.is_cancelled():
task.update(status=TaskStatus.COMPLETED, progress=100.0, message=f"Successfully generated {total} chapters")
# ==================== Projects/Tasks Endpoints Extensions ====================
@app.post("/tasks/generate-bulk")
async def start_bulk_generation(req: BulkGenReq):
task = await task_manager.create_task(
name=f"Bulk Generation for {req.project_id}",
task_type="generate_bulk",
metadata={"project_id": req.project_id, "chapters": req.chapter_nums}
)
# Start the task in background
asyncio.create_task(task_manager.run_task(task.id, generate_bulk_task, req))
return {"task_id": task.id, "message": "Bulk generation started"}
# ==================== Genres ====================
@app.get("/genres")
async def list_genres():
return GenreManager.list_genres()
@app.post("/genres")
async def add_genre(req: GenreReq):
success = GenreManager.add_genre(req.name, req.description)
if not success:
raise HTTPException(status_code=400, detail="Genre already exists or error")
return {"message": "Genre added", "name": req.name}
@app.put("/genres/{name}")
async def update_genre(name: str, req: GenreReq):
success = GenreManager.update_genre(name, req.name, req.description)
if not success:
raise HTTPException(status_code=400, detail="Update failed")
return {"message": "Genre updated"}
@app.delete("/genres/{name}")
async def delete_genre(name: str):
success = GenreManager.delete_genre(name)
if not success:
raise HTTPException(status_code=400, detail="Delete failed")
return {"message": "Genre deleted"}
# ==================== Sub-Genres ====================
@app.get("/sub-genres")
async def list_all_sub_genres():
return SubGenreManager.get_sub_genre_names()
@app.get("/sub-genres/by-genre/{genre}")
async def list_sub_genres_by_genre(genre: str):
return SubGenreManager.get_sub_genres_by_genre(genre)
@app.post("/sub-genres")
async def add_sub_genre(req: GenreReq):
success = SubGenreManager.add_sub_genre(req.name, req.description)
if not success:
raise HTTPException(status_code=400, detail="Sub-genre already exists or error")
return {"message": "Sub-genre added", "name": req.name}
@app.put("/sub-genres/{name}")
async def update_sub_genre(name: str, req: GenreReq):
success = SubGenreManager.update_sub_genre(name, req.name, req.description)
if not success:
raise HTTPException(status_code=400, detail="Update failed")
return {"message": "Sub-genre updated"}
@app.delete("/sub-genres/{name}")
async def delete_sub_genre(name: str):
success = SubGenreManager.delete_sub_genre(name)
if not success:
raise HTTPException(status_code=400, detail="Delete failed")
return {"message": "Sub-genre deleted"}
# ==================== Styles ====================
@app.get("/styles")
async def list_styles():
return StyleManager.get_style_names()
@app.get("/styles/all")
async def list_styles_full():
return StyleManager.load_styles()
@app.post("/styles")
async def add_style(req: StyleReq):
success = StyleManager.add_style(req.name, req.description)
if not success:
raise HTTPException(status_code=400, detail="Style already exists or error")
return {"message": "Style added", "name": req.name}
@app.put("/styles/{name}")
async def update_style(name: str, req: StyleReq):
success = StyleManager.update_style(name, req.name, req.description)
if not success:
raise HTTPException(status_code=400, detail="Update failed")
return {"message": "Style updated"}
@app.delete("/styles/{name}")
async def delete_style(name: str):
success = StyleManager.delete_style(name)
if not success:
raise HTTPException(status_code=400, detail="Delete failed")
return {"message": "Style deleted"}
# ==================== Config / Settings ====================
@app.get("/config/backends")
async def config_list_backends():
return ConfigAPIManager.list_backends()
@app.post("/config/backends")
async def config_add_backend(req: BackendReq):
result = ConfigAPIManager.add_backend(
req.name, req.type, req.base_url, req.api_key,
req.model, req.timeout, req.retry_times, req.enabled
)
if result["success"]:
reinit_api_client()
app_state.generator = None
return result
@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,
api_key=req.api_key, model=req.model, timeout=req.timeout
)
if result["success"]:
reinit_api_client()
app_state.generator = None
return result
@app.delete("/config/backends/{name}")
async def config_delete_backend(name: str):
result = ConfigAPIManager.delete_backend(name)
if result["success"]:
reinit_api_client()
app_state.generator = None
return result
@app.post("/config/backends/{name}/test")
async def config_test_backend(name: str):
return ConfigAPIManager.test_backend(name)
@app.get("/config/generation")
async def config_get_generation():
cfg = get_config()
gen = cfg.generation
return {
"temperature": gen.temperature,
"top_p": gen.top_p,
"max_tokens": gen.max_tokens,
"chapter_target_words": gen.chapter_target_words,
"writing_style": gen.writing_style,
"writing_tone": gen.writing_tone,
"character_development": gen.character_development,
"plot_complexity": gen.plot_complexity,
}
@app.put("/config/generation")
async def config_update_generation(req: GenParamsReq):
cfg = get_config()
params = {k: v for k, v in req.model_dump().items() if v is not None}
success, msg = cfg.update_generation_config(**params)
if success:
app_state.generator = None
return {"success": success, "message": msg}
@app.get("/config/providers")
async def config_list_providers():
return API_PROVIDERS
# ==================== Cache ====================
@app.get("/cache/stats")
async def cache_stats():
try:
api_client = get_api_client()
stats = api_client.get_cache_stats()
gen_caches = list_generation_caches()
gen_size = get_cache_size()
return {
"api_cache": stats,
"generation_cache_count": len(gen_caches),
"generation_cache_size_kb": round(gen_size / 1024, 1)
}
except Exception as e:
return {"error": str(e)}
@app.delete("/cache")
async def clear_cache():
try:
api_client = get_api_client()
api_client.clear_cache()
clear_generation_cache()
return {"message": "All caches cleared"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== Task Management ====================
@app.get("/tasks")
async def list_tasks():
return task_manager.list_tasks()
@app.get("/tasks/{task_id}")
async def get_task_status(task_id: str):
task = task_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task.to_dict()
@app.delete("/tasks/{task_id}")
async def cancel_task(task_id: str):
task = task_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
task.cancel()
return {"message": "Task cancellation requested"}
# ==================== Entry ====================
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
+6 -1
View File
@@ -1,9 +1,14 @@
# Công cụ sáng tác tiểu thuyết AI Pro - Dependencies cốt lõi
# TiniX Story 1.0 - Core Dependencies
gradio>=4.0.0
pandas>=2.0.0
openai>=1.0.0
python-docx>=1.0.0
# FastAPI server
fastapi>=0.100.0
uvicorn[standard]>=0.20.0
pydantic>=2.0.0
# Dependencies tùy chọn (Sử dụng cho phân tích file)
PyMuPDF>=1.23.0 # Hỗ trợ PDF
ebooklib>=0.18 # Hỗ trợ EPUB
+61
View File
@@ -0,0 +1,61 @@
"""
TiniX Story 1.0 - Entry Point
Khởi động cả Gradio UI FastAPI server
"""
import os
import sys
import threading
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("run")
def start_gradio():
"""Khởi động Gradio UI trên port 7860"""
try:
from app import main
main()
except Exception as e:
logger.error(f"Gradio startup failed: {e}")
sys.exit(1)
def start_fastapi():
"""Khởi động FastAPI server trên port 8000"""
try:
import uvicorn
from main_api import app
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
except Exception as e:
logger.error(f"FastAPI startup failed: {e}")
sys.exit(1)
def main():
mode = os.getenv("TINIX_MODE", "all").lower()
if mode == "api":
logger.info("Starting TiniX Story in API-only mode (port 8000)")
start_fastapi()
elif mode == "ui":
logger.info("Starting TiniX Story in Gradio UI mode (port 7860)")
start_gradio()
else:
logger.info("Starting TiniX Story - All services")
logger.info(" → FastAPI: http://localhost:8000")
logger.info(" → Gradio UI: http://localhost:7860")
# FastAPI in background thread
api_thread = threading.Thread(target=start_fastapi, daemon=True)
api_thread.start()
# Gradio in main thread (blocks)
start_gradio()
if __name__ == "__main__":
main()
+3 -3
View File
@@ -15,6 +15,8 @@ from functools import wraps
import logging
from openai import OpenAI, RateLimitError, APIError, AuthenticationError, APIConnectionError
import pickle
import random
import re
from core.config import get_config, Backend
from locales.i18n import t
@@ -296,7 +298,6 @@ class APIClient:
retry_count = 0
base_wait = 1.0
import random
while retry_count < max_retries:
client_info = self._get_next_client(retry_count)
@@ -388,7 +389,6 @@ class APIClient:
response_str = str(response)
# Thử tìm content='...'
import re
content_match = re.search(r"content=(?:'|\")((?:.|\n)*?)(?:'|\"),\s*refusal", response_str)
if content_match:
content = content_match.group(1).replace("\\n", "\n").replace("\\'", "'")
@@ -537,7 +537,7 @@ class APIClient:
retry_count = 0
base_wait = 1.0
import random
while retry_count < max_retries:
client_info = self._get_next_client(retry_count)
+6 -9
View File
@@ -192,7 +192,8 @@ class NovelGenerator:
total_chapters: int,
character_setting: str,
world_setting: str,
plot_idea: str
plot_idea: str,
custom_outline_prompt: str = ""
) -> Tuple[str, str]:
"""
Tạo dàn ý tiểu thuyết
@@ -228,12 +229,15 @@ class NovelGenerator:
sub_genre_details.append(f"- {sg}")
style_desc += f"\n\nCác chủ đề con (Tag) bổ sung:\n" + "\n".join(sub_genre_details) + "\n\nHãy kết hợp chặt chẽ các đặc điểm của những chủ đề này để làm phong phú cấu trúc cốt truyện."
custom_prompt_str = f"Yêu cầu chuyên biệt của tác giả:\n{custom_outline_prompt}\n\n" if custom_outline_prompt.strip() else ""
prompt = t("prompts.outline_user",
genre=genre, title=title,
character_setting=character_setting,
world_setting=world_setting,
plot_idea=plot_idea,
style_desc=style_desc,
custom_prompt=custom_prompt_str,
total_chapters=total_chapters
)
@@ -424,11 +428,7 @@ class NovelGenerator:
if context_summary:
context_prompt = t("prompts.context_prompt", context_summary=context_summary)
# Lấy thông tin thể loại truyện thông qua class properties (cần get từ CSDL dự án hoặc cấu hình, mượn tạm cách try-except)
# Vì hàm `generate_chapter` ko truyền `genre`, ta sẽ thêm tham số hoặc ngầm hiểu thông qua plot/world_setting.
# Tạm thời cứ gán thêm nếu có thể, hoặc yêu cầu truyền thêm `genre` ở hàm gọi. Sẽ cập nhật `system_prompt` chung với outline.
genre_desc_prompt = ""
# TODO: Sắp tới cần update file `app.py` chỗ gọi hàm `generate_chapter` để truyền thêm Genre vào.
prompt = t("prompts.chapter_user",
novel_title=novel_title, chapter_num=chapter_num,
@@ -611,7 +611,6 @@ class NovelGenerator:
# Cơ chế thử lại: thử lại khi nội dung quá ngắn
max_retries = 3
content = ""
success_msg = ""
for attempt in range(max_retries):
logger.debug(f"Rewrite attempt {attempt + 1}/{max_retries}")
@@ -754,7 +753,6 @@ class NovelGenerator:
# Cơ chế thử lại: thử lại khi nội dung quá ngắn
max_retries = 3
content = ""
success_msg = ""
for attempt in range(max_retries):
logger.debug(f"Polish attempt {attempt + 1}/{max_retries}")
@@ -969,7 +967,6 @@ class NovelGenerator:
# Cơ chế thử lại
max_retries = 3
content = ""
success_msg = ""
for attempt in range(max_retries):
logger.debug(f"Continue attempt {attempt + 1}/{max_retries}")
+1 -1
View File
@@ -161,7 +161,7 @@ class ProjectManager:
sg_str = "[]"
try:
sg_list = json.loads(sg_str) if sg_str else []
except:
except (json.JSONDecodeError, TypeError, ValueError):
sg_list = []
# Xây dựng lại dự án
+10 -4
View File
@@ -109,6 +109,11 @@ def build_create_tab():
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
)
with gr.Row():
total_chapters = gr.Number(
label=t("create.chapter_count"), value=20, minimum=1, maximum=200, step=1, scale=1, interactive=False
@@ -202,12 +207,12 @@ def build_create_tab():
traceback.print_exc()
yield gr.update(), gr.update(value=f"❌ Lỗi: {str(e)}", visible=True), gr.update(interactive=True)
def on_generate_outline(title, genre, sub_genres, num_chapters, char_setting, world_setting, plot_idea, progress=gr.Progress()):
def on_generate_outline(title, genre, sub_genres, num_chapters, char_setting, world_setting, plot_idea, custom_outline, progress=gr.Progress()):
progress(0.1, desc="Đang gọi AI...")
gen = app_state.get_generator()
content, msg = gen.generate_outline(
title, genre, sub_genres or [],
int(num_chapters), char_setting, world_setting, plot_idea
int(num_chapters), char_setting, world_setting, plot_idea, custom_outline
)
return content, msg
@@ -350,7 +355,7 @@ def build_create_tab():
)
generate_outline_btn.click(
fn=on_generate_outline,
inputs=[title_input, genre_dropdown, sub_genre_dropdown, total_chapters, character_input, world_input, plot_input],
inputs=[title_input, genre_dropdown, sub_genre_dropdown, total_chapters, character_input, world_input, plot_input, custom_outline_prompt],
outputs=[outline_output, outline_status],
show_progress="full"
)
@@ -399,11 +404,12 @@ def build_create_tab():
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, generate_outline_btn]
outputs=[total_chapters, custom_outline_prompt, generate_outline_btn]
)
outline_output.change(
+44 -1
View File
@@ -10,10 +10,30 @@ from services.style_manager import StyleManager
from core.state import app_state
def build_settings_tab():
from core.auth import has_password, verify_password, set_password
with gr.Tab(t("tabs.settings")):
gr.Markdown(f"### {t('settings.header')}")
with gr.Tabs():
is_locked = has_password()
with gr.Column(visible=is_locked) as login_col:
gr.Markdown("#### 🔒 Bảng điều khiển bảo mật")
gr.Markdown("Cài đặt hệ thống đang được bảo vệ bằng mật khẩu.")
with gr.Row():
login_pwd = gr.Textbox(label="Vui lòng nhập mật khẩu", type="password", scale=4)
login_btn = gr.Button("Xác nhận", variant="primary", scale=1)
login_msg = gr.Markdown("")
with gr.Tabs(visible=not is_locked) as settings_tabs:
def on_login(pwd):
if verify_password(pwd):
return gr.update(visible=False), gr.update(visible=True), ""
else:
return gr.update(), gr.update(), "❌ Mật khẩu không chính xác!"
login_btn.click(fn=on_login, inputs=[login_pwd], outputs=[login_col, settings_tabs, login_msg])
login_pwd.submit(fn=on_login, inputs=[login_pwd], outputs=[login_col, settings_tabs, login_msg])
# Sub-tab: Quản lý giao diện API
with gr.Tab(t("settings.tab_backends")):
gr.Markdown(f"### {t('settings.backends_header')}")
@@ -466,3 +486,26 @@ def build_settings_tab():
style_add_btn.click(fn=on_style_add, inputs=[style_name_input, style_desc_input], outputs=[style_op_status, style_select])
style_update_btn.click(fn=on_style_update, inputs=[style_select, style_name_input, style_desc_input], outputs=[style_op_status, style_select])
style_delete_btn.click(fn=on_style_delete, inputs=[style_select], outputs=[style_op_status, style_select])
# Sub-tab: Bảo mật
with gr.Tab("Bảo mật"):
gr.Markdown("#### Quản lý Mật khẩu cho phần Cài đặt hệ thống")
gr.Markdown("Nếu để trống mật khẩu mới, hệ thống sẽ gỡ bỏ bảo vệ mật khẩu.")
with gr.Row():
with gr.Column(scale=1):
security_old_pwd = gr.Textbox(label="Mật khẩu cũ (để trống nếu chưa cài)", type="password")
security_new_pwd = gr.Textbox(label="Mật khẩu mới", type="password")
security_confirm_pwd = gr.Textbox(label="Xác nhận mật khẩu mới", type="password")
security_save_btn = gr.Button("Lưu mật khẩu", variant="primary")
security_status = gr.Textbox(label="Trạng thái", interactive=False)
def on_security_save(old_pwd, new_pwd, confirm_pwd):
if new_pwd != confirm_pwd:
return "❌ Mật khẩu xác nhận không khớp!"
success, msg = set_password(old_pwd, new_pwd)
return ("" if success else "") + msg
security_save_btn.click(fn=on_security_save, inputs=[security_old_pwd, security_new_pwd, security_confirm_pwd], outputs=[security_status])