commit 1ae06209ac8c58972be517d8323c6ad9f3fbbeef Author: omo Date: Mon Aug 17 17:11:30 2026 +0800 initial: novel-app snapshot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b9f482 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.omo/run-continuation/ses_ff1186371ffetBg7TKNOufDhae.json b/.omo/run-continuation/ses_ff1186371ffetBg7TKNOufDhae.json new file mode 100644 index 0000000..dc5fded --- /dev/null +++ b/.omo/run-continuation/ses_ff1186371ffetBg7TKNOufDhae.json @@ -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" + } + } +} \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..e72f06e --- /dev/null +++ b/app.py @@ -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) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..b741216 --- /dev/null +++ b/config.py @@ -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") \ No newline at end of file diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..02e5212 --- /dev/null +++ b/extensions.py @@ -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() \ No newline at end of file diff --git a/repositories/__init__.py b/repositories/__init__.py new file mode 100644 index 0000000..3610dcc --- /dev/null +++ b/repositories/__init__.py @@ -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. +""" \ No newline at end of file diff --git a/repositories/chapter_repo.py b/repositories/chapter_repo.py new file mode 100644 index 0000000..1dbdce9 --- /dev/null +++ b/repositories/chapter_repo.py @@ -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), + ) + ) \ No newline at end of file diff --git a/repositories/character_repo.py b/repositories/character_repo.py new file mode 100644 index 0000000..8899faf --- /dev/null +++ b/repositories/character_repo.py @@ -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] \ No newline at end of file diff --git a/repositories/foreshadowing_repo.py b/repositories/foreshadowing_repo.py new file mode 100644 index 0000000..61d9a0d --- /dev/null +++ b/repositories/foreshadowing_repo.py @@ -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,), + ) + ) \ No newline at end of file diff --git a/repositories/job_repo.py b/repositories/job_repo.py new file mode 100644 index 0000000..65fcc9b --- /dev/null +++ b/repositories/job_repo.py @@ -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), + ) + ) \ No newline at end of file diff --git a/repositories/novel_repo.py b/repositories/novel_repo.py new file mode 100644 index 0000000..71cf401 --- /dev/null +++ b/repositories/novel_repo.py @@ -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, + } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..482aa65 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0 +requests>=2.28 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..2e90aa0 --- /dev/null +++ b/routes/__init__.py @@ -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"] diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..a102ab6 --- /dev/null +++ b/routes/api.py @@ -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/") +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//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//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//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/") +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/", 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//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/", 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//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/", 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//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//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 diff --git a/routes/pages.py b/routes/pages.py new file mode 100644 index 0000000..1b4ad7a --- /dev/null +++ b/routes/pages.py @@ -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/") +def page_novel_detail(novel_id): + return render_template("novel_detail.html", novel_id=novel_id) + + +@bp.route("/novel//chapter/") +def page_chapter_write(novel_id, chapter_id): + return render_template( + "chapter_write.html", novel_id=novel_id, chapter_id=chapter_id + ) diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..afad30d --- /dev/null +++ b/scripts/deploy.sh @@ -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" diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..e3e118d --- /dev/null +++ b/scripts/init_db.py @@ -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() \ No newline at end of file diff --git a/security.py b/security.py new file mode 100644 index 0000000..5153da0 --- /dev/null +++ b/security.py @@ -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 \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..01b59cd --- /dev/null +++ b/services/__init__.py @@ -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/. +""" diff --git a/services/chapter_writer.py b/services/chapter_writer.py new file mode 100644 index 0000000..5efebf4 --- /dev/null +++ b/services/chapter_writer.py @@ -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)}) diff --git a/services/deconstruct.py b/services/deconstruct.py new file mode 100644 index 0000000..b6e86e4 --- /dev/null +++ b/services/deconstruct.py @@ -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}) diff --git a/services/llm.py b/services/llm.py new file mode 100644 index 0000000..b19ae63 --- /dev/null +++ b/services/llm.py @@ -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: \\ndata: \\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)}) diff --git a/static/js/components/sse-client.js b/static/js/components/sse-client.js new file mode 100644 index 0000000..4a525cf --- /dev/null +++ b/static/js/components/sse-client.js @@ -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 }; +} diff --git a/static/js/components/status-badge.js b/static/js/components/status-badge.js new file mode 100644 index 0000000..dd63293 --- /dev/null +++ b/static/js/components/status-badge.js @@ -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 `${escapeHtml(label)}`; +} diff --git a/static/js/pages/chapter-write.js b/static/js/pages/chapter-write.js new file mode 100644 index 0000000..a54217b --- /dev/null +++ b/static/js/pages/chapter-write.js @@ -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) + : '点击"生成"开始写作…'; + 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 += "
前文摘要 (" + + ctx.earlier_summaries.length + ")"; + html += ctx.earlier_summaries.length + ? ctx.earlier_summaries.map((s) => + "

第" + s.volume + "卷 第" + s.chapter_number + "章 " + + escapeHtml(s.title || "") + ": " + escapeHtml(s.summary) + "

").join("") + : '

'; + html += "
"; + + html += "
前章正文 (" + + ctx.prev_chapters.length + ")"; + html += ctx.prev_chapters.length + ? ctx.prev_chapters.map((c) => + "
第" + c.volume + "卷 第" + + c.chapter_number + "章 " + escapeHtml(c.title || "") + "" + + "
"
+                + escapeHtml(c.content) + "
").join("") + : '

无 (这是第一章)

'; + html += "
"; + + html += "
相关角色 (" + + ctx.characters.length + ")"; + html += ctx.characters.length + ? ctx.characters.map((c) => + "

" + escapeHtml(c.name) + " (" + + escapeHtml(c.role || "") + "): " + + escapeHtml((c.description || "").slice(0, 200)) + "

").join("") + : '

'; + html += "
"; + + html += "
未回收伏笔 (" + + ctx.open_foreshadowing.length + ")"; + html += ctx.open_foreshadowing.length + ? ctx.open_foreshadowing.map((f) => + "

" + escapeHtml(f.description) + "

").join("") + : '

'; + html += "
"; + + html += '

上下文总大小: ' + ctx.total_chars + " 字符

"; + el.innerHTML = html; + } catch (err) { + el.innerHTML = '
' + escapeHtml(err.message) + "
"; + } +} + +/* ---------------- 生成控制 ---------------- */ + +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 = '
' + + escapeHtml(summary) + "
"; + }, + 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 = '
已保存
'; + } 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 = '
本章已通过 (status=done)
'; + } catch (err) { + showError(alertsEl, "操作失败: " + err.message); + } +}); + +btn.saveOutline.addEventListener("click", async () => { + try { + await postJson("/api/chapters/" + CHAPTER_ID, { outline: outlineEl.value }); + ctxSummaryEl.innerHTML = '
大纲已保存
'; + loadContext(); + } catch (err) { + showError(alertsEl, "保存大纲失败: " + err.message); + } +}); + +/* ---------------- init ---------------- */ + +loadChapter(); +loadContext(); diff --git a/static/js/pages/deconstruct.js b/static/js/pages/deconstruct.js new file mode 100644 index 0000000..cfbcee4 --- /dev/null +++ b/static/js/pages/deconstruct.js @@ -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: '待生成', + streaming: '生成中', + confirm: '待确认', + confirmed: '已确认', + skipped: '已跳过', +}; + +function buildPanels() { + sectionsEl.innerHTML = SECTIONS.map((s, i) => '' + + '
' + + '

' + + ' ' + + '

' + + '
' + + '
' + + ' ' + + '
' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
').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); diff --git a/static/js/pages/index.js b/static/js/pages/index.js new file mode 100644 index 0000000..8e7b6e1 --- /dev/null +++ b/static/js/pages/index.js @@ -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 '' + + '
' + + '
' + + '
' + + '
' + escapeHtml(n.title) + '
' + + '

' + + ' ' + escapeHtml(n.genre || "未分类") + ' ' + + statusBadge(n.status) + + '

' + + '

' + escapeHtml(n.style || "") + '

' + + '
' + + '
' + pct + '%
' + + '
' + + '

' + current + ' / ' + target + ' 字

' + + ' 打开' + + '
' + + '
' + + '
'; +} + +async function loadNovels() { + const container = document.getElementById("novelCards"); + try { + const novels = await fetchJson("/api/novels"); + if (!novels.length) { + container.innerHTML = '
还没有小说, 点击右上角"拆书创建新小说"开始。
'; + return; + } + container.innerHTML = novels.map(renderCard).join(""); + } catch (err) { + container.innerHTML = '
加载失败: ' + escapeHtml(err.message) + "
"; + } +} + +loadNovels(); diff --git a/static/js/pages/novel-detail.js b/static/js/pages/novel-detail.js new file mode 100644 index 0000000..33b7867 --- /dev/null +++ b/static/js/pages/novel-detail.js @@ -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 = '' + + '
' + + '

' + escapeHtml(n.title) + '

' + + '

' + + ' ' + escapeHtml(n.genre || "未分类") + ' ' + + statusBadge(n.status) + + ' ' + escapeHtml(n.style || "") + '' + + '

' + + '
' + pct + '%
' + + '

' + + ' 章节 ' + (s.chapter_count || 0) + ' (完成 ' + (s.done_chapters || 0) + ') · ' + + ' 总字数 ' + (s.total_words || 0) + ' · ' + + ' 角色 ' + (s.character_count || 0) + ' · ' + + ' 未回收伏笔 ' + (s.open_foreshadowing || 0) + + '

' + + '
'; + } catch (err) { + el.innerHTML = '
' + + escapeHtml(err.message) + "
"; + } +} + +/* ---------------- chapters ---------------- */ + +async function loadChapters() { + const el = document.getElementById("ndChapterList"); + try { + const chapters = await fetchJson("/api/novels/" + NOVEL_ID + "/chapters"); + if (!chapters.length) { + el.innerHTML = '还没有章节, 用上方表单添加。'; + 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) => '' + + '' + + '第' + c.chapter_number + '章' + + '' + escapeHtml(c.title || "") + '' + + '' + statusBadge(c.status) + '' + + '' + (c.word_count || 0) + '' + + '' + (c.content ? "续写/查看" : "写") + "" + + "").join(""); + return '' + + '
第' + v + '卷
' + + '' + + '' + + "" + rows + "
章节标题状态字数
"; + }).join(""); + } catch (err) { + el.innerHTML = '
' + escapeHtml(err.message) + "
"; + } +} + +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 = '还没有角色。'; + return; + } + el.innerHTML = '' + + "" + + "" + + chars.map((c) => '' + + "" + + "" + + "" + + '" + + '' + ).join("") + "
名字定位状态描述
" + escapeHtml(c.name) + "" + escapeHtml(c.role || "") + "" + statusBadge(c.status) + "' + escapeHtml((c.description || "").slice(0, 120)) + "
"; + + 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 = '
' + escapeHtml(err.message) + "
"; + } +} + +/* ---------------- foreshadowing ---------------- */ + +async function loadForeshadowing() { + const el = document.getElementById("ndForeshadowingList"); + try { + const list = await fetchJson("/api/novels/" + NOVEL_ID + "/foreshadowing"); + if (!list.length) { + el.innerHTML = '还没有伏笔。'; + return; + } + el.innerHTML = '' + + "" + + "" + + list.map((f) => '' + + '" + + "" + + "" + + "" + + "" + ).join("") + "
描述埋设回收状态
' + escapeHtml((f.description || "").slice(0, 150)) + "" + (f.planted_chapter ? "第" + f.planted_chapter + "章" : "-") + "" + (f.resolved_chapter ? "第" + f.resolved_chapter + "章" : "-") + "" + statusBadge(f.status) + "" + (f.status !== "resolved" + ? '' : "") + + "
"; + + 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 = '
' + escapeHtml(err.message) + "
"; + } +} + +/* ---------------- init ---------------- */ + +loadHeader(); +loadChapters(); +loadCharacters(); +loadForeshadowing(); diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..a41ff1f --- /dev/null +++ b/static/js/utils.js @@ -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, "'"); +} + +/** Read the session CSRF token rendered into . */ +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 = + ''; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5eac444 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,48 @@ + + + + + + + {% block title %}Novel Writer{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+
Novel Writer - 拆书 / 写书 / 人机协作
+
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/chapter_write.html b/templates/chapter_write.html new file mode 100644 index 0000000..db07056 --- /dev/null +++ b/templates/chapter_write.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}写章节 - Novel Writer{% endblock %} + +{% block content %} +
+
+

写章节

+ ← 返回小说 +
+ +
+
+
+
章节大纲
+
+ + +
+
+
+
上下文面板
+
+ 加载中… +
+
+
+
+
+
+ + + + + + + +
+
+
+
+点击"生成"开始写作…
+
+
+
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/deconstruct.html b/templates/deconstruct.html new file mode 100644 index 0000000..5349521 --- /dev/null +++ b/templates/deconstruct.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}拆书 - Novel Writer{% endblock %} + +{% block content %} +

拆书: 从零构建小说框架

+ +
+
基本信息
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+ +
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..daf697f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}首页 - Novel Writer{% endblock %} + +{% block content %} +
+

我的小说

+ + 拆书创建新小说 +
+
+
加载中…
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/novel_detail.html b/templates/novel_detail.html new file mode 100644 index 0000000..4f1a2c4 --- /dev/null +++ b/templates/novel_detail.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}小说详情 - Novel Writer{% endblock %} + +{% block content %} +
+
+
加载中…
+
+ + + +
+
+
+
+
+
+
+
+
+
加载中…
+
+
+
加载中…
+
+
+
加载中…
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %}