initial: novel-app snapshot

This commit is contained in:
omo
2026-08-17 17:11:30 +08:00
commit 1ae06209ac
34 changed files with 2821 additions and 0 deletions

18
.gitignore vendored Normal file
View 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/

View 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
View 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
View 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
View 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
View 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.
"""

View 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),
)
)

View 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]

View 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
View 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),
)
)

View 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
View File

@@ -0,0 +1,2 @@
flask>=3.0
requests>=2.28

6
routes/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)})

View 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 };
}

View 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>`;
}

View 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();

View 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
View 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();

View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** 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
View 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>

View 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 %}

View 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
View 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 %}

View 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 %}