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