initial: novel-app snapshot
This commit is contained in:
6
routes/__init__.py
Normal file
6
routes/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Route blueprints."""
|
||||
|
||||
from routes.api import bp as api_bp
|
||||
from routes.pages import bp as pages_bp
|
||||
|
||||
__all__ = ["api_bp", "pages_bp"]
|
||||
375
routes/api.py
Normal file
375
routes/api.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""JSON API + SSE streaming blueprint: /api/* endpoints.
|
||||
|
||||
SSE 规则 (沿用 blog-app 教训): 所有 DB 数据与上下文必须在 view 体内
|
||||
(application context 仍存活) 准备好; generator 在 view return 后才被消费,
|
||||
闭包内不得再依赖 Flask g / request, job 落库用独立 sqlite3 连接.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
import config
|
||||
import security
|
||||
from extensions import get_db
|
||||
from repositories import (
|
||||
chapter_repo,
|
||||
character_repo,
|
||||
foreshadowing_repo,
|
||||
job_repo,
|
||||
novel_repo,
|
||||
)
|
||||
from services import chapter_writer, deconstruct, llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
_SSE_HEADERS = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
def _payload():
|
||||
"""JSON body first, fall back to form fields."""
|
||||
if request.is_json:
|
||||
return request.get_json(silent=True) or {}
|
||||
return request.form.to_dict()
|
||||
|
||||
|
||||
def _job_conn():
|
||||
"""Fresh connection for generator-side job updates (outlives request)."""
|
||||
conn = sqlite3.connect(config.DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- novels
|
||||
|
||||
@bp.route("/novels")
|
||||
def api_novels():
|
||||
try:
|
||||
return jsonify(novel_repo.list_novels(get_db()))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novels failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels", methods=["POST"])
|
||||
def api_novel_create():
|
||||
"""创建小说 (拆书确认后调用): 基础字段 + 可选 chapters/characters/foreshadowing."""
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title required"}), 400
|
||||
try:
|
||||
db = get_db()
|
||||
novel_id = novel_repo.create_novel(
|
||||
db,
|
||||
title,
|
||||
genre=data.get("genre"),
|
||||
style=data.get("style"),
|
||||
target_words=data.get("target_words"),
|
||||
status="outlining",
|
||||
)
|
||||
for ch in data.get("outline_chapters") or []:
|
||||
chapter_repo.create_chapter(
|
||||
db, novel_id,
|
||||
ch.get("volume") or 1,
|
||||
ch.get("chapter_number") or 1,
|
||||
title=ch.get("title"),
|
||||
outline=ch.get("outline"),
|
||||
status="outlined" if ch.get("outline") else "pending",
|
||||
)
|
||||
for c in data.get("characters") or []:
|
||||
character_repo.create_character(
|
||||
db, novel_id, c.get("name") or "未命名",
|
||||
role=c.get("role"), description=c.get("description"),
|
||||
)
|
||||
for f in data.get("foreshadowing") or []:
|
||||
foreshadowing_repo.create_foreshadowing(
|
||||
db, novel_id, f.get("description") or "",
|
||||
planted_chapter=f.get("planted_chapter"),
|
||||
resolved_chapter=f.get("resolved_chapter"),
|
||||
)
|
||||
return jsonify({"id": novel_id}), 201
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novel_create failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>")
|
||||
def api_novel_detail(novel_id):
|
||||
try:
|
||||
db = get_db()
|
||||
novel = novel_repo.get_novel(db, novel_id)
|
||||
if novel is None:
|
||||
return jsonify({"error": "novel not found"}), 404
|
||||
novel["stats"] = novel_repo.get_stats(db, novel_id)
|
||||
return jsonify(novel)
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novel_detail failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/status", methods=["POST"])
|
||||
def api_novel_status(novel_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
novel_repo.update_novel(get_db(), novel_id, status=data.get("status"))
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_novel_status failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- chapters
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/chapters")
|
||||
def api_chapters(novel_id):
|
||||
try:
|
||||
return jsonify(chapter_repo.list_chapters(get_db(), novel_id))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapters failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/chapters", methods=["POST"])
|
||||
def api_chapter_create(novel_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
chapter_id = chapter_repo.create_chapter(
|
||||
get_db(), novel_id,
|
||||
data.get("volume") or 1,
|
||||
data.get("chapter_number") or 1,
|
||||
title=data.get("title"),
|
||||
outline=data.get("outline"),
|
||||
status="outlined" if data.get("outline") else "pending",
|
||||
)
|
||||
return jsonify({"id": chapter_id}), 201
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_create failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>")
|
||||
def api_chapter_detail(chapter_id):
|
||||
try:
|
||||
chapter = chapter_repo.get_chapter(get_db(), chapter_id)
|
||||
if chapter is None:
|
||||
return jsonify({"error": "chapter not found"}), 404
|
||||
return jsonify(chapter)
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_detail failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>", methods=["POST"])
|
||||
def api_chapter_update(chapter_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
fields = {k: data[k] for k in ("title", "outline", "content", "status") if k in data}
|
||||
if "content" in fields:
|
||||
fields["word_count"] = len(fields["content"] or "")
|
||||
if not fields:
|
||||
return jsonify({"error": "no updatable fields"}), 400
|
||||
try:
|
||||
chapter_repo.update_chapter(get_db(), chapter_id, **fields)
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_update failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- characters / foreshadowing
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/characters")
|
||||
def api_characters(novel_id):
|
||||
try:
|
||||
return jsonify(character_repo.list_characters(get_db(), novel_id))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_characters failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/characters/<int:character_id>", methods=["POST"])
|
||||
def api_character_update(character_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
character_repo.update_character(get_db(), character_id, **data)
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_character_update failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/novels/<int:novel_id>/foreshadowing")
|
||||
def api_foreshadowing(novel_id):
|
||||
try:
|
||||
return jsonify(foreshadowing_repo.list_foreshadowing(get_db(), novel_id))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_foreshadowing failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/foreshadowing/<int:fs_id>", methods=["POST"])
|
||||
def api_foreshadowing_update(fs_id):
|
||||
security.validate_csrf()
|
||||
data = _payload()
|
||||
try:
|
||||
foreshadowing_repo.update_foreshadowing(get_db(), fs_id, **data)
|
||||
return jsonify({"ok": True})
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_foreshadowing_update failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 拆书 SSE
|
||||
|
||||
@bp.route("/deconstruct/stream")
|
||||
def api_deconstruct_stream():
|
||||
"""SSE: 逐 section 拆书. 事件: section_start / token / section_end / error."""
|
||||
security.validate_csrf()
|
||||
section = request.args.get("section", "").strip()
|
||||
if section not in deconstruct.SECTIONS:
|
||||
return jsonify({"error": f"section must be one of {deconstruct.SECTIONS}"}), 400
|
||||
|
||||
confirmed_raw = request.args.get("confirmed", "").strip()
|
||||
try:
|
||||
confirmed = json.loads(confirmed_raw) if confirmed_raw else {}
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({"error": "confirmed must be valid JSON"}), 400
|
||||
|
||||
inputs = {
|
||||
"title": request.args.get("title", "").strip(),
|
||||
"genre": request.args.get("genre", "").strip(),
|
||||
"style": request.args.get("style", "").strip(),
|
||||
"target_words": request.args.get("target_words", "").strip(),
|
||||
"reference_novel": request.args.get("reference_novel", "").strip(),
|
||||
"confirmed": confirmed,
|
||||
}
|
||||
if not inputs["title"]:
|
||||
return jsonify({"error": "title required"}), 400
|
||||
|
||||
db = get_db()
|
||||
job_id = job_repo.create_job(
|
||||
db, None, "deconstruct", input_json=json.dumps(inputs, ensure_ascii=False)
|
||||
)
|
||||
|
||||
def _wrap():
|
||||
conn = _job_conn()
|
||||
try:
|
||||
for frame in deconstruct.stream_section(section, inputs):
|
||||
yield frame
|
||||
if frame.startswith("event: section_end"):
|
||||
job_repo.update_job(conn, job_id, status="done", output_json=frame)
|
||||
elif frame.startswith("event: error"):
|
||||
job_repo.update_job(conn, job_id, status="failed", error=frame)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.exception("deconstruct stream crashed")
|
||||
job_repo.update_job(conn, job_id, status="failed", error=str(exc))
|
||||
yield llm._sse("error", {"error": str(exc)})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return _wrap(), _SSE_HEADERS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 写书 SSE
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>/context")
|
||||
def api_chapter_context(chapter_id):
|
||||
"""章节上下文预览 (前端上下文面板)."""
|
||||
try:
|
||||
db = get_db()
|
||||
chapter = chapter_repo.get_chapter(db, chapter_id)
|
||||
if chapter is None:
|
||||
return jsonify({"error": "chapter not found"}), 404
|
||||
novel = novel_repo.get_novel(db, chapter["novel_id"])
|
||||
return jsonify(chapter_writer.build_chapter_context(db, novel, chapter))
|
||||
except sqlite3.Error as exc:
|
||||
logger.exception("api_chapter_context failed")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.route("/chapters/<int:chapter_id>/generate")
|
||||
def api_chapter_generate(chapter_id):
|
||||
"""SSE: 流式写章节. 事件: context / token / done / error. ?prefix= 续写断点."""
|
||||
security.validate_csrf()
|
||||
prefix = request.args.get("prefix") or None
|
||||
|
||||
db = get_db()
|
||||
chapter = chapter_repo.get_chapter(db, chapter_id)
|
||||
if chapter is None:
|
||||
return jsonify({"error": "chapter not found"}), 404
|
||||
novel = novel_repo.get_novel(db, chapter["novel_id"])
|
||||
if novel is None:
|
||||
return jsonify({"error": "novel not found"}), 404
|
||||
|
||||
job_id = job_repo.create_job(
|
||||
db, novel["id"], "chapter",
|
||||
input_json=json.dumps(
|
||||
{"chapter_id": chapter_id, "prefix_len": len(prefix or "")},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
# 关键: 在 view 体内就把 generator 物化成帧列表前的数据全部取好.
|
||||
# chapter_writer.stream_chapter 需要 db 仅用于 build_chapter_context,
|
||||
# 所以先在 view 内构建 context, generator 只做纯 LLM 流式.
|
||||
try:
|
||||
context = chapter_writer.build_chapter_context(db, novel, chapter)
|
||||
except Exception as exc:
|
||||
logger.exception("failed to build chapter context")
|
||||
return jsonify({"error": f"failed to build context: {exc}"}), 500
|
||||
|
||||
def _wrap():
|
||||
conn = _job_conn()
|
||||
job_repo.update_job(conn, job_id, status="running")
|
||||
try:
|
||||
summary = (
|
||||
f"已注入上下文: 前 {len(context['prev_chapters'])} 章正文, "
|
||||
f"{len(context['earlier_summaries'])} 条更早摘要, "
|
||||
f"{len(context['characters'])} 个角色, "
|
||||
f"{len(context['open_foreshadowing'])} 条未回收伏笔, "
|
||||
f"共约 {context['total_chars']} 字符"
|
||||
+ (f"; 续写前缀 {len(prefix)} 字符" if prefix else "")
|
||||
)
|
||||
yield llm._sse("context", {"summary": summary})
|
||||
|
||||
messages = chapter_writer.build_chapter_prompts(context, chapter, prefix=prefix)
|
||||
parts = []
|
||||
|
||||
for frame in llm.make_token_stream(messages, parts.append, max_tokens=8192):
|
||||
yield frame
|
||||
if frame.startswith("event: error"):
|
||||
job_repo.update_job(conn, job_id, status="failed", error=frame)
|
||||
return
|
||||
|
||||
full_text = (prefix or "") + "".join(parts)
|
||||
chapter_repo.update_chapter(
|
||||
conn, chapter_id,
|
||||
content=full_text, word_count=len(full_text), status="drafting",
|
||||
)
|
||||
job_repo.update_job(
|
||||
conn, job_id, status="done", output_json=json.dumps(
|
||||
{"word_count": len(full_text)}, ensure_ascii=False
|
||||
),
|
||||
)
|
||||
yield llm._sse("done", {"content": full_text, "word_count": len(full_text)})
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.exception("chapter generate stream crashed")
|
||||
job_repo.update_job(conn, job_id, status="failed", error=str(exc))
|
||||
yield llm._sse("error", {"error": str(exc)})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return _wrap(), _SSE_HEADERS
|
||||
33
routes/pages.py
Normal file
33
routes/pages.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Page blueprint: HTML routes rendering Jinja2 templates."""
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
|
||||
bp = Blueprint("pages", __name__)
|
||||
|
||||
|
||||
@bp.route("/healthz")
|
||||
def page_healthz():
|
||||
"""Liveness probe. 不查 DB — deploy.sh 用它判断进程是否活着."""
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def page_index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@bp.route("/deconstruct")
|
||||
def page_deconstruct():
|
||||
return render_template("deconstruct.html")
|
||||
|
||||
|
||||
@bp.route("/novel/<int:novel_id>")
|
||||
def page_novel_detail(novel_id):
|
||||
return render_template("novel_detail.html", novel_id=novel_id)
|
||||
|
||||
|
||||
@bp.route("/novel/<int:novel_id>/chapter/<int:chapter_id>")
|
||||
def page_chapter_write(novel_id, chapter_id):
|
||||
return render_template(
|
||||
"chapter_write.html", novel_id=novel_id, chapter_id=chapter_id
|
||||
)
|
||||
Reference in New Issue
Block a user