initial: novel-app snapshot
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.pid
|
||||
*.log
|
||||
.env
|
||||
venv/
|
||||
.venv/
|
||||
node_modules/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
10
.omo/run-continuation/ses_ff1186371ffetBg7TKNOufDhae.json
Normal file
10
.omo/run-continuation/ses_ff1186371ffetBg7TKNOufDhae.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_ff1186371ffetBg7TKNOufDhae",
|
||||
"updatedAt": "2026-08-17T09:04:08.079Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-08-17T09:04:08.079Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
84
app.py
Normal file
84
app.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Flask backend for novel-writer.
|
||||
|
||||
Application factory only — routes live in routes/, business logic in
|
||||
services/, queries in repositories/. See config.py for env overrides.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from flask import Flask, g, has_request_context, request
|
||||
|
||||
import config
|
||||
import security
|
||||
from extensions import close_db
|
||||
from routes import api_bp, pages_bp
|
||||
|
||||
|
||||
class _RequestIdFilter(logging.Filter):
|
||||
"""Inject `request_id` into every LogRecord so the formatter can print it.
|
||||
|
||||
Reads Flask `g.request_id` when a request context is active; falls back
|
||||
to "-" for log lines emitted outside a request (startup, daemon threads).
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
if has_request_context():
|
||||
record.request_id = getattr(g, "request_id", "-")
|
||||
else:
|
||||
record.request_id = "-"
|
||||
return True
|
||||
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s [%(process)d] [%(request_id)s] %(levelname)s: %(message)s"
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
||||
_handler.addFilter(_RequestIdFilter())
|
||||
|
||||
_root = logging.getLogger()
|
||||
_root.setLevel(logging.INFO)
|
||||
if not any(isinstance(h, logging.StreamHandler) and h.formatter for h in _root.handlers):
|
||||
_root.addHandler(_handler)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=os.path.join(config.BASE_DIR, "templates"),
|
||||
static_folder=os.path.join(config.BASE_DIR, "static"),
|
||||
)
|
||||
app.secret_key = config.SECRET_KEY
|
||||
|
||||
@app.before_request
|
||||
def _assign_request_id():
|
||||
g.request_id = uuid.uuid4().hex[:8]
|
||||
|
||||
@app.after_request
|
||||
def _log_request(response):
|
||||
logger.info(
|
||||
"%s %s -> %s (%s)",
|
||||
request.method,
|
||||
request.path,
|
||||
response.status_code,
|
||||
g.request_id,
|
||||
)
|
||||
return response
|
||||
|
||||
app.teardown_appcontext(close_db)
|
||||
security.init_app(app)
|
||||
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(pages_bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=config.PORT, debug=False)
|
||||
26
config.py
Normal file
26
config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""novel-app 中央配置。
|
||||
|
||||
所有值均可通过环境变量覆盖(NOVEL_APP_* 前缀,SECRET_KEY 例外,
|
||||
沿用惯例的 FLASK_SECRET_KEY)。上下文组装预算(字符数)由
|
||||
services/context.py 与 routes/ 共享。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
DB_PATH = os.environ.get("NOVEL_APP_DB_PATH", os.path.join(BASE_DIR, "novel.db"))
|
||||
|
||||
LLM_URL = os.environ.get("NOVEL_APP_LLM_URL", "http://127.0.0.1:4000/v1")
|
||||
LLM_MODEL = os.environ.get("NOVEL_APP_LLM_MODEL", "default")
|
||||
LLM_TIMEOUT = int(os.environ.get("NOVEL_APP_LLM_TIMEOUT", "600"))
|
||||
|
||||
PORT = int(os.environ.get("NOVEL_APP_PORT", "8091"))
|
||||
|
||||
# 上下文组装预算(字符数)
|
||||
CTX_PREV_CHAPTERS = int(os.environ.get("NOVEL_APP_CTX_PREV_CHAPTERS", "2")) # 最近 N 章全文
|
||||
CTX_SUMMARY_CHAPTERS = int(os.environ.get("NOVEL_APP_CTX_SUMMARY_CHAPTERS", "20")) # 结构化摘要窗口
|
||||
CTX_BUDGET_MIN = int(os.environ.get("NOVEL_APP_CTX_BUDGET_MIN", "8000"))
|
||||
CTX_BUDGET_MAX = int(os.environ.get("NOVEL_APP_CTX_BUDGET_MAX", "120000"))
|
||||
|
||||
SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "dev-novel-secret-change-me")
|
||||
30
extensions.py
Normal file
30
extensions.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Flask 绑定的扩展:每请求数据库连接与 teardown。
|
||||
|
||||
这些辅助函数依赖 Flask 的 `g`,只能在应用/请求上下文中使用。
|
||||
纯(无 Flask 依赖)的辅助函数位于 services/ 与 repositories/。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from flask import g
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""返回每请求的 SQLite 连接(按名称访问行)。"""
|
||||
if "db" not in g:
|
||||
conn = sqlite3.connect(config.DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
g.db = conn
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(exception=None):
|
||||
"""通过 app.teardown_appcontext 注册的 teardown 处理器。"""
|
||||
conn = g.pop("db", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
5
repositories/__init__.py
Normal file
5
repositories/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Data-access layer for the novel app.
|
||||
|
||||
Each module exposes plain functions taking an explicit sqlite3 connection
|
||||
(row_factory=sqlite3.Row) and returning plain dicts. No Flask imports.
|
||||
"""
|
||||
109
repositories/chapter_repo.py
Normal file
109
repositories/chapter_repo.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Chapter-related queries.
|
||||
|
||||
Functions take an explicit sqlite3 connection with row_factory=sqlite3.Row
|
||||
and return plain dicts. No Flask imports.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
CHAPTER_COLUMNS = (
|
||||
"id, novel_id, volume, chapter_number, title, outline, content, "
|
||||
"word_count, status, created_at, updated_at"
|
||||
)
|
||||
|
||||
ALLOWED_UPDATE_FIELDS = {
|
||||
"title",
|
||||
"outline",
|
||||
"content",
|
||||
"word_count",
|
||||
"status",
|
||||
"volume",
|
||||
"chapter_number",
|
||||
}
|
||||
|
||||
|
||||
def _rows(cur):
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def list_chapters(db, novel_id):
|
||||
"""All chapters of a novel ordered by volume, chapter_number."""
|
||||
return _rows(
|
||||
db.execute(
|
||||
f"SELECT {CHAPTER_COLUMNS} FROM chapters WHERE novel_id = ? "
|
||||
"ORDER BY volume, chapter_number",
|
||||
(novel_id,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_chapter(db, chapter_id):
|
||||
"""Single chapter row as dict, or None."""
|
||||
row = db.execute(
|
||||
f"SELECT {CHAPTER_COLUMNS} FROM chapters WHERE id = ?", (chapter_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def get_chapter_by_number(db, novel_id, volume, chapter_number):
|
||||
"""Chapter identified by novel + volume + chapter_number, or None."""
|
||||
row = db.execute(
|
||||
f"SELECT {CHAPTER_COLUMNS} FROM chapters "
|
||||
"WHERE novel_id = ? AND volume = ? AND chapter_number = ?",
|
||||
(novel_id, volume, chapter_number),
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def create_chapter(db, novel_id, volume, chapter_number, title=None, outline=None, status="pending"):
|
||||
"""Insert a new chapter and return its id."""
|
||||
now = _now()
|
||||
cur = db.execute(
|
||||
"INSERT INTO chapters (novel_id, volume, chapter_number, title, outline, status, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(novel_id, volume, chapter_number, title, outline, status, now, now),
|
||||
)
|
||||
db.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_chapter(db, chapter_id, **fields):
|
||||
"""Update allowed chapter fields."""
|
||||
updates = {k: v for k, v in fields.items() if k in ALLOWED_UPDATE_FIELDS}
|
||||
if not updates:
|
||||
return
|
||||
updates["updated_at"] = _now()
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
db.execute(
|
||||
f"UPDATE chapters SET {assignments} WHERE id = ?",
|
||||
(*updates.values(), chapter_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_previous_chapters(db, novel_id, volume, chapter_number, limit=2):
|
||||
"""Chapters strictly before (volume, chapter_number), newest first, with content."""
|
||||
return _rows(
|
||||
db.execute(
|
||||
f"SELECT {CHAPTER_COLUMNS} FROM chapters "
|
||||
"WHERE novel_id = ? AND (volume < ? OR (volume = ? AND chapter_number < ?)) "
|
||||
"ORDER BY volume DESC, chapter_number DESC LIMIT ?",
|
||||
(novel_id, volume, volume, chapter_number, limit),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_done_chapters(db, novel_id, limit=20):
|
||||
"""Done chapters, newest first."""
|
||||
return _rows(
|
||||
db.execute(
|
||||
f"SELECT {CHAPTER_COLUMNS} FROM chapters "
|
||||
"WHERE novel_id = ? AND status = 'done' "
|
||||
"ORDER BY volume DESC, chapter_number DESC LIMIT ?",
|
||||
(novel_id, limit),
|
||||
)
|
||||
)
|
||||
83
repositories/character_repo.py
Normal file
83
repositories/character_repo.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Character-related queries.
|
||||
|
||||
Functions take an explicit sqlite3 connection with row_factory=sqlite3.Row
|
||||
and return plain dicts. No Flask imports.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
CHARACTER_COLUMNS = (
|
||||
"id, novel_id, name, role, description, first_appearance_chapter, "
|
||||
"status, created_at, updated_at"
|
||||
)
|
||||
|
||||
ALLOWED_UPDATE_FIELDS = {
|
||||
"name",
|
||||
"role",
|
||||
"description",
|
||||
"first_appearance_chapter",
|
||||
"status",
|
||||
}
|
||||
|
||||
|
||||
def _rows(cur):
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def list_characters(db, novel_id):
|
||||
"""All characters of a novel ordered by id."""
|
||||
return _rows(
|
||||
db.execute(
|
||||
f"SELECT {CHARACTER_COLUMNS} FROM characters WHERE novel_id = ? ORDER BY id",
|
||||
(novel_id,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_character(db, character_id):
|
||||
"""Single character row as dict, or None."""
|
||||
row = db.execute(
|
||||
f"SELECT {CHARACTER_COLUMNS} FROM characters WHERE id = ?", (character_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def create_character(db, novel_id, name, role=None, description=None, first_appearance_chapter=None):
|
||||
"""Insert a new character and return its id."""
|
||||
now = _now()
|
||||
cur = db.execute(
|
||||
"INSERT INTO characters (novel_id, name, role, description, first_appearance_chapter, status, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'alive', ?, ?)",
|
||||
(novel_id, name, role, description, first_appearance_chapter, now, now),
|
||||
)
|
||||
db.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_character(db, character_id, **fields):
|
||||
"""Update allowed character fields."""
|
||||
updates = {k: v for k, v in fields.items() if k in ALLOWED_UPDATE_FIELDS}
|
||||
if not updates:
|
||||
return
|
||||
updates["updated_at"] = _now()
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
db.execute(
|
||||
f"UPDATE characters SET {assignments} WHERE id = ?",
|
||||
(*updates.values(), character_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def find_characters_in_text(db, novel_id, text):
|
||||
"""Characters whose name appears as a substring of text."""
|
||||
characters = _rows(
|
||||
db.execute(
|
||||
f"SELECT {CHARACTER_COLUMNS} FROM characters WHERE novel_id = ?",
|
||||
(novel_id,),
|
||||
)
|
||||
)
|
||||
return [c for c in characters if c["name"] and c["name"] in text]
|
||||
74
repositories/foreshadowing_repo.py
Normal file
74
repositories/foreshadowing_repo.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Foreshadowing-related queries.
|
||||
|
||||
Functions take an explicit sqlite3 connection with row_factory=sqlite3.Row
|
||||
and return plain dicts. No Flask imports.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
FORESHADOWING_COLUMNS = (
|
||||
"id, novel_id, description, planted_chapter, resolved_chapter, "
|
||||
"status, created_at, updated_at"
|
||||
)
|
||||
|
||||
ALLOWED_UPDATE_FIELDS = {
|
||||
"description",
|
||||
"planted_chapter",
|
||||
"resolved_chapter",
|
||||
"status",
|
||||
}
|
||||
|
||||
|
||||
def _rows(cur):
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def list_foreshadowing(db, novel_id):
|
||||
"""All foreshadowing entries of a novel ordered by id."""
|
||||
return _rows(
|
||||
db.execute(
|
||||
f"SELECT {FORESHADOWING_COLUMNS} FROM foreshadowing WHERE novel_id = ? ORDER BY id",
|
||||
(novel_id,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_foreshadowing(db, novel_id, description, planted_chapter=None, resolved_chapter=None, status="pending"):
|
||||
"""Insert a new foreshadowing entry and return its id."""
|
||||
now = _now()
|
||||
cur = db.execute(
|
||||
"INSERT INTO foreshadowing (novel_id, description, planted_chapter, resolved_chapter, status, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(novel_id, description, planted_chapter, resolved_chapter, status, now, now),
|
||||
)
|
||||
db.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_foreshadowing(db, fs_id, **fields):
|
||||
"""Update allowed foreshadowing fields."""
|
||||
updates = {k: v for k, v in fields.items() if k in ALLOWED_UPDATE_FIELDS}
|
||||
if not updates:
|
||||
return
|
||||
updates["updated_at"] = _now()
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
db.execute(
|
||||
f"UPDATE foreshadowing SET {assignments} WHERE id = ?",
|
||||
(*updates.values(), fs_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_open_foreshadowing(db, novel_id):
|
||||
"""Open (pending/planted) foreshadowing entries ordered by id."""
|
||||
return _rows(
|
||||
db.execute(
|
||||
f"SELECT {FORESHADOWING_COLUMNS} FROM foreshadowing "
|
||||
"WHERE novel_id = ? AND status IN ('pending', 'planted') ORDER BY id",
|
||||
(novel_id,),
|
||||
)
|
||||
)
|
||||
69
repositories/job_repo.py
Normal file
69
repositories/job_repo.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Background-job-related queries.
|
||||
|
||||
Functions take an explicit sqlite3 connection with row_factory=sqlite3.Row
|
||||
and return plain dicts. No Flask imports.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
JOB_COLUMNS = (
|
||||
"id, novel_id, job_type, status, input_json, output_json, error, "
|
||||
"created_at, completed_at"
|
||||
)
|
||||
|
||||
ALLOWED_UPDATE_FIELDS = {
|
||||
"status",
|
||||
"output_json",
|
||||
"error",
|
||||
"completed_at",
|
||||
}
|
||||
|
||||
|
||||
def _rows(cur):
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def create_job(db, novel_id, job_type, input_json=None):
|
||||
"""Insert a new job with status 'pending' and return its id."""
|
||||
cur = db.execute(
|
||||
"INSERT INTO jobs (novel_id, job_type, status, input_json, created_at) "
|
||||
"VALUES (?, ?, 'pending', ?, ?)",
|
||||
(novel_id, job_type, input_json, _now()),
|
||||
)
|
||||
db.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_job(db, job_id, **fields):
|
||||
"""Update allowed job fields."""
|
||||
updates = {k: v for k, v in fields.items() if k in ALLOWED_UPDATE_FIELDS}
|
||||
if not updates:
|
||||
return
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
db.execute(
|
||||
f"UPDATE jobs SET {assignments} WHERE id = ?",
|
||||
(*updates.values(), job_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_job(db, job_id):
|
||||
"""Single job row as dict, or None."""
|
||||
row = db.execute(
|
||||
f"SELECT {JOB_COLUMNS} FROM jobs WHERE id = ?", (job_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def list_jobs(db, novel_id, limit=20):
|
||||
"""Most recent jobs for a novel, newest first."""
|
||||
return _rows(
|
||||
db.execute(
|
||||
f"SELECT {JOB_COLUMNS} FROM jobs WHERE novel_id = ? ORDER BY id DESC LIMIT ?",
|
||||
(novel_id, limit),
|
||||
)
|
||||
)
|
||||
99
repositories/novel_repo.py
Normal file
99
repositories/novel_repo.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Novel-related queries.
|
||||
|
||||
Functions take an explicit sqlite3 connection with row_factory=sqlite3.Row
|
||||
and return plain dicts. No Flask imports.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
NOVEL_COLUMNS = (
|
||||
"id, title, genre, style, target_words, current_words, status, "
|
||||
"created_at, updated_at"
|
||||
)
|
||||
|
||||
ALLOWED_UPDATE_FIELDS = {
|
||||
"title",
|
||||
"genre",
|
||||
"style",
|
||||
"target_words",
|
||||
"current_words",
|
||||
"status",
|
||||
}
|
||||
|
||||
|
||||
def _rows(cur):
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def list_novels(db):
|
||||
"""All novels ordered by updated_at desc."""
|
||||
return _rows(
|
||||
db.execute(f"SELECT {NOVEL_COLUMNS} FROM novels ORDER BY updated_at DESC")
|
||||
)
|
||||
|
||||
|
||||
def get_novel(db, novel_id):
|
||||
"""Single novel row as dict, or None."""
|
||||
row = db.execute(
|
||||
f"SELECT {NOVEL_COLUMNS} FROM novels WHERE id = ?", (novel_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def create_novel(db, title, genre=None, style=None, target_words=None, status="planning"):
|
||||
"""Insert a new novel and return its id."""
|
||||
now = _now()
|
||||
cur = db.execute(
|
||||
"INSERT INTO novels (title, genre, style, target_words, status, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(title, genre, style, target_words, status, now, now),
|
||||
)
|
||||
db.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_novel(db, novel_id, **fields):
|
||||
"""Update allowed novel fields and refresh updated_at."""
|
||||
updates = {k: v for k, v in fields.items() if k in ALLOWED_UPDATE_FIELDS}
|
||||
if not updates:
|
||||
return
|
||||
updates["updated_at"] = _now()
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
db.execute(
|
||||
f"UPDATE novels SET {assignments} WHERE id = ?",
|
||||
(*updates.values(), novel_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_stats(db, novel_id):
|
||||
"""Aggregate stats for one novel."""
|
||||
chapter_count = db.execute(
|
||||
"SELECT COUNT(*) FROM chapters WHERE novel_id = ?", (novel_id,)
|
||||
).fetchone()[0]
|
||||
done_chapters = db.execute(
|
||||
"SELECT COUNT(*) FROM chapters WHERE novel_id = ? AND status = 'done'",
|
||||
(novel_id,),
|
||||
).fetchone()[0]
|
||||
total_words = db.execute(
|
||||
"SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE novel_id = ?",
|
||||
(novel_id,),
|
||||
).fetchone()[0]
|
||||
character_count = db.execute(
|
||||
"SELECT COUNT(*) FROM characters WHERE novel_id = ?", (novel_id,)
|
||||
).fetchone()[0]
|
||||
open_foreshadowing = db.execute(
|
||||
"SELECT COUNT(*) FROM foreshadowing WHERE novel_id = ? AND status IN ('pending', 'planted')",
|
||||
(novel_id,),
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"chapter_count": chapter_count,
|
||||
"done_chapters": done_chapters,
|
||||
"total_words": total_words,
|
||||
"character_count": character_count,
|
||||
"open_foreshadowing": open_foreshadowing,
|
||||
}
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
requests>=2.28
|
||||
6
routes/__init__.py
Normal file
6
routes/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Route blueprints."""
|
||||
|
||||
from routes.api import bp as api_bp
|
||||
from routes.pages import bp as pages_bp
|
||||
|
||||
__all__ = ["api_bp", "pages_bp"]
|
||||
375
routes/api.py
Normal file
375
routes/api.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""JSON API + SSE streaming blueprint: /api/* endpoints.
|
||||
|
||||
SSE 规则 (沿用 blog-app 教训): 所有 DB 数据与上下文必须在 view 体内
|
||||
(application context 仍存活) 准备好; generator 在 view return 后才被消费,
|
||||
闭包内不得再依赖 Flask g / request, job 落库用独立 sqlite3 连接.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
import config
|
||||
import security
|
||||
from extensions import get_db
|
||||
from repositories import (
|
||||
chapter_repo,
|
||||
character_repo,
|
||||
foreshadowing_repo,
|
||||
job_repo,
|
||||
novel_repo,
|
||||
)
|
||||
from services import chapter_writer, deconstruct, llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
_SSE_HEADERS = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
def _payload():
|
||||
"""JSON body first, fall back to form fields."""
|
||||
if request.is_json:
|
||||
return request.get_json(silent=True) or {}
|
||||
return request.form.to_dict()
|
||||
|
||||
|
||||
def _job_conn():
|
||||
"""Fresh connection for generator-side job updates (outlives request)."""
|
||||
conn = sqlite3.connect(config.DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- novels
|
||||
|
||||
@bp.route("/novels")
|
||||
def api_novels():
|
||||
try:
|
||||
return jsonify(novel_repo.list_novels(get_db()))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novels failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels", methods=["POST"])
|
||||
def api_novel_create():
|
||||
"""创建小说 (拆书确认后调用): 基础字段 + 可选 chapters/characters/foreshadowing."""
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title required"}), 400
|
||||
try:
|
||||
db = get_db()
|
||||
novel_id = novel_repo.create_novel(
|
||||
db,
|
||||
title,
|
||||
genre=data.get("genre"),
|
||||
style=data.get("style"),
|
||||
target_words=data.get("target_words"),
|
||||
status="outlining",
|
||||
)
|
||||
for ch in data.get("outline_chapters") or []:
|
||||
chapter_repo.create_chapter(
|
||||
db, novel_id,
|
||||
ch.get("volume") or 1,
|
||||
ch.get("chapter_number") or 1,
|
||||
title=ch.get("title"),
|
||||
outline=ch.get("outline"),
|
||||
status="outlined" if ch.get("outline") else "pending",
|
||||
)
|
||||
for c in data.get("characters") or []:
|
||||
character_repo.create_character(
|
||||
db, novel_id, c.get("name") or "未命名",
|
||||
role=c.get("role"), description=c.get("description"),
|
||||
)
|
||||
for f in data.get("foreshadowing") or []:
|
||||
foreshadowing_repo.create_foreshadowing(
|
||||
db, novel_id, f.get("description") or "",
|
||||
planted_chapter=f.get("planted_chapter"),
|
||||
resolved_chapter=f.get("resolved_chapter"),
|
||||
)
|
||||
return jsonify({"id": novel_id}), 201
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novel_create failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>")
|
||||
def api_novel_detail(novel_id):
|
||||
try:
|
||||
db = get_db()
|
||||
novel = novel_repo.get_novel(db, novel_id)
|
||||
if novel is None:
|
||||
return jsonify({"error": "novel not found"}), 404
|
||||
novel["stats"] = novel_repo.get_stats(db, novel_id)
|
||||
return jsonify(novel)
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novel_detail failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/status", methods=["POST"])
|
||||
def api_novel_status(novel_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
novel_repo.update_novel(get_db(), novel_id, status=data.get("status"))
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novel_status failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- chapters
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/chapters")
|
||||
def api_chapters(novel_id):
|
||||
try:
|
||||
return jsonify(chapter_repo.list_chapters(get_db(), novel_id))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapters failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/chapters", methods=["POST"])
|
||||
def api_chapter_create(novel_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
chapter_id = chapter_repo.create_chapter(
|
||||
get_db(), novel_id,
|
||||
data.get("volume") or 1,
|
||||
data.get("chapter_number") or 1,
|
||||
title=data.get("title"),
|
||||
outline=data.get("outline"),
|
||||
status="outlined" if data.get("outline") else "pending",
|
||||
)
|
||||
return jsonify({"id": chapter_id}), 201
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_create failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>")
|
||||
def api_chapter_detail(chapter_id):
|
||||
try:
|
||||
chapter = chapter_repo.get_chapter(get_db(), chapter_id)
|
||||
if chapter is None:
|
||||
return jsonify({"error": "chapter not found"}), 404
|
||||
return jsonify(chapter)
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_detail failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>", methods=["POST"])
|
||||
def api_chapter_update(chapter_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
fields = {k: data[k] for k in ("title", "outline", "content", "status") if k in data}
|
||||
if "content" in fields:
|
||||
fields["word_count"] = len(fields["content"] or "")
|
||||
if not fields:
|
||||
return jsonify({"error": "no updatable fields"}), 400
|
||||
try:
|
||||
chapter_repo.update_chapter(get_db(), chapter_id, **fields)
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_update failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- characters / foreshadowing
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/characters")
|
||||
def api_characters(novel_id):
|
||||
try:
|
||||
return jsonify(character_repo.list_characters(get_db(), novel_id))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_characters failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/characters/<int:character_id>", methods=["POST"])
|
||||
def api_character_update(character_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
character_repo.update_character(get_db(), character_id, **data)
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_character_update failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/foreshadowing")
|
||||
def api_foreshadowing(novel_id):
|
||||
try:
|
||||
return jsonify(foreshadowing_repo.list_foreshadowing(get_db(), novel_id))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_foreshadowing failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/foreshadowing/<int:fs_id>", methods=["POST"])
|
||||
def api_foreshadowing_update(fs_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
foreshadowing_repo.update_foreshadowing(get_db(), fs_id, **data)
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_foreshadowing_update failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 拆书 SSE
|
||||
|
||||
@bp.route("/deconstruct/stream")
|
||||
def api_deconstruct_stream():
|
||||
"""SSE: 逐 section 拆书. 事件: section_start / token / section_end / error."""
|
||||
security.validate_csrf()
|
||||
section = request.args.get("section", "").strip()
|
||||
if section not in deconstruct.SECTIONS:
|
||||
return jsonify({"error": f"section must be one of {deconstruct.SECTIONS}"}), 400
|
||||
|
||||
confirmed_raw = request.args.get("confirmed", "").strip()
|
||||
try:
|
||||
confirmed = json.loads(confirmed_raw) if confirmed_raw else {}
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({"error": "confirmed must be valid JSON"}), 400
|
||||
|
||||
inputs = {
|
||||
"title": request.args.get("title", "").strip(),
|
||||
"genre": request.args.get("genre", "").strip(),
|
||||
"style": request.args.get("style", "").strip(),
|
||||
"target_words": request.args.get("target_words", "").strip(),
|
||||
"reference_novel": request.args.get("reference_novel", "").strip(),
|
||||
"confirmed": confirmed,
|
||||
}
|
||||
if not inputs["title"]:
|
||||
return jsonify({"error": "title required"}), 400
|
||||
|
||||
db = get_db()
|
||||
job_id = job_repo.create_job(
|
||||
db, None, "deconstruct", input_json=json.dumps(inputs, ensure_ascii=False)
|
||||
)
|
||||
|
||||
def _wrap():
|
||||
conn = _job_conn()
|
||||
try:
|
||||
for frame in deconstruct.stream_section(section, inputs):
|
||||
yield frame
|
||||
if frame.startswith("event: section_end"):
|
||||
job_repo.update_job(conn, job_id, status="done", output_json=frame)
|
||||
elif frame.startswith("event: error"):
|
||||
job_repo.update_job(conn, job_id, status="failed", error=frame)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.exception("deconstruct stream crashed")
|
||||
job_repo.update_job(conn, job_id, status="failed", error=str(exc))
|
||||
yield llm._sse("error", {"error": str(exc)})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return _wrap(), _SSE_HEADERS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 写书 SSE
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>/context")
|
||||
def api_chapter_context(chapter_id):
|
||||
"""章节上下文预览 (前端上下文面板)."""
|
||||
try:
|
||||
db = get_db()
|
||||
chapter = chapter_repo.get_chapter(db, chapter_id)
|
||||
if chapter is None:
|
||||
return jsonify({"error": "chapter not found"}), 404
|
||||
novel = novel_repo.get_novel(db, chapter["novel_id"])
|
||||
return jsonify(chapter_writer.build_chapter_context(db, novel, chapter))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_context failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>/generate")
|
||||
def api_chapter_generate(chapter_id):
|
||||
"""SSE: 流式写章节. 事件: context / token / done / error. ?prefix= 续写断点."""
|
||||
security.validate_csrf()
|
||||
prefix = request.args.get("prefix") or None
|
||||
|
||||
db = get_db()
|
||||
chapter = chapter_repo.get_chapter(db, chapter_id)
|
||||
if chapter is None:
|
||||
return jsonify({"error": "chapter not found"}), 404
|
||||
novel = novel_repo.get_novel(db, chapter["novel_id"])
|
||||
if novel is None:
|
||||
return jsonify({"error": "novel not found"}), 404
|
||||
|
||||
job_id = job_repo.create_job(
|
||||
db, novel["id"], "chapter",
|
||||
input_json=json.dumps(
|
||||
{"chapter_id": chapter_id, "prefix_len": len(prefix or "")},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
# 关键: 在 view 体内就把 generator 物化成帧列表前的数据全部取好.
|
||||
# chapter_writer.stream_chapter 需要 db 仅用于 build_chapter_context,
|
||||
# 所以先在 view 内构建 context, generator 只做纯 LLM 流式.
|
||||
try:
|
||||
context = chapter_writer.build_chapter_context(db, novel, chapter)
|
||||
except Exception as exc:
|
||||
logger.exception("failed to build chapter context")
|
||||
return jsonify({"error": f"failed to build context: {exc}"}), 500
|
||||
|
||||
def _wrap():
|
||||
conn = _job_conn()
|
||||
job_repo.update_job(conn, job_id, status="running")
|
||||
try:
|
||||
summary = (
|
||||
f"已注入上下文: 前 {len(context['prev_chapters'])} 章正文, "
|
||||
f"{len(context['earlier_summaries'])} 条更早摘要, "
|
||||
f"{len(context['characters'])} 个角色, "
|
||||
f"{len(context['open_foreshadowing'])} 条未回收伏笔, "
|
||||
f"共约 {context['total_chars']} 字符"
|
||||
+ (f"; 续写前缀 {len(prefix)} 字符" if prefix else "")
|
||||
)
|
||||
yield llm._sse("context", {"summary": summary})
|
||||
|
||||
messages = chapter_writer.build_chapter_prompts(context, chapter, prefix=prefix)
|
||||
parts = []
|
||||
|
||||
for frame in llm.make_token_stream(messages, parts.append, max_tokens=8192):
|
||||
yield frame
|
||||
if frame.startswith("event: error"):
|
||||
job_repo.update_job(conn, job_id, status="failed", error=frame)
|
||||
return
|
||||
|
||||
full_text = (prefix or "") + "".join(parts)
|
||||
chapter_repo.update_chapter(
|
||||
conn, chapter_id,
|
||||
content=full_text, word_count=len(full_text), status="drafting",
|
||||
)
|
||||
job_repo.update_job(
|
||||
conn, job_id, status="done", output_json=json.dumps(
|
||||
{"word_count": len(full_text)}, ensure_ascii=False
|
||||
),
|
||||
)
|
||||
yield llm._sse("done", {"content": full_text, "word_count": len(full_text)})
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.exception("chapter generate stream crashed")
|
||||
job_repo.update_job(conn, job_id, status="failed", error=str(exc))
|
||||
yield llm._sse("error", {"error": str(exc)})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return _wrap(), _SSE_HEADERS
|
||||
33
routes/pages.py
Normal file
33
routes/pages.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Page blueprint: HTML routes rendering Jinja2 templates."""
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
|
||||
bp = Blueprint("pages", __name__)
|
||||
|
||||
|
||||
@bp.route("/healthz")
|
||||
def page_healthz():
|
||||
"""Liveness probe. 不查 DB — deploy.sh 用它判断进程是否活着."""
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def page_index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@bp.route("/deconstruct")
|
||||
def page_deconstruct():
|
||||
return render_template("deconstruct.html")
|
||||
|
||||
|
||||
@bp.route("/novel/<int:novel_id>")
|
||||
def page_novel_detail(novel_id):
|
||||
return render_template("novel_detail.html", novel_id=novel_id)
|
||||
|
||||
|
||||
@bp.route("/novel/<int:novel_id>/chapter/<int:chapter_id>")
|
||||
def page_chapter_write(novel_id, chapter_id):
|
||||
return render_template(
|
||||
"chapter_write.html", novel_id=novel_id, chapter_id=chapter_id
|
||||
)
|
||||
84
scripts/deploy.sh
Executable file
84
scripts/deploy.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restart the novel-app Flask server on 0.0.0.0:8091.
|
||||
#
|
||||
# Guarantees:
|
||||
# - PID file (/tmp/novel-app.pid) tracks the live server.
|
||||
# - Old server is killed before new one starts.
|
||||
# - Health check loops on /healthz up to 30s (1s interval). 启动失败立即报错.
|
||||
# - On failure: kill the failed new PID, print last 50 lines of /tmp/novel-app.log, exit 1.
|
||||
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PID_FILE="$APP_DIR/app.pid"
|
||||
LOG_FILE="/tmp/novel-app.log"
|
||||
HEALTHZ_URL="http://127.0.0.1:8091/healthz"
|
||||
HEALTHZ_TIMEOUT_S=30
|
||||
PYTHON_BIN="/usr/bin/python3"
|
||||
|
||||
cd "$APP_DIR"
|
||||
export HOME=/home/yi
|
||||
|
||||
log() { printf '[deploy] %s\n' "$*"; }
|
||||
fail() {
|
||||
log "ERROR: $*"
|
||||
log "----- last 50 lines of $LOG_FILE -----"
|
||||
tail -n 50 "$LOG_FILE" 2>/dev/null || log "(no log file)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 1. Kill old server if PID is alive ---
|
||||
OLD_PID=""
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
log "stopping old server pid=$OLD_PID"
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5; do
|
||||
if ! kill -0 "$OLD_PID" 2>/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
log "old pid $OLD_PID did not exit, sending SIGKILL"
|
||||
kill -9 "$OLD_PID" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. Ensure DB schema exists ---
|
||||
"$PYTHON_BIN" scripts/init_db.py > /dev/null || fail "init_db failed"
|
||||
|
||||
# --- 3. Start new server, record PID ---
|
||||
log "starting new server: $PYTHON_BIN app.py (port 8091)"
|
||||
setsid nohup "$PYTHON_BIN" app.py > "$LOG_FILE" 2>&1 &
|
||||
NEW_PID=$!
|
||||
echo "$NEW_PID" > "$PID_FILE"
|
||||
log "new pid=$NEW_PID"
|
||||
|
||||
# --- 4. Health check loop ---
|
||||
healthz_ok=0
|
||||
for ((i=1; i<=HEALTHZ_TIMEOUT_S; i++)); do
|
||||
if ! kill -0 "$NEW_PID" 2>/dev/null; then
|
||||
fail "server process $NEW_PID died during startup"
|
||||
fi
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 2 "$HEALTHZ_URL" 2>/dev/null || echo '000')"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
healthz_ok=1
|
||||
log "healthz OK after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "$healthz_ok" -ne 1 ]]; then
|
||||
kill "$NEW_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$NEW_PID" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
fail "deploy failed; health check timed out"
|
||||
fi
|
||||
|
||||
log "deploy succeeded pid=$NEW_PID"
|
||||
curl -sS -o /dev/null -w 'local:%{http_code}\n' "$HEALTHZ_URL"
|
||||
101
scripts/init_db.py
Normal file
101
scripts/init_db.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""初始化 novel-app 数据库:创建表与索引。
|
||||
|
||||
用法:python3 scripts/init_db.py(可从任意目录运行)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import config
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS novels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
genre TEXT,
|
||||
style TEXT,
|
||||
target_words INTEGER,
|
||||
current_words INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'planning',
|
||||
created_at TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
novel_id INTEGER REFERENCES novels(id),
|
||||
volume INTEGER,
|
||||
chapter_number INTEGER,
|
||||
title TEXT,
|
||||
outline TEXT,
|
||||
content TEXT,
|
||||
word_count INTEGER,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
novel_id INTEGER REFERENCES novels(id),
|
||||
name TEXT NOT NULL,
|
||||
role TEXT,
|
||||
description TEXT,
|
||||
first_appearance_chapter INTEGER,
|
||||
status TEXT DEFAULT 'alive',
|
||||
created_at TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS foreshadowing (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
novel_id INTEGER REFERENCES novels(id),
|
||||
description TEXT,
|
||||
planted_chapter INTEGER,
|
||||
resolved_chapter INTEGER,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
novel_id INTEGER REFERENCES novels(id),
|
||||
job_type TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
input_json TEXT,
|
||||
output_json TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT, completed_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
INDEXES = """
|
||||
CREATE INDEX IF NOT EXISTS idx_chapters_novel ON chapters(novel_id, volume, chapter_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_characters_novel ON characters(novel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_foreshadowing_novel ON foreshadowing(novel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_novel ON jobs(novel_id);
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
db_path = config.DB_PATH
|
||||
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.executescript(INDEXES)
|
||||
conn.commit()
|
||||
|
||||
tables = [
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
]
|
||||
print(f"Database initialized at: {db_path}")
|
||||
print("Tables:")
|
||||
for t in tables:
|
||||
print(f" - {t}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
44
security.py
Normal file
44
security.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""CSRF protection (session-scoped token, no external dependency)."""
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from flask import abort, request, session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_csrf_token():
|
||||
"""Return the per-session CSRF token, generating one on first use."""
|
||||
token = session.get("_csrf_token")
|
||||
if not token:
|
||||
token = secrets.token_hex(32)
|
||||
session["_csrf_token"] = token
|
||||
return token
|
||||
|
||||
|
||||
def validate_csrf():
|
||||
"""Abort 403 if the CSRF token doesn't match the session token.
|
||||
|
||||
Token may arrive in the form field "csrf_token" (POST), the JSON body
|
||||
field "csrf_token" (fetch JSON POST), or as the query parameter
|
||||
csrf_token (GET SSE endpoints).
|
||||
"""
|
||||
expected = session.get("_csrf_token")
|
||||
provided = request.form.get("csrf_token", "") or request.args.get("csrf_token", "")
|
||||
if not provided and request.is_json:
|
||||
body = request.get_json(silent=True) or {}
|
||||
provided = body.get("csrf_token", "")
|
||||
if not expected or not provided or not hmac.compare_digest(expected, provided):
|
||||
logger.warning(
|
||||
"CSRF validation failed for %s from %s",
|
||||
request.path,
|
||||
request.remote_addr,
|
||||
)
|
||||
abort(403)
|
||||
|
||||
|
||||
def init_app(app):
|
||||
"""Expose csrf_token() to Jinja templates as a global."""
|
||||
app.jinja_env.globals["csrf_token"] = get_csrf_token
|
||||
7
services/__init__.py
Normal file
7
services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Service layer for the novel app.
|
||||
|
||||
Pure functions — no Flask imports. LLM access, book deconstruction (拆书),
|
||||
and chapter writing with context assembly. All DB access goes through
|
||||
explicit sqlite3 connections (row_factory=sqlite3.Row) and repository
|
||||
functions. SSE framing helpers live here for use by routes/.
|
||||
"""
|
||||
194
services/chapter_writer.py
Normal file
194
services/chapter_writer.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Chapter generation service: context assembly + streaming chapter writing.
|
||||
|
||||
Pure functions — no Flask imports. Context assembly follows the 拆书→写书
|
||||
pipeline: recent chapters full-text, earlier chapters as short summaries,
|
||||
relevant characters, open foreshadowing, novel meta. Total prompt size is
|
||||
bounded by ``config.CTX_BUDGET_MAX``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import config
|
||||
from repositories import chapter_repo, character_repo, foreshadowing_repo
|
||||
from services import llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是一位专业网文写手, 擅长按大纲续写章节, 文风贴合用户指定风格。"
|
||||
"只输出章节正文, 不要输出标题、解释或任何元信息。"
|
||||
)
|
||||
|
||||
_EARLIER_SUMMARY_CHARS = 200 # per earlier-chapter heuristic summary
|
||||
_PREFIX_TAIL_CHARS = 500 # how much of a resume-prefix to echo back
|
||||
|
||||
|
||||
def _truncate(text, limit):
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + "..."
|
||||
|
||||
|
||||
def build_chapter_context(db, novel, chapter, ctx_prev=None):
|
||||
"""Assemble the context dict for writing ``chapter`` of ``novel``.
|
||||
|
||||
Budget enforcement: drop earlier summaries first, then truncate prev
|
||||
chapter contents, to stay under ``config.CTX_BUDGET_MAX``.
|
||||
"""
|
||||
novel_id = novel["id"]
|
||||
prev_limit = ctx_prev or config.CTX_PREV_CHAPTERS
|
||||
|
||||
prev = chapter_repo.get_previous_chapters(
|
||||
db, novel_id, chapter.get("volume") or 1,
|
||||
chapter.get("chapter_number") or 1, limit=prev_limit,
|
||||
)
|
||||
prev_chapters = [
|
||||
{"title": c.get("title"), "volume": c.get("volume"),
|
||||
"chapter_number": c.get("chapter_number"), "content": c.get("content") or ""}
|
||||
for c in reversed(prev) # chronological order
|
||||
]
|
||||
|
||||
prev_ids = {c["id"] for c in prev}
|
||||
done = chapter_repo.list_done_chapters(
|
||||
db, novel_id, limit=config.CTX_SUMMARY_CHAPTERS
|
||||
)
|
||||
earlier_summaries = [
|
||||
{"title": c.get("title"), "volume": c.get("volume"),
|
||||
"chapter_number": c.get("chapter_number"),
|
||||
"summary": _truncate(c.get("content") or "", _EARLIER_SUMMARY_CHARS)}
|
||||
for c in done if c["id"] not in prev_ids
|
||||
]
|
||||
|
||||
outline = chapter.get("outline") or ""
|
||||
characters = character_repo.find_characters_in_text(db, novel_id, outline)
|
||||
if not characters:
|
||||
characters = character_repo.list_characters(db, novel_id)
|
||||
|
||||
context = {
|
||||
"novel_meta": {
|
||||
"title": novel.get("title"),
|
||||
"genre": novel.get("genre"),
|
||||
"style": novel.get("style"),
|
||||
},
|
||||
"prev_chapters": prev_chapters,
|
||||
"earlier_summaries": earlier_summaries,
|
||||
"characters": characters,
|
||||
"open_foreshadowing": foreshadowing_repo.list_open_foreshadowing(db, novel_id),
|
||||
}
|
||||
|
||||
serialized = json.dumps(context, ensure_ascii=False, default=str)
|
||||
# Budget pass 1: drop earlier summaries.
|
||||
if len(serialized) > config.CTX_BUDGET_MAX and context["earlier_summaries"]:
|
||||
logger.info("chapter context over budget, dropping earlier summaries")
|
||||
context["earlier_summaries"] = []
|
||||
serialized = json.dumps(context, ensure_ascii=False, default=str)
|
||||
# Budget pass 2: truncate prev chapter bodies proportionally.
|
||||
if len(serialized) > config.CTX_BUDGET_MAX and context["prev_chapters"]:
|
||||
per = max(1000, config.CTX_BUDGET_MAX // (2 * len(context["prev_chapters"])))
|
||||
for c in context["prev_chapters"]:
|
||||
c["content"] = _truncate(c["content"], per)
|
||||
serialized = json.dumps(context, ensure_ascii=False, default=str)
|
||||
logger.info("chapter context over budget, truncated prev chapters to %d chars each", per)
|
||||
|
||||
context["total_chars"] = len(serialized)
|
||||
if len(serialized) < config.CTX_BUDGET_MIN:
|
||||
logger.info(
|
||||
"chapter context small (%d chars < %d): early chapter or sparse data",
|
||||
len(serialized), config.CTX_BUDGET_MIN,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def build_chapter_prompts(context, chapter, prefix=None):
|
||||
"""Assemble system/user messages for chapter generation."""
|
||||
meta = context["novel_meta"]
|
||||
parts = [
|
||||
f"小说: 《{meta['title']}》 类型: {meta['genre']} 文风: {meta['style']}",
|
||||
"",
|
||||
]
|
||||
|
||||
if context["earlier_summaries"]:
|
||||
parts.append("【更早章节摘要】")
|
||||
for s in context["earlier_summaries"]:
|
||||
parts.append(
|
||||
f"第{s['volume']}卷 第{s['chapter_number']}章 {s.get('title') or ''}: {s['summary']}"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
if context["prev_chapters"]:
|
||||
parts.append("【最近章节正文】")
|
||||
for c in context["prev_chapters"]:
|
||||
parts.append(
|
||||
f"--- 第{c['volume']}卷 第{c['chapter_number']}章 {c.get('title') or ''} ---"
|
||||
)
|
||||
parts.append(c["content"])
|
||||
parts.append("")
|
||||
|
||||
if context["characters"]:
|
||||
parts.append("【相关角色】")
|
||||
for ch in context["characters"]:
|
||||
parts.append(
|
||||
f"{ch.get('name')} ({ch.get('role') or '角色'}, 状态: {ch.get('status')}): "
|
||||
f"{_truncate(ch.get('description') or '', 300)}"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
if context["open_foreshadowing"]:
|
||||
parts.append("【未回收伏笔】")
|
||||
for f in context["open_foreshadowing"]:
|
||||
parts.append(f"- {f.get('description')} (埋设: 第{f.get('planted_chapter') or '?'}章)")
|
||||
parts.append("")
|
||||
|
||||
parts.append("【当前章节大纲】")
|
||||
parts.append(
|
||||
f"第{chapter.get('volume') or 1}卷 第{chapter.get('chapter_number') or 1}章 "
|
||||
f"{chapter.get('title') or ''}"
|
||||
)
|
||||
parts.append(chapter.get("outline") or "(无大纲, 请自由发挥但承接前文)")
|
||||
parts.append("")
|
||||
|
||||
if prefix:
|
||||
parts.append("【续写指令】以下为本章已生成的开头, 请无缝续写, 不要重复已有内容:")
|
||||
parts.append(prefix[-_PREFIX_TAIL_CHARS:])
|
||||
parts.append("")
|
||||
|
||||
parts.append("要求: 写 3000-8000 字正文, 只输出正文, 不要标题不要解释。")
|
||||
|
||||
user_prompt = "\n".join(parts)
|
||||
return [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
|
||||
def stream_chapter(db, novel, chapter, prefix=None):
|
||||
"""Generator yielding SSE frames: context → token… → done / error.
|
||||
|
||||
NOTE: ``db`` is only used here to build the context; callers must build
|
||||
everything inside the request view and may pass a connection that dies
|
||||
with the request — so we build context eagerly before streaming.
|
||||
"""
|
||||
context = build_chapter_context(db, novel, chapter)
|
||||
summary = (
|
||||
f"已注入上下文: 前 {len(context['prev_chapters'])} 章正文, "
|
||||
f"{len(context['earlier_summaries'])} 条更早摘要, "
|
||||
f"{len(context['characters'])} 个角色, "
|
||||
f"{len(context['open_foreshadowing'])} 条未回收伏笔, "
|
||||
f"共约 {context['total_chars']} 字符"
|
||||
+ (f"; 续写前缀 {len(prefix)} 字符" if prefix else "")
|
||||
)
|
||||
yield llm._sse("context", {"summary": summary})
|
||||
|
||||
messages = build_chapter_prompts(context, chapter, prefix=prefix)
|
||||
parts = []
|
||||
|
||||
def _on_token(t):
|
||||
parts.append(t)
|
||||
|
||||
yield from llm.make_token_stream(messages, _on_token, max_tokens=8192)
|
||||
|
||||
full_text = (prefix or "") + "".join(parts)
|
||||
yield llm._sse("done", {"content": full_text, "word_count": len(full_text)})
|
||||
137
services/deconstruct.py
Normal file
137
services/deconstruct.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Book deconstruction (拆书) service: novel architect that produces
|
||||
concept, world, characters, outline, and foreshadowing sections.
|
||||
|
||||
Pure functions — no Flask imports. Each section is streamed via
|
||||
:func:`llm.make_token_stream` with ``section_start`` / ``section_end``
|
||||
SSE bookends.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from services import llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECTIONS = ["concept", "world", "characters", "outline", "foreshadowing"]
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是一位资深小说架构师, 精通网文结构设计与节奏把控。"
|
||||
"请根据用户的需求, 逐步构建完整的小说框架。"
|
||||
"输出纯文本, 不要使用 Markdown 标题标记 (# / ## 等), 用换行分段即可。"
|
||||
)
|
||||
|
||||
# ---- per-section Chinese user prompts -----------------------------------
|
||||
|
||||
_SECTION_PROMPTS = {
|
||||
"concept": (
|
||||
"请为小说《{title}》设计核心概念, 包括:\n"
|
||||
"1. 主题 (这部小说想表达什么)\n"
|
||||
"2. 核心冲突 (主角面临的主要矛盾)\n"
|
||||
"3. 一句话卖点 (最能吸引读者的概括)\n"
|
||||
"类型: {genre} | 风格: {style} | 目标字数: {target_words}"
|
||||
),
|
||||
"world": (
|
||||
"请为小说《{title}》设计世界观设定, 包括:\n"
|
||||
"1. 时代背景\n"
|
||||
"2. 主要地点\n"
|
||||
"3. 力量体系 (如有)\n"
|
||||
"4. 社会结构\n"
|
||||
"类型: {genre} | 风格: {style}"
|
||||
),
|
||||
"characters": (
|
||||
"请为小说《{title}》设计主要角色表。\n"
|
||||
"每个角色单独一段, 以【角色】名字 开头, 包含:\n"
|
||||
"名字 / 定位 / 性格 / 动机 / 成长弧线。\n"
|
||||
"类型: {genre} | 风格: {style}"
|
||||
),
|
||||
"outline": (
|
||||
"请为小说《{title}》规划三幕结构与卷章大纲。\n"
|
||||
"格式: 第N卷 / 第N章 标题 — 一句话概要\n"
|
||||
"预估总章数按目标字数 {target_words} 除以每章 5000 字计算。\n"
|
||||
"类型: {genre} | 风格: {style}"
|
||||
),
|
||||
"foreshadowing": (
|
||||
"请为小说《{title}》设计伏笔计划。\n"
|
||||
"每条伏笔单独一段, 以【伏笔】开头, 包含:\n"
|
||||
"描述 / 埋设位置 (第几卷第几章) / 回收位置。\n"
|
||||
"类型: {genre} | 风格: {style}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _format_inputs(inputs):
|
||||
"""Return (title, genre, style, target_words, reference_novel, confirmed)."""
|
||||
return (
|
||||
inputs.get("title", "未命名"),
|
||||
inputs.get("genre", "未指定"),
|
||||
inputs.get("style", "未指定"),
|
||||
inputs.get("target_words", "未指定"),
|
||||
inputs.get("reference_novel"),
|
||||
inputs.get("confirmed", {}),
|
||||
)
|
||||
|
||||
|
||||
def _confirmed_context(confirmed):
|
||||
"""Render previously confirmed sections as context for the next prompt."""
|
||||
if not confirmed:
|
||||
return ""
|
||||
parts = []
|
||||
for sec in SECTIONS:
|
||||
text = confirmed.get(sec)
|
||||
if text:
|
||||
parts.append(f"【已确认 - {sec}】\n{text}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def build_section_prompt(section, inputs):
|
||||
"""Build the messages list for a given section.
|
||||
|
||||
``inputs`` is a dict with keys: title, genre, style, target_words,
|
||||
reference_novel (optional), confirmed (dict of previous section texts).
|
||||
Returns a list of ``{"role": ..., "content": ...}`` dicts ready for
|
||||
the LLM chat API.
|
||||
"""
|
||||
title, genre, style, target_words, reference_novel, confirmed = _format_inputs(inputs)
|
||||
|
||||
template = _SECTION_PROMPTS.get(section)
|
||||
if template is None:
|
||||
raise ValueError(f"unknown section: {section}")
|
||||
|
||||
user_prompt = template.format(
|
||||
title=title,
|
||||
genre=genre,
|
||||
style=style,
|
||||
target_words=target_words,
|
||||
)
|
||||
|
||||
if reference_novel:
|
||||
user_prompt += f"\n\n参考小说: 《{reference_novel}》, 参考其节奏与结构但不抄袭。"
|
||||
|
||||
ctx = _confirmed_context(confirmed)
|
||||
if ctx:
|
||||
user_prompt = f"以下是已确认的前序设定:\n\n{ctx}\n\n---\n\n{user_prompt}"
|
||||
|
||||
return [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
|
||||
def stream_section(section, inputs):
|
||||
"""Generator yielding SSE frames for a section.
|
||||
|
||||
Emits ``section_start`` → token frames → ``section_end`` (with full
|
||||
accumulated content). Uses :func:`llm.make_token_stream` under the hood.
|
||||
"""
|
||||
messages = build_section_prompt(section, inputs)
|
||||
yield llm._sse("section_start", {"section": section})
|
||||
|
||||
parts = []
|
||||
|
||||
def _on_token(t):
|
||||
parts.append(t)
|
||||
|
||||
yield from llm.make_token_stream(messages, _on_token, max_tokens=4096)
|
||||
|
||||
full_text = "".join(parts)
|
||||
yield llm._sse("section_end", {"section": section, "content": full_text})
|
||||
96
services/llm.py
Normal file
96
services/llm.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""LLM client: streaming and non-streaming chat completions, SSE framing.
|
||||
|
||||
Pure functions — no Flask imports. Uses ``requests`` for HTTP against an
|
||||
OpenAI-compatible endpoint (``config.LLM_URL``). The ``_sse`` helper is
|
||||
shared by deconstruct / chapter_writer so every SSE stream uses the same
|
||||
wire format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KEEPALIVE_INTERVAL = 15 # seconds
|
||||
|
||||
|
||||
def _sse(event, payload):
|
||||
"""Return a single SSE frame: ``event: <event>\\ndata: <json>\\n\\n``."""
|
||||
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def stream_chat(messages, max_tokens=4096, model=None, timeout=None):
|
||||
"""Generator yielding raw token strings from a streaming chat completion.
|
||||
|
||||
POSTs to ``{config.LLM_URL}/chat/completions`` with ``stream=True``.
|
||||
Parses ``data:`` lines, handles ``[DONE]`` terminator, extracts
|
||||
``choices[0].delta.content``. Raises ``RuntimeError`` on HTTP failure.
|
||||
"""
|
||||
url = f"{config.LLM_URL}/chat/completions"
|
||||
body = {
|
||||
"model": model or config.LLM_MODEL,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
resp = http_requests.post(url, json=body, stream=True, timeout=timeout or config.LLM_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line = line.decode("utf-8") if isinstance(line, bytes) else line
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
chunk_data = line[6:]
|
||||
if chunk_data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(chunk_data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
token_text = delta.get("content", "")
|
||||
if token_text:
|
||||
yield token_text
|
||||
|
||||
|
||||
def complete_chat(messages, max_tokens=2048, model=None, timeout=None):
|
||||
"""Non-streaming call returning the assistant content string."""
|
||||
url = f"{config.LLM_URL}/chat/completions"
|
||||
body = {
|
||||
"model": model or config.LLM_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
resp = http_requests.post(url, json=body, timeout=timeout or config.LLM_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
|
||||
def make_token_stream(messages, on_token, max_tokens=4096):
|
||||
"""Generator wrapping :func:`stream_chat` with SSE framing + keepalive.
|
||||
|
||||
Yields ``_sse('token', {'text': t})`` for each token. Calls
|
||||
``on_token(t)`` per token so callers can accumulate the full text.
|
||||
Emits ``: keepalive\\n\\n`` comments if >15 s silence between chunks.
|
||||
On exception yields ``_sse('error', {'error': ...})`` and stops.
|
||||
"""
|
||||
last_emit = time.time()
|
||||
try:
|
||||
for token in stream_chat(messages, max_tokens=max_tokens):
|
||||
now = time.time()
|
||||
if now - last_emit > _KEEPALIVE_INTERVAL:
|
||||
yield ": keepalive\n\n"
|
||||
yield _sse("token", {"text": token})
|
||||
on_token(token)
|
||||
last_emit = time.time()
|
||||
except Exception as exc:
|
||||
logger.exception("make_token_stream: LLM error")
|
||||
yield _sse("error", {"error": str(exc)})
|
||||
77
static/js/components/sse-client.js
Normal file
77
static/js/components/sse-client.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/* Generic SSE client wrapper for novel-app streams.
|
||||
*
|
||||
* 后端事件契约:
|
||||
* token: {text:string}
|
||||
* section_start: {section:string} (拆书)
|
||||
* section_end: {section:string, content:string}
|
||||
* context: {summary:string} (写章节)
|
||||
* done: {content:string, word_count:number}
|
||||
* error: {error:string}
|
||||
*
|
||||
* handlers 全部可选:
|
||||
* {onToken, onSectionStart, onSectionEnd, onContext, onDone, onError}
|
||||
* 返回句柄 {close()}, close() 幂等. done / error 帧后自动 close.
|
||||
*/
|
||||
|
||||
const EVENTS = ["token", "section_start", "section_end", "context", "done", "error"];
|
||||
|
||||
export function createSSEStream(url, handlers = {}) {
|
||||
let closed = false;
|
||||
const es = new EventSource(url);
|
||||
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
es.close();
|
||||
}
|
||||
|
||||
function parse(e) {
|
||||
try {
|
||||
return JSON.parse(e.data);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
es.addEventListener("token", (e) => {
|
||||
const d = parse(e);
|
||||
if (d && handlers.onToken) handlers.onToken(d.text || "");
|
||||
});
|
||||
|
||||
es.addEventListener("section_start", (e) => {
|
||||
const d = parse(e);
|
||||
if (d && handlers.onSectionStart) handlers.onSectionStart(d.section);
|
||||
});
|
||||
|
||||
es.addEventListener("section_end", (e) => {
|
||||
const d = parse(e);
|
||||
if (d && handlers.onSectionEnd) handlers.onSectionEnd(d.section, d.content || "");
|
||||
close();
|
||||
});
|
||||
|
||||
es.addEventListener("context", (e) => {
|
||||
const d = parse(e);
|
||||
if (d && handlers.onContext) handlers.onContext(d.summary || "");
|
||||
});
|
||||
|
||||
es.addEventListener("done", (e) => {
|
||||
const d = parse(e);
|
||||
if (d && handlers.onDone) handlers.onDone(d.content || "", d.word_count || 0);
|
||||
close();
|
||||
});
|
||||
|
||||
es.addEventListener("error", (e) => {
|
||||
const d = parse(e);
|
||||
if (handlers.onError) handlers.onError(d && d.error ? d.error : "未知错误", "server");
|
||||
close();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
if (!closed) {
|
||||
if (handlers.onError) handlers.onError("连接错误, 请检查后端日志", "connection");
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
return { close };
|
||||
}
|
||||
55
static/js/components/status-badge.js
Normal file
55
static/js/components/status-badge.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Status badge renderer (Bootstrap pill). */
|
||||
|
||||
import { escapeHtml } from "../utils.js";
|
||||
|
||||
const COLOR_MAP = {
|
||||
// chapter / job statuses
|
||||
pending: "secondary",
|
||||
outlined: "info",
|
||||
drafting: "warning",
|
||||
done: "success",
|
||||
reviewed: "primary",
|
||||
running: "warning",
|
||||
failed: "danger",
|
||||
// novel statuses
|
||||
planning: "secondary",
|
||||
outlining: "info",
|
||||
reviewing: "primary",
|
||||
completed: "success",
|
||||
// character statuses
|
||||
alive: "success",
|
||||
dead: "dark",
|
||||
unknown: "secondary",
|
||||
// foreshadowing statuses
|
||||
planted: "info",
|
||||
resolved: "success",
|
||||
missed: "danger",
|
||||
};
|
||||
|
||||
const LABEL_MAP = {
|
||||
pending: "待处理",
|
||||
outlined: "已列纲",
|
||||
drafting: "写作中",
|
||||
done: "已完成",
|
||||
reviewed: "已审阅",
|
||||
running: "运行中",
|
||||
failed: "失败",
|
||||
planning: "筹划中",
|
||||
outlining: "大纲中",
|
||||
reviewing: "审阅中",
|
||||
completed: "完本",
|
||||
alive: "存活",
|
||||
dead: "死亡",
|
||||
unknown: "未知",
|
||||
planted: "已埋设",
|
||||
resolved: "已回收",
|
||||
missed: "已遗漏",
|
||||
};
|
||||
|
||||
/** Return a badge HTML string for a status. */
|
||||
export function statusBadge(status) {
|
||||
const key = String(status || "pending");
|
||||
const color = COLOR_MAP[key] || "secondary";
|
||||
const label = LABEL_MAP[key] || key;
|
||||
return `<span class="badge text-bg-${color}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
230
static/js/pages/chapter-write.js
Normal file
230
static/js/pages/chapter-write.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/* chapter write page: 大纲编辑 + 上下文面板 + SSE 流式生成 + 暂停/继续/重写. */
|
||||
|
||||
import { escapeHtml, fetchJson, postJson, showError } from "../utils.js";
|
||||
import { createSSEStream } from "../components/sse-client.js";
|
||||
|
||||
const NOVEL_ID = window.NOVEL_ID;
|
||||
const CHAPTER_ID = window.CHAPTER_ID;
|
||||
|
||||
const alertsEl = document.getElementById("cwAlerts");
|
||||
const outlineEl = document.getElementById("cwOutline");
|
||||
const outputEl = document.getElementById("cwOutput");
|
||||
const wordCountEl = document.getElementById("cwWordCount");
|
||||
const ctxSummaryEl = document.getElementById("cwContextSummary");
|
||||
|
||||
const btn = {
|
||||
generate: document.getElementById("cwGenerate"),
|
||||
pause: document.getElementById("cwPause"),
|
||||
resume: document.getElementById("cwResume"),
|
||||
regen: document.getElementById("cwRegen"),
|
||||
edit: document.getElementById("cwEdit"),
|
||||
save: document.getElementById("cwSave"),
|
||||
approve: document.getElementById("cwApprove"),
|
||||
saveOutline: document.getElementById("cwSaveOutline"),
|
||||
};
|
||||
|
||||
let stream = null;
|
||||
let accumulated = ""; // 已生成文本 (暂停断点)
|
||||
let editing = false;
|
||||
|
||||
/* ---------------- 状态管理 ---------------- */
|
||||
|
||||
function setStreaming(on) {
|
||||
btn.generate.disabled = on;
|
||||
btn.pause.disabled = !on;
|
||||
btn.regen.disabled = on || !accumulated;
|
||||
btn.resume.disabled = on || !accumulated;
|
||||
btn.saveOutline.disabled = on;
|
||||
if (on) {
|
||||
btn.edit.disabled = true;
|
||||
btn.save.disabled = true;
|
||||
btn.approve.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setIdleWithContent() {
|
||||
btn.generate.disabled = false;
|
||||
btn.pause.disabled = true;
|
||||
btn.resume.disabled = true;
|
||||
btn.regen.disabled = false;
|
||||
btn.edit.disabled = false;
|
||||
btn.save.disabled = false;
|
||||
btn.approve.disabled = false;
|
||||
}
|
||||
|
||||
function renderOutput() {
|
||||
outputEl.innerHTML = accumulated
|
||||
? escapeHtml(accumulated)
|
||||
: '<span class="text-muted">点击"生成"开始写作…</span>';
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
wordCountEl.textContent = accumulated ? "当前字数: " + accumulated.length : "";
|
||||
}
|
||||
|
||||
/* ---------------- 加载章节 + 上下文 ---------------- */
|
||||
|
||||
async function loadChapter() {
|
||||
try {
|
||||
const ch = await fetchJson("/api/chapters/" + CHAPTER_ID);
|
||||
document.getElementById("cwTitle").textContent =
|
||||
"第" + (ch.volume || 1) + "卷 第" + (ch.chapter_number || 1) + "章 " + (ch.title || "");
|
||||
outlineEl.value = ch.outline || "";
|
||||
if (ch.content) {
|
||||
accumulated = ch.content;
|
||||
renderOutput();
|
||||
setIdleWithContent();
|
||||
}
|
||||
} catch (err) {
|
||||
showError(alertsEl, "加载章节失败: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContext() {
|
||||
const el = document.getElementById("cwContext");
|
||||
try {
|
||||
const ctx = await fetchJson("/api/chapters/" + CHAPTER_ID + "/context");
|
||||
let html = "";
|
||||
|
||||
html += "<details open><summary class=\"fw-bold\">前文摘要 ("
|
||||
+ ctx.earlier_summaries.length + ")</summary>";
|
||||
html += ctx.earlier_summaries.length
|
||||
? ctx.earlier_summaries.map((s) =>
|
||||
"<p class=\"small mb-1\">第" + s.volume + "卷 第" + s.chapter_number + "章 "
|
||||
+ escapeHtml(s.title || "") + ": " + escapeHtml(s.summary) + "</p>").join("")
|
||||
: '<p class="small text-muted">无</p>';
|
||||
html += "</details>";
|
||||
|
||||
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">前章正文 ("
|
||||
+ ctx.prev_chapters.length + ")</summary>";
|
||||
html += ctx.prev_chapters.length
|
||||
? ctx.prev_chapters.map((c) =>
|
||||
"<details class=\"ms-2\"><summary class=\"small\">第" + c.volume + "卷 第"
|
||||
+ c.chapter_number + "章 " + escapeHtml(c.title || "") + "</summary>"
|
||||
+ "<pre class=\"small\" style=\"white-space:pre-wrap;max-height:200px;overflow-y:auto;\">"
|
||||
+ escapeHtml(c.content) + "</pre></details>").join("")
|
||||
: '<p class="small text-muted">无 (这是第一章)</p>';
|
||||
html += "</details>";
|
||||
|
||||
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">相关角色 ("
|
||||
+ ctx.characters.length + ")</summary>";
|
||||
html += ctx.characters.length
|
||||
? ctx.characters.map((c) =>
|
||||
"<p class=\"small mb-1\"><strong>" + escapeHtml(c.name) + "</strong> ("
|
||||
+ escapeHtml(c.role || "") + "): "
|
||||
+ escapeHtml((c.description || "").slice(0, 200)) + "</p>").join("")
|
||||
: '<p class="small text-muted">无</p>';
|
||||
html += "</details>";
|
||||
|
||||
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">未回收伏笔 ("
|
||||
+ ctx.open_foreshadowing.length + ")</summary>";
|
||||
html += ctx.open_foreshadowing.length
|
||||
? ctx.open_foreshadowing.map((f) =>
|
||||
"<p class=\"small mb-1\">" + escapeHtml(f.description) + "</p>").join("")
|
||||
: '<p class="small text-muted">无</p>';
|
||||
html += "</details>";
|
||||
|
||||
html += '<p class="small text-muted mt-2">上下文总大小: ' + ctx.total_chars + " 字符</p>";
|
||||
el.innerHTML = html;
|
||||
} catch (err) {
|
||||
el.innerHTML = '<div class="alert alert-danger small">' + escapeHtml(err.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 生成控制 ---------------- */
|
||||
|
||||
function startStream(prefix) {
|
||||
if (stream) stream.close();
|
||||
setStreaming(true);
|
||||
ctxSummaryEl.innerHTML = "";
|
||||
|
||||
const params = new URLSearchParams({ csrf_token: document.querySelector('meta[name="csrf-token"]').content });
|
||||
if (prefix) params.append("prefix", prefix);
|
||||
|
||||
stream = createSSEStream("/api/chapters/" + CHAPTER_ID + "/generate?" + params.toString(), {
|
||||
onContext: (summary) => {
|
||||
ctxSummaryEl.innerHTML = '<div class="alert alert-info small py-1">'
|
||||
+ escapeHtml(summary) + "</div>";
|
||||
},
|
||||
onToken: (t) => {
|
||||
accumulated += t;
|
||||
renderOutput();
|
||||
},
|
||||
onDone: (content) => {
|
||||
accumulated = content;
|
||||
renderOutput();
|
||||
stream = null;
|
||||
setIdleWithContent();
|
||||
},
|
||||
onError: (msg) => {
|
||||
showError(alertsEl, "生成失败: " + msg);
|
||||
stream = null;
|
||||
setStreaming(false);
|
||||
btn.resume.disabled = !accumulated;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
btn.generate.addEventListener("click", () => {
|
||||
accumulated = "";
|
||||
renderOutput();
|
||||
startStream(null);
|
||||
});
|
||||
|
||||
btn.pause.addEventListener("click", () => {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
setStreaming(false);
|
||||
btn.resume.disabled = !accumulated;
|
||||
btn.regen.disabled = !accumulated;
|
||||
});
|
||||
|
||||
btn.resume.addEventListener("click", () => startStream(accumulated));
|
||||
|
||||
btn.regen.addEventListener("click", () => {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
accumulated = "";
|
||||
renderOutput();
|
||||
startStream(null);
|
||||
});
|
||||
|
||||
btn.edit.addEventListener("click", () => {
|
||||
editing = !editing;
|
||||
outputEl.contentEditable = editing ? "true" : "false";
|
||||
btn.edit.textContent = editing ? "完成编辑" : "编辑";
|
||||
if (editing) outputEl.focus();
|
||||
else accumulated = outputEl.innerText;
|
||||
});
|
||||
|
||||
btn.save.addEventListener("click", async () => {
|
||||
if (editing) accumulated = outputEl.innerText;
|
||||
try {
|
||||
await postJson("/api/chapters/" + CHAPTER_ID, { content: accumulated });
|
||||
renderOutput();
|
||||
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">已保存</div>';
|
||||
} catch (err) {
|
||||
showError(alertsEl, "保存失败: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
btn.approve.addEventListener("click", async () => {
|
||||
if (editing) accumulated = outputEl.innerText;
|
||||
try {
|
||||
await postJson("/api/chapters/" + CHAPTER_ID, { content: accumulated, status: "done" });
|
||||
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">本章已通过 (status=done)</div>';
|
||||
} catch (err) {
|
||||
showError(alertsEl, "操作失败: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
btn.saveOutline.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJson("/api/chapters/" + CHAPTER_ID, { outline: outlineEl.value });
|
||||
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">大纲已保存</div>';
|
||||
loadContext();
|
||||
} catch (err) {
|
||||
showError(alertsEl, "保存大纲失败: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------- init ---------------- */
|
||||
|
||||
loadChapter();
|
||||
loadContext();
|
||||
250
static/js/pages/deconstruct.js
Normal file
250
static/js/pages/deconstruct.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/* deconstruct page: 拆书 UI - 顺序生成 5 个 section, 用户逐个确认后创建小说. */
|
||||
|
||||
import { escapeHtml, getCsrf, postJson, showError } from "../utils.js";
|
||||
import { createSSEStream } from "../components/sse-client.js";
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "concept", label: "主题构思" },
|
||||
{ id: "world", label: "世界观" },
|
||||
{ id: "characters", label: "角色" },
|
||||
{ id: "outline", label: "大纲" },
|
||||
{ id: "foreshadowing", label: "伏笔计划" },
|
||||
];
|
||||
|
||||
// state: pending -> streaming -> confirm -> confirmed | skipped
|
||||
const state = {};
|
||||
SECTIONS.forEach((s) => { state[s.id] = { status: "pending", text: "" }; });
|
||||
|
||||
let activeStream = null;
|
||||
let autoRunning = false;
|
||||
|
||||
const alertsEl = document.getElementById("dcAlerts");
|
||||
const sectionsEl = document.getElementById("dcSections");
|
||||
const startBtn = document.getElementById("dcStartBtn");
|
||||
const createBtn = document.getElementById("dcCreateBtn");
|
||||
|
||||
/* ---------------- UI 构建 ---------------- */
|
||||
|
||||
const BADGE = {
|
||||
pending: '<span class="badge text-bg-secondary">待生成</span>',
|
||||
streaming: '<span class="badge text-bg-warning">生成中</span>',
|
||||
confirm: '<span class="badge text-bg-info">待确认</span>',
|
||||
confirmed: '<span class="badge text-bg-success">已确认</span>',
|
||||
skipped: '<span class="badge text-bg-dark">已跳过</span>',
|
||||
};
|
||||
|
||||
function buildPanels() {
|
||||
sectionsEl.innerHTML = SECTIONS.map((s, i) => ''
|
||||
+ '<div class="accordion-item" data-section="' + s.id + '">'
|
||||
+ ' <h2 class="accordion-header">'
|
||||
+ ' <button class="accordion-button' + (i === 0 ? "" : " collapsed") + '" type="button"'
|
||||
+ ' data-bs-toggle="collapse" data-bs-target="#dcPanel-' + s.id + '">'
|
||||
+ ' <span class="me-2">' + (i + 1) + '. ' + s.label + '</span>'
|
||||
+ ' <span class="dc-badge">' + BADGE.pending + '</span>'
|
||||
+ ' </button>'
|
||||
+ ' </h2>'
|
||||
+ ' <div id="dcPanel-' + s.id + '" class="accordion-collapse collapse' + (i === 0 ? " show" : "") + '">'
|
||||
+ ' <div class="accordion-body">'
|
||||
+ ' <textarea class="form-control dc-text" rows="8" placeholder="等待生成…"></textarea>'
|
||||
+ ' <div class="mt-2 d-flex gap-2">'
|
||||
+ ' <button type="button" class="btn btn-outline-success btn-sm dc-gen">生成</button>'
|
||||
+ ' <button type="button" class="btn btn-outline-secondary btn-sm dc-skip">跳过 (用默认值)</button>'
|
||||
+ ' <button type="button" class="btn btn-outline-primary btn-sm dc-confirm" disabled>确认</button>'
|
||||
+ ' </div>'
|
||||
+ ' </div>'
|
||||
+ ' </div>'
|
||||
+ '</div>').join("");
|
||||
|
||||
sectionsEl.querySelectorAll(".accordion-item").forEach((item) => {
|
||||
const id = item.dataset.section;
|
||||
item.querySelector(".dc-gen").addEventListener("click", () => streamSection(id));
|
||||
item.querySelector(".dc-skip").addEventListener("click", () => skipSection(id));
|
||||
item.querySelector(".dc-confirm").addEventListener("click", () => confirmSection(id));
|
||||
item.querySelector(".dc-text").addEventListener("input", (e) => {
|
||||
state[id].text = e.target.value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(id, status) {
|
||||
state[id].status = status;
|
||||
const item = sectionsEl.querySelector('[data-section="' + id + '"]');
|
||||
item.querySelector(".dc-badge").innerHTML = BADGE[status];
|
||||
const genBtn = item.querySelector(".dc-gen");
|
||||
const confirmBtn = item.querySelector(".dc-confirm");
|
||||
genBtn.disabled = status === "streaming";
|
||||
genBtn.textContent = (status === "pending" || status === "streaming") ? "生成" : "重新生成";
|
||||
confirmBtn.disabled = status !== "confirm";
|
||||
updateCreateBtn();
|
||||
}
|
||||
|
||||
function openPanel(id) {
|
||||
const panel = document.getElementById("dcPanel-" + id);
|
||||
if (panel && !panel.classList.contains("show")) {
|
||||
new bootstrap.Collapse(panel, { toggle: true });
|
||||
}
|
||||
}
|
||||
|
||||
function updateCreateBtn() {
|
||||
createBtn.disabled = !SECTIONS.every(
|
||||
(s) => state[s.id].status === "confirmed" || state[s.id].status === "skipped"
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 拆书流程 ---------------- */
|
||||
|
||||
function inputs(section) {
|
||||
const confirmed = {};
|
||||
SECTIONS.forEach((s) => {
|
||||
if (state[s.id].status === "confirmed" && s.id !== section) confirmed[s.id] = state[s.id].text;
|
||||
});
|
||||
return {
|
||||
title: document.getElementById("dcTitle").value.trim(),
|
||||
genre: document.getElementById("dcGenre").value,
|
||||
style: document.getElementById("dcStyle").value.trim(),
|
||||
target_words: document.getElementById("dcWords").value,
|
||||
reference_novel: document.getElementById("dcRef").value.trim(),
|
||||
section,
|
||||
confirmed: JSON.stringify(confirmed),
|
||||
csrf_token: getCsrf(),
|
||||
};
|
||||
}
|
||||
|
||||
function streamSection(id) {
|
||||
const inp = inputs(id);
|
||||
if (!inp.title) {
|
||||
showError(alertsEl, "请先填写书名");
|
||||
return;
|
||||
}
|
||||
if (activeStream) activeStream.close();
|
||||
|
||||
const item = sectionsEl.querySelector('[data-section="' + id + '"]');
|
||||
const textarea = item.querySelector(".dc-text");
|
||||
textarea.value = "";
|
||||
state[id].text = "";
|
||||
setStatus(id, "streaming");
|
||||
openPanel(id);
|
||||
|
||||
const url = "/api/deconstruct/stream?" + new URLSearchParams(inp).toString();
|
||||
activeStream = createSSEStream(url, {
|
||||
onToken: (t) => {
|
||||
textarea.value += t;
|
||||
state[id].text = textarea.value;
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
},
|
||||
onSectionEnd: (section, content) => {
|
||||
textarea.value = content;
|
||||
state[section].text = content;
|
||||
setStatus(section, "confirm");
|
||||
activeStream = null;
|
||||
if (autoRunning) nextSection(section);
|
||||
},
|
||||
onError: (msg) => {
|
||||
showError(alertsEl, "生成失败: " + msg);
|
||||
setStatus(id, "pending");
|
||||
activeStream = null;
|
||||
autoRunning = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function nextSection(doneId) {
|
||||
const idx = SECTIONS.findIndex((s) => s.id === doneId);
|
||||
const next = SECTIONS.slice(idx + 1).find((s) => state[s.id].status === "pending");
|
||||
if (next) streamSection(next.id);
|
||||
else autoRunning = false;
|
||||
}
|
||||
|
||||
function confirmSection(id) {
|
||||
const item = sectionsEl.querySelector('[data-section="' + id + '"]');
|
||||
state[id].text = item.querySelector(".dc-text").value;
|
||||
setStatus(id, "confirmed");
|
||||
}
|
||||
|
||||
function skipSection(id) {
|
||||
if (activeStream) { activeStream.close(); activeStream = null; }
|
||||
if (!state[id].text) state[id].text = "(用户跳过, 由 AI 在写作时自由发挥)";
|
||||
setStatus(id, "skipped");
|
||||
}
|
||||
|
||||
/* ---------------- 解析 + 创建小说 ---------------- */
|
||||
|
||||
function parseCharacters(text) {
|
||||
const out = [];
|
||||
text.split(/【角色】/).slice(1).forEach((chunk) => {
|
||||
const lines = chunk.trim().split("\n").filter((l) => l.trim());
|
||||
if (!lines.length) return;
|
||||
const first = lines[0].trim();
|
||||
const name = first.split(/[\s,,::/]/)[0].trim() || "未命名";
|
||||
out.push({ name, role: "角色", description: chunk.trim() });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseOutline(text) {
|
||||
const chapters = [];
|
||||
let volume = 1;
|
||||
let seq = 0;
|
||||
text.split("\n").forEach((line) => {
|
||||
const t = line.trim();
|
||||
if (!t) return;
|
||||
const vm = t.match(/第\s*(\d+)\s*卷/);
|
||||
if (vm) { volume = parseInt(vm[1], 10); return; }
|
||||
const cm = t.match(/第\s*(\d+)\s*章[\s:::]*(.*)/);
|
||||
if (cm) {
|
||||
chapters.push({
|
||||
volume, chapter_number: parseInt(cm[1], 10),
|
||||
title: (cm[2] || "").split(/[—\-–]/)[0].trim(), outline: t,
|
||||
});
|
||||
seq = cm[1] ? parseInt(cm[1], 10) : seq;
|
||||
return;
|
||||
}
|
||||
seq += 1;
|
||||
chapters.push({ volume: 1, chapter_number: seq, title: "", outline: t });
|
||||
});
|
||||
return chapters;
|
||||
}
|
||||
|
||||
function parseForeshadowing(text) {
|
||||
return text.split(/【伏笔】/).slice(1).map((chunk) => ({
|
||||
description: chunk.trim(),
|
||||
})).filter((f) => f.description);
|
||||
}
|
||||
|
||||
async function createNovel() {
|
||||
const inp = inputs("concept");
|
||||
const characters = parseCharacters(state.characters.text);
|
||||
const outlineChapters = parseOutline(state.outline.text);
|
||||
const foreshadowing = parseForeshadowing(state.foreshadowing.text);
|
||||
if (state.characters.status === "confirmed" && !characters.length) {
|
||||
alert("角色文本未能解析出【角色】条目, 将只创建小说本体。");
|
||||
}
|
||||
createBtn.disabled = true;
|
||||
try {
|
||||
const resp = await postJson("/api/novels", {
|
||||
title: inp.title,
|
||||
genre: inp.genre,
|
||||
style: inp.style,
|
||||
target_words: parseInt(inp.target_words, 10),
|
||||
outline_chapters: outlineChapters,
|
||||
characters,
|
||||
foreshadowing,
|
||||
});
|
||||
window.location.href = "/novel/" + resp.id;
|
||||
} catch (err) {
|
||||
showError(alertsEl, "创建失败: " + err.message);
|
||||
createBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 入口 ---------------- */
|
||||
|
||||
buildPanels();
|
||||
|
||||
startBtn.addEventListener("click", () => {
|
||||
autoRunning = true;
|
||||
const first = SECTIONS.find((s) => state[s.id].status === "pending") || SECTIONS[0];
|
||||
streamSection(first.id);
|
||||
});
|
||||
|
||||
createBtn.addEventListener("click", createNovel);
|
||||
44
static/js/pages/index.js
Normal file
44
static/js/pages/index.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* index page: 小说列表 dashboard. */
|
||||
|
||||
import { escapeHtml, fetchJson } from "../utils.js";
|
||||
import { statusBadge } from "../components/status-badge.js";
|
||||
|
||||
function renderCard(n) {
|
||||
const target = n.target_words || 0;
|
||||
const current = n.current_words || 0;
|
||||
const pct = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
|
||||
return ''
|
||||
+ '<div class="col-md-4">'
|
||||
+ ' <div class="card h-100">'
|
||||
+ ' <div class="card-body">'
|
||||
+ ' <h5 class="card-title">' + escapeHtml(n.title) + '</h5>'
|
||||
+ ' <p class="card-text mb-2">'
|
||||
+ ' <span class="badge text-bg-primary">' + escapeHtml(n.genre || "未分类") + '</span> '
|
||||
+ statusBadge(n.status)
|
||||
+ ' </p>'
|
||||
+ ' <p class="card-text text-muted small">' + escapeHtml(n.style || "") + '</p>'
|
||||
+ ' <div class="progress mb-2" role="progressbar" aria-label="写作进度">'
|
||||
+ ' <div class="progress-bar" style="width:' + pct + '%">' + pct + '%</div>'
|
||||
+ ' </div>'
|
||||
+ ' <p class="card-text small text-muted">' + current + ' / ' + target + ' 字</p>'
|
||||
+ ' <a class="btn btn-outline-primary btn-sm" href="/novel/' + n.id + '">打开</a>'
|
||||
+ ' </div>'
|
||||
+ ' </div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
async function loadNovels() {
|
||||
const container = document.getElementById("novelCards");
|
||||
try {
|
||||
const novels = await fetchJson("/api/novels");
|
||||
if (!novels.length) {
|
||||
container.innerHTML = '<div class="text-muted">还没有小说, 点击右上角"拆书创建新小说"开始。</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = novels.map(renderCard).join("");
|
||||
} catch (err) {
|
||||
container.innerHTML = '<div class="alert alert-danger">加载失败: ' + escapeHtml(err.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
loadNovels();
|
||||
180
static/js/pages/novel-detail.js
Normal file
180
static/js/pages/novel-detail.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/* novel detail page: 章节 / 角色 / 伏笔 三 tab 管理. */
|
||||
|
||||
import { escapeHtml, fetchJson, postJson, showError } from "../utils.js";
|
||||
import { statusBadge } from "../components/status-badge.js";
|
||||
|
||||
const NOVEL_ID = window.NOVEL_ID;
|
||||
const alertsEl = document.getElementById("ndAlerts");
|
||||
|
||||
/* ---------------- header ---------------- */
|
||||
|
||||
async function loadHeader() {
|
||||
const el = document.getElementById("ndHeader");
|
||||
try {
|
||||
const n = await fetchJson("/api/novels/" + NOVEL_ID);
|
||||
const s = n.stats || {};
|
||||
const pct = n.target_words > 0
|
||||
? Math.min(100, Math.round(((n.current_words || 0) / n.target_words) * 100)) : 0;
|
||||
el.innerHTML = ''
|
||||
+ '<div class="card-body">'
|
||||
+ ' <h2 class="card-title">' + escapeHtml(n.title) + '</h2>'
|
||||
+ ' <p class="card-text">'
|
||||
+ ' <span class="badge text-bg-primary">' + escapeHtml(n.genre || "未分类") + '</span> '
|
||||
+ statusBadge(n.status)
|
||||
+ ' <span class="text-muted ms-2">' + escapeHtml(n.style || "") + '</span>'
|
||||
+ ' </p>'
|
||||
+ ' <div class="progress mb-2"><div class="progress-bar" style="width:' + pct + '%">' + pct + '%</div></div>'
|
||||
+ ' <p class="card-text small text-muted">'
|
||||
+ ' 章节 ' + (s.chapter_count || 0) + ' (完成 ' + (s.done_chapters || 0) + ') · '
|
||||
+ ' 总字数 ' + (s.total_words || 0) + ' · '
|
||||
+ ' 角色 ' + (s.character_count || 0) + ' · '
|
||||
+ ' 未回收伏笔 ' + (s.open_foreshadowing || 0)
|
||||
+ ' </p>'
|
||||
+ '</div>';
|
||||
} catch (err) {
|
||||
el.innerHTML = '<div class="card-body"><div class="alert alert-danger mb-0">'
|
||||
+ escapeHtml(err.message) + "</div></div>";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- chapters ---------------- */
|
||||
|
||||
async function loadChapters() {
|
||||
const el = document.getElementById("ndChapterList");
|
||||
try {
|
||||
const chapters = await fetchJson("/api/novels/" + NOVEL_ID + "/chapters");
|
||||
if (!chapters.length) {
|
||||
el.innerHTML = '<span class="text-muted">还没有章节, 用上方表单添加。</span>';
|
||||
return;
|
||||
}
|
||||
const byVolume = {};
|
||||
chapters.forEach((c) => {
|
||||
const v = c.volume || 1;
|
||||
(byVolume[v] = byVolume[v] || []).push(c);
|
||||
});
|
||||
el.innerHTML = Object.keys(byVolume).sort((a, b) => a - b).map((v) => {
|
||||
const rows = byVolume[v].map((c) => ''
|
||||
+ '<tr>'
|
||||
+ '<td>第' + c.chapter_number + '章</td>'
|
||||
+ '<td>' + escapeHtml(c.title || "") + '</td>'
|
||||
+ '<td>' + statusBadge(c.status) + '</td>'
|
||||
+ '<td>' + (c.word_count || 0) + '</td>'
|
||||
+ '<td><a class="btn btn-outline-primary btn-sm" href="/novel/' + NOVEL_ID
|
||||
+ '/chapter/' + c.id + '">' + (c.content ? "续写/查看" : "写") + "</a></td>"
|
||||
+ "</tr>").join("");
|
||||
return ''
|
||||
+ '<h5 class="mt-3">第' + v + '卷</h5>'
|
||||
+ '<table class="table table-sm table-hover">'
|
||||
+ '<thead><tr><th>章节</th><th>标题</th><th>状态</th><th>字数</th><th></th></tr></thead>'
|
||||
+ "<tbody>" + rows + "</tbody></table>";
|
||||
}).join("");
|
||||
} catch (err) {
|
||||
el.innerHTML = '<div class="alert alert-danger">' + escapeHtml(err.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("ndAddChapterForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await postJson("/api/novels/" + NOVEL_ID + "/chapters", {
|
||||
volume: parseInt(document.getElementById("ndChVolume").value, 10),
|
||||
chapter_number: parseInt(document.getElementById("ndChNumber").value, 10),
|
||||
title: document.getElementById("ndChTitle").value.trim(),
|
||||
outline: document.getElementById("ndChOutline").value.trim(),
|
||||
});
|
||||
document.getElementById("ndChTitle").value = "";
|
||||
document.getElementById("ndChOutline").value = "";
|
||||
loadChapters();
|
||||
} catch (err) {
|
||||
showError(alertsEl, "添加章节失败: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------- characters ---------------- */
|
||||
|
||||
async function loadCharacters() {
|
||||
const el = document.getElementById("ndCharacterList");
|
||||
try {
|
||||
const chars = await fetchJson("/api/novels/" + NOVEL_ID + "/characters");
|
||||
if (!chars.length) {
|
||||
el.innerHTML = '<span class="text-muted">还没有角色。</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<table class="table table-sm"><thead><tr>'
|
||||
+ "<th>名字</th><th>定位</th><th>状态</th><th>描述</th><th></th>"
|
||||
+ "</tr></thead><tbody>"
|
||||
+ chars.map((c) => ''
|
||||
+ "<tr><td>" + escapeHtml(c.name) + "</td>"
|
||||
+ "<td>" + escapeHtml(c.role || "") + "</td>"
|
||||
+ "<td>" + statusBadge(c.status) + "</td>"
|
||||
+ '<td class="small">' + escapeHtml((c.description || "").slice(0, 120)) + "</td>"
|
||||
+ '<td><button type="button" class="btn btn-outline-secondary btn-sm nd-edit-char" data-id="'
|
||||
+ c.id + '" data-name="' + escapeHtml(c.name) + '">编辑</button></td></tr>'
|
||||
).join("") + "</tbody></table>";
|
||||
|
||||
el.querySelectorAll(".nd-edit-char").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const desc = prompt("编辑角色描述 (" + btn.dataset.name + "):");
|
||||
if (desc === null) return;
|
||||
try {
|
||||
await postJson("/api/characters/" + btn.dataset.id, { description: desc });
|
||||
loadCharacters();
|
||||
} catch (err) {
|
||||
showError(alertsEl, "编辑失败: " + err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
el.innerHTML = '<div class="alert alert-danger">' + escapeHtml(err.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- foreshadowing ---------------- */
|
||||
|
||||
async function loadForeshadowing() {
|
||||
const el = document.getElementById("ndForeshadowingList");
|
||||
try {
|
||||
const list = await fetchJson("/api/novels/" + NOVEL_ID + "/foreshadowing");
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<span class="text-muted">还没有伏笔。</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<table class="table table-sm"><thead><tr>'
|
||||
+ "<th>描述</th><th>埋设</th><th>回收</th><th>状态</th><th></th>"
|
||||
+ "</tr></thead><tbody>"
|
||||
+ list.map((f) => ''
|
||||
+ '<tr><td class="small">' + escapeHtml((f.description || "").slice(0, 150)) + "</td>"
|
||||
+ "<td>" + (f.planted_chapter ? "第" + f.planted_chapter + "章" : "-") + "</td>"
|
||||
+ "<td>" + (f.resolved_chapter ? "第" + f.resolved_chapter + "章" : "-") + "</td>"
|
||||
+ "<td>" + statusBadge(f.status) + "</td>"
|
||||
+ "<td>" + (f.status !== "resolved"
|
||||
? '<button type="button" class="btn btn-outline-success btn-sm nd-resolve-fs" data-id="'
|
||||
+ f.id + '">标记回收</button>' : "")
|
||||
+ "</td></tr>"
|
||||
).join("") + "</tbody></table>";
|
||||
|
||||
el.querySelectorAll(".nd-resolve-fs").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const ch = prompt("回收于第几章?");
|
||||
if (ch === null) return;
|
||||
try {
|
||||
await postJson("/api/foreshadowing/" + btn.dataset.id, {
|
||||
status: "resolved", resolved_chapter: parseInt(ch, 10) || null,
|
||||
});
|
||||
loadForeshadowing();
|
||||
} catch (err) {
|
||||
showError(alertsEl, "操作失败: " + err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
el.innerHTML = '<div class="alert alert-danger">' + escapeHtml(err.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- init ---------------- */
|
||||
|
||||
loadHeader();
|
||||
loadChapters();
|
||||
loadCharacters();
|
||||
loadForeshadowing();
|
||||
77
static/js/utils.js
Normal file
77
static/js/utils.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/* Common helpers shared across pages (ES module). */
|
||||
|
||||
/** Debounce a function call. */
|
||||
export function debounce(fn, delay) {
|
||||
let timer = null;
|
||||
return function (...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/** Format an ISO date string to YYYY-MM-DD. */
|
||||
export function formatDate(isoString) {
|
||||
if (!isoString) return "";
|
||||
const d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return String(isoString).slice(0, 10);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Truncate text to maxLen chars, appending ellipsis. */
|
||||
export function truncate(text, maxLen) {
|
||||
if (!text) return "";
|
||||
const s = String(text);
|
||||
return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
|
||||
}
|
||||
|
||||
/** Escape HTML special chars to prevent XSS when injecting via innerHTML. */
|
||||
export function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return "";
|
||||
return String(text)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Read the session CSRF token rendered into <meta name="csrf-token">. */
|
||||
export function getCsrf() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.content : "";
|
||||
}
|
||||
|
||||
/** Fetch JSON with basic error handling. */
|
||||
export async function fetchJson(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`请求失败: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** POST JSON, injecting csrf_token into the body. Returns parsed JSON. */
|
||||
export async function postJson(url, data) {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...data, csrf_token: getCsrf() }),
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new Error(body.error || `请求失败: ${resp.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Render a dismissible error alert into a container element. */
|
||||
export function showError(container, msg) {
|
||||
if (!container) return;
|
||||
container.innerHTML =
|
||||
'<div class="alert alert-danger alert-dismissible fade show" role="alert">'
|
||||
+ escapeHtml(msg)
|
||||
+ '<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button></div>';
|
||||
}
|
||||
48
templates/base.html
Normal file
48
templates/base.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}Novel Writer{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
|
||||
crossorigin="anonymous">
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">Novel Writer</a>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="/">首页</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/deconstruct">拆书</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container py-4 flex-grow-1">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category | default('info') }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-dark text-light text-center py-3 mt-auto">
|
||||
<div class="container">Novel Writer - 拆书 / 写书 / 人机协作</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
|
||||
crossorigin="anonymous"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
57
templates/chapter_write.html
Normal file
57
templates/chapter_write.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}写章节 - Novel Writer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="cwAlerts"></div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0" id="cwTitle">写章节</h1>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/novel/{{ novel_id }}">← 返回小说</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">章节大纲</div>
|
||||
<div class="card-body">
|
||||
<textarea class="form-control" id="cwOutline" rows="6" placeholder="本章大纲…"></textarea>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm mt-2" id="cwSaveOutline">保存大纲</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header fw-bold">上下文面板</div>
|
||||
<div class="card-body" id="cwContext" style="max-height:50vh;overflow-y:auto;">
|
||||
<span class="text-muted">加载中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-success btn-sm" id="cwGenerate">生成</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" id="cwPause" disabled>暂停</button>
|
||||
<button type="button" class="btn btn-info btn-sm" id="cwResume" disabled>继续</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" id="cwRegen" disabled>重新生成</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="cwEdit" disabled>编辑</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="cwSave" disabled>保存修改</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="cwApprove" disabled>通过本章</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="cwContextSummary"></div>
|
||||
<div id="cwOutput" class="border rounded p-3"
|
||||
style="min-height:400px;max-height:65vh;overflow-y:auto;white-space:pre-wrap;word-break:break-word;">
|
||||
<span class="text-muted">点击"生成"开始写作…</span></div>
|
||||
<div class="text-muted small mt-2" id="cwWordCount"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NOVEL_ID = {{ novel_id }};
|
||||
window.CHAPTER_ID = {{ chapter_id }};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ url_for('static', filename='js/pages/chapter-write.js') }}"></script>
|
||||
{% endblock %}
|
||||
55
templates/deconstruct.html
Normal file
55
templates/deconstruct.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}拆书 - Novel Writer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">拆书: 从零构建小说框架</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header fw-bold">基本信息</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="dcTitle">书名 *</label>
|
||||
<input type="text" class="form-control" id="dcTitle" placeholder="例: 剑啸江湖">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="dcGenre">类型</label>
|
||||
<select class="form-select" id="dcGenre">
|
||||
<option>玄幻</option><option>武侠</option><option>言情</option>
|
||||
<option>科幻</option><option>现实</option><option>悬疑</option><option>其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="dcStyle">文风</label>
|
||||
<input type="text" class="form-control" id="dcStyle" placeholder="例: 金庸+古龙混合">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="dcWords">目标字数</label>
|
||||
<select class="form-select" id="dcWords">
|
||||
<option value="300000">30 万字</option>
|
||||
<option value="1000000">100 万字</option>
|
||||
<option value="3000000">300 万字</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="dcRef">参考小说 (可选)</label>
|
||||
<input type="text" class="form-control" id="dcRef" placeholder="例: 雪中悍刀行">
|
||||
</div>
|
||||
<div class="col-md-6 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-success" id="dcStartBtn">开始拆书</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dcAlerts"></div>
|
||||
<div class="accordion" id="dcSections"></div>
|
||||
|
||||
<div class="mt-4 text-end">
|
||||
<button type="button" class="btn btn-primary btn-lg" id="dcCreateBtn" disabled>创建小说</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ url_for('static', filename='js/pages/deconstruct.js') }}"></script>
|
||||
{% endblock %}
|
||||
16
templates/index.html
Normal file
16
templates/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}首页 - Novel Writer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="mb-0">我的小说</h1>
|
||||
<a class="btn btn-primary" href="/deconstruct">+ 拆书创建新小说</a>
|
||||
</div>
|
||||
<div class="row g-3" id="novelCards">
|
||||
<div class="text-muted">加载中…</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ url_for('static', filename='js/pages/index.js') }}"></script>
|
||||
{% endblock %}
|
||||
46
templates/novel_detail.html
Normal file
46
templates/novel_detail.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}小说详情 - Novel Writer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="ndAlerts"></div>
|
||||
<div class="card mb-4" id="ndHeader">
|
||||
<div class="card-body text-muted">加载中…</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs" id="ndTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#ndPaneChapters" type="button" role="tab">章节</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#ndPaneCharacters" type="button" role="tab">角色</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#ndPaneForeshadowing" type="button" role="tab">伏笔</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content border border-top-0 rounded-bottom p-3">
|
||||
<div class="tab-pane fade show active" id="ndPaneChapters" role="tabpanel">
|
||||
<form class="row g-2 mb-3" id="ndAddChapterForm">
|
||||
<div class="col-auto"><input type="number" class="form-control" id="ndChVolume" value="1" min="1" title="卷" style="width:80px"></div>
|
||||
<div class="col-auto"><input type="number" class="form-control" id="ndChNumber" value="1" min="1" title="章节号" style="width:90px"></div>
|
||||
<div class="col-auto"><input type="text" class="form-control" id="ndChTitle" placeholder="章节标题"></div>
|
||||
<div class="col-auto"><input type="text" class="form-control" id="ndChOutline" placeholder="一句话大纲 (可选)" style="width:280px"></div>
|
||||
<div class="col-auto"><button type="submit" class="btn btn-outline-primary">添加章节</button></div>
|
||||
</form>
|
||||
<div id="ndChapterList" class="text-muted">加载中…</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="ndPaneCharacters" role="tabpanel">
|
||||
<div id="ndCharacterList" class="text-muted">加载中…</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="ndPaneForeshadowing" role="tabpanel">
|
||||
<div id="ndForeshadowingList" class="text-muted">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>window.NOVEL_ID = {{ novel_id }};</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ url_for('static', filename='js/pages/novel-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user