feat: add SQLite write-ahead log and shared memory files.
@@ -1,7 +1,6 @@
|
||||
# ============================================
|
||||
# AI小说生成工具正式版V3.0
|
||||
# 版权所有 © 2026 新疆幻城网安科技有限责任公司 (幻城科技)
|
||||
# 作者:幻城
|
||||
# TiniX Story 1.0
|
||||
# Copyright © 2026 TiniX AI
|
||||
# ============================================
|
||||
|
||||
# ============================================
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"settings_password": "TiniX@123"
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -6,7 +6,6 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
container_name: ai-novel-generator
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
TiniX Story 1.0 - Entry Point
|
||||
Khởi động cả Gradio UI và 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||