From 2eb066dbec3e4d74782c2cba4392e94c3f9bbe59 Mon Sep 17 00:00:00 2001 From: Sully <101929462+Sullivansome@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:12:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E2=80=9C=E7=AB=A0=E8=8A=82=E6=AD=A3?= =?UTF-8?q?=E6=96=87=20+=20=E5=9B=BE=E7=89=87=E2=80=9D=E4=BB=8E=20chapters?= =?UTF-8?q?=20=E5=8D=95=E8=A1=A8/JSON=20=E7=BB=93=E6=9E=84=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BA=E2=80=9C=E7=AB=A0=E8=8A=82=20chapte?= =?UTF-8?q?r=20+=20=E6=AE=B5=E8=90=BD=20section=20+=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=20memoir=5Fimages=20=E7=8B=AC=E7=AB=8B=E8=A1=A8=E2=80=9D?= =?UTF-8?q?=E7=9A=84=E6=96=B0=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E8=81=94=E5=8A=A8=E4=BF=AE=E6=94=B9=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=81PDF=20=E5=AF=BC=E5=87=BA=E3=80=81=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E4=BB=BB=E5=8A=A1=E3=80=81=E8=BF=81=E7=A7=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E3=80=81=E6=B5=8B=E8=AF=95=EF=BC=8C=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Android=20=E7=AB=AF=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=98=E3=80=82?= =?UTF-8?q?=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 表结构重构,新增段落section和图片image新表 * fix: fix android app import error * refactor: 重构文件名 * fix: 优化提示词 * fix: 消息气泡显示位置异常问题 --------- Co-authored-by: yangshilin <2157598560@qq.com> --- api/agents/prompts/memory_prompts.py | 4 +- api/database/models.py | 71 +++- api/migrations/add_chapter_sections.sql | 26 ++ api/migrations/add_memoir_images_table.sql | 32 ++ api/migrations/add_section_image_id_fk.sql | 18 + .../fix_memoir_images_order_index.sql | 9 + api/routers/books.py | 18 +- api/routers/chapters.py | 108 +++-- api/scripts/migrate_chapters_to_sections.py | 98 +++++ api/scripts/reprocess_user_memoir.py | 46 +- api/scripts/run_chapter_sections_migration.py | 157 +++++++ api/scripts/run_memoir_images_migration.py | 196 +++++++++ api/services/memoir_images/parser.py | 30 ++ api/services/memoir_images/serializers.py | 70 +++ api/services/pdf_service.py | 31 +- api/tasks/memoir_tasks.py | 399 ++++++++++++------ .../test_generate_chapter_images_task.py | 252 ++++------- api/tests/test_memoir_image_bootstrap.py | 234 ++-------- .../ui/components/chat/MessageList.kt | 105 ++--- 19 files changed, 1280 insertions(+), 624 deletions(-) create mode 100644 api/migrations/add_chapter_sections.sql create mode 100644 api/migrations/add_memoir_images_table.sql create mode 100644 api/migrations/add_section_image_id_fk.sql create mode 100644 api/migrations/fix_memoir_images_order_index.sql create mode 100644 api/scripts/migrate_chapters_to_sections.py create mode 100644 api/scripts/run_chapter_sections_migration.py create mode 100644 api/scripts/run_memoir_images_migration.py create mode 100644 api/services/memoir_images/serializers.py diff --git a/api/agents/prompts/memory_prompts.py b/api/agents/prompts/memory_prompts.py index 3405d83..3e78d68 100644 --- a/api/agents/prompts/memory_prompts.py +++ b/api/agents/prompts/memory_prompts.py @@ -48,7 +48,9 @@ STAGE_TO_ORDER = { IMAGE_PLACEHOLDER_TEMPLATE = ( "温暖怀旧风格,年代感复古色调,柔和光影,朴素温馨氛围,安静治愈,低饱和度," "质感柔和细腻,简约构图,充满岁月沉淀感与故事感,高清唯美插画封面,不要包含文字," - "要适合老年人阅读风格,要有年代感,有朦胧感。" + "要适合老年人审美,画面要真实可信、让老年人产生共鸣与代入感," + "场景环境、建筑风格、服饰器物必须严格符合所述时代背景和地域特色," + "有朦胧怀旧的年代感。" ) diff --git a/api/database/models.py b/api/database/models.py index 446eb73..436ca3a 100644 --- a/api/database/models.py +++ b/api/database/models.py @@ -81,16 +81,15 @@ class Segment(Base): class Chapter(Base): - """章节表""" + """章节表(正文与插图存于 chapter_sections)""" __tablename__ = "chapters" id = Column(String, primary_key=True) user_id = Column(String, ForeignKey("users.id"), nullable=False) title = Column(String, nullable=False) - content = Column(Text, nullable=False) order_index = Column(Integer, nullable=False) status = Column(String, default="draft") # draft, completed - images = Column(JSON, nullable=True) # 图片元数据对象列表 + cover_image = Column(JSON, nullable=True) # 章节封面图(单条图片元数据) updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) category = Column(String, nullable=True) # 章节分类 is_new = Column(Boolean, default=True) # 是否为新内容(未读) @@ -99,6 +98,72 @@ class Chapter(Base): # Relationships user = relationship("User", back_populates="chapters") + sections = relationship( + "ChapterSection", + back_populates="chapter", + order_by="ChapterSection.order_index", + cascade="all, delete-orphan", + ) + images = relationship( + "MemoirImage", + back_populates="chapter", + foreign_keys="MemoirImage.chapter_id", + cascade="all, delete-orphan", + ) + + +class ChapterSection(Base): + """章节段落表:一章多段,每段一段正文 + 可选一张图""" + __tablename__ = "chapter_sections" + + id = Column(String, primary_key=True) + chapter_id = Column(String, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False) + order_index = Column(Integer, nullable=False) + content = Column(Text, nullable=False) # 本段正文(无占位符) + image_id = Column(String, ForeignKey("memoir_images.id", ondelete="SET NULL"), nullable=True) # 关联 memoir_images.id + updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) + + # Relationships + chapter = relationship("Chapter", back_populates="sections") + image_record = relationship( + "MemoirImage", + back_populates="section", + uselist=False, + foreign_keys="ChapterSection.image_id", + cascade="all, delete-orphan", + single_parent=True, + ) + + +class MemoirImage(Base): + """章节配图与封面:字段独立存储,section_id 为空表示章节封面,非空表示该 section 的配图""" + __tablename__ = "memoir_images" + + id = Column(String, primary_key=True) + chapter_id = Column(String, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False) + section_id = Column(String, ForeignKey("chapter_sections.id", ondelete="CASCADE"), nullable=True) + order_index = Column(Integer, nullable=False, default=0) + placeholder = Column(Text, nullable=True) + description = Column(Text, nullable=True) + status = Column(String, nullable=False, default="pending") + prompt = Column(Text, nullable=True) + url = Column(Text, nullable=True) + storage_key = Column(Text, nullable=True) + provider = Column(String, nullable=True) + style = Column(String, nullable=True) + size = Column(String, nullable=True) + error = Column(Text, nullable=True) + retryable = Column(Boolean, nullable=True) + created_at = Column(DateTime(timezone=True), nullable=True) + updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) + + # Relationships + chapter = relationship("Chapter", back_populates="images") + section = relationship( + "ChapterSection", + back_populates="image_record", + foreign_keys="ChapterSection.image_id", + ) class Book(Base): diff --git a/api/migrations/add_chapter_sections.sql b/api/migrations/add_chapter_sections.sql new file mode 100644 index 0000000..069cd07 --- /dev/null +++ b/api/migrations/add_chapter_sections.sql @@ -0,0 +1,26 @@ +-- 章节拆分为 chapter_sections:每段正文 + 配图独立存储,chapters 只保留封面图 +-- 执行顺序: 1) 本文件 2) python -m scripts.migrate_chapters_to_sections +-- 执行方式: psql -U -d -f api/migrations/add_chapter_sections.sql + +-- ========== 1. 新建 chapter_sections 表 ========== +CREATE TABLE IF NOT EXISTS chapter_sections ( + id VARCHAR NOT NULL PRIMARY KEY, + chapter_id VARCHAR NOT NULL REFERENCES chapters(id) ON DELETE CASCADE, + order_index INTEGER NOT NULL, + content TEXT NOT NULL, + image JSONB, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS ix_chapter_sections_chapter_id ON chapter_sections(chapter_id); +CREATE INDEX IF NOT EXISTS ix_chapter_sections_order ON chapter_sections(chapter_id, order_index); + +-- ========== 2. chapters 表增加 cover_image ========== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'cover_image') THEN + ALTER TABLE chapters ADD COLUMN cover_image JSONB; + RAISE NOTICE '已添加 chapters.cover_image'; + END IF; +END $$; + +-- ========== 3. 回填与删列由脚本 scripts.migrate_chapters_to_sections 完成 ========== diff --git a/api/migrations/add_memoir_images_table.sql b/api/migrations/add_memoir_images_table.sql new file mode 100644 index 0000000..e0bc2da --- /dev/null +++ b/api/migrations/add_memoir_images_table.sql @@ -0,0 +1,32 @@ +-- 图片独立表:将原先挤在 chapter.cover_image / chapter_sections.image 的 JSON 拆成独立列 +-- 执行顺序: 1) 本文件 2) python -m api.scripts.run_memoir_images_migration +-- 执行方式: psql -U -d -f api/migrations/add_memoir_images_table.sql + +-- ========== 1. 新建 memoir_images 表 ========== +CREATE TABLE IF NOT EXISTS memoir_images ( + id VARCHAR NOT NULL PRIMARY KEY, + chapter_id VARCHAR NOT NULL REFERENCES chapters(id) ON DELETE CASCADE, + section_id VARCHAR NULL REFERENCES chapter_sections(id) ON DELETE CASCADE, + order_index INTEGER NOT NULL DEFAULT 0, + placeholder TEXT, + description TEXT, + status VARCHAR NOT NULL DEFAULT 'pending', + prompt TEXT, + url TEXT, + storage_key TEXT, + provider VARCHAR, + style VARCHAR, + size VARCHAR, + error TEXT, + retryable BOOLEAN, + created_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE memoir_images IS '章节配图与封面:section_id 为空表示章节封面,非空表示该 section 的配图'; +COMMENT ON COLUMN memoir_images.order_index IS '章节内唯一:封面=0,段落配图=1,2,3,...(对应 section.order_index+1)'; + +CREATE INDEX IF NOT EXISTS ix_memoir_images_chapter_id ON memoir_images(chapter_id); +CREATE INDEX IF NOT EXISTS ix_memoir_images_section_id ON memoir_images(section_id); +CREATE UNIQUE INDEX IF NOT EXISTS ix_memoir_images_chapter_cover ON memoir_images(chapter_id) WHERE section_id IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS ix_memoir_images_section_unique ON memoir_images(section_id) WHERE section_id IS NOT NULL; diff --git a/api/migrations/add_section_image_id_fk.sql b/api/migrations/add_section_image_id_fk.sql new file mode 100644 index 0000000..114418f --- /dev/null +++ b/api/migrations/add_section_image_id_fk.sql @@ -0,0 +1,18 @@ +-- section 表用 image_id 关联 memoir_images,不再存 JSON +-- 执行顺序:1) 本文件 2) 回填后执行 DROP 语句(或由脚本完成) +-- 执行方式: psql -U -d -f api/migrations/add_section_image_id_fk.sql + +-- 1. 添加外键列(可空,无默认) +ALTER TABLE chapter_sections +ADD COLUMN IF NOT EXISTS image_id VARCHAR REFERENCES memoir_images(id) ON DELETE SET NULL; + +-- 2. 回填:已有 memoir_images 且 section_id 指向本行的,把其 id 写入本行 image_id +UPDATE chapter_sections cs +SET image_id = sub.id +FROM ( + SELECT id, section_id FROM memoir_images WHERE section_id IS NOT NULL +) sub +WHERE sub.section_id = cs.id AND cs.image_id IS NULL; + +-- 3. 删除旧的 JSON 列 +ALTER TABLE chapter_sections DROP COLUMN IF EXISTS image; diff --git a/api/migrations/fix_memoir_images_order_index.sql b/api/migrations/fix_memoir_images_order_index.sql new file mode 100644 index 0000000..20507fb --- /dev/null +++ b/api/migrations/fix_memoir_images_order_index.sql @@ -0,0 +1,9 @@ +-- 修复 memoir_images 同一章节内 order_index 重复:封面=0,段落配图=1,2,3,...(section.order_index+1) +-- 仅更新有 section_id 的段落配图,封面(section_id 为空)保持 0。 +-- 执行方式: psql -U -d -f api/migrations/fix_memoir_images_order_index.sql + +UPDATE memoir_images mi +SET order_index = cs.order_index + 1 +FROM chapter_sections cs +WHERE mi.section_id IS NOT NULL + AND mi.section_id = cs.id; diff --git a/api/routers/books.py b/api/routers/books.py index 89004d4..cb99d1c 100644 --- a/api/routers/books.py +++ b/api/routers/books.py @@ -112,14 +112,20 @@ async def export_pdf( if book.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权导出此回忆录") - # 获取所有 active 章节 + # 获取所有 active 章节并预加载 sections(供 PDF 按段渲染) from database.models import Chapter - stmt = select(Chapter).where( - Chapter.user_id == current_user.id, - Chapter.is_active == True - ).order_by(Chapter.order_index) + from sqlalchemy.orm import joinedload + stmt = ( + select(Chapter) + .where( + Chapter.user_id == current_user.id, + Chapter.is_active == True, + ) + .options(joinedload(Chapter.sections)) + .order_by(Chapter.order_index) + ) result = await db.execute(stmt) - chapters = result.scalars().all() + chapters = result.unique().scalars().all() # 生成 PDF pdf_bytes = await pdf_service.generate_pdf(book, chapters) diff --git a/api/routers/chapters.py b/api/routers/chapters.py index 6e6e4f9..6e13688 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -8,9 +8,10 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload from database import get_async_db -from database.models import Chapter as ChapterModel +from database.models import Chapter as ChapterModel, ChapterSection from database.models import User as UserModel from middleware.auth import get_current_user from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER @@ -19,6 +20,7 @@ from services.memoir_images.schema import ( IMAGE_STATUS_COMPLETED, normalize_image_assets, ) +from services.memoir_images.serializers import memoir_image_to_dict from services.memoir_images.settings import MemoirImageSettings from services.memoir_images.storage import ( CosDownloadUrlError, @@ -72,16 +74,68 @@ def _normalize_image_assets(images: list[dict] | None) -> list[dict]: return normalized_assets +def _section_image_to_dict(section) -> dict | None: + """从 section.image_id 关联的 memoir_images(image_record)取配图。""" + if getattr(section, "image_record", None): + return memoir_image_to_dict(section.image_record) + return None + + +def _chapter_cover_to_dict(ch) -> dict | None: + """优先从 memoir_images 表(section_id 为空的一条)取封面,否则回退到 chapter.cover_image JSON。""" + images = getattr(ch, "images", None) or [] + for m in images: + if getattr(m, "section_id", None) is None: + return memoir_image_to_dict(m) + if getattr(ch, "cover_image", None) and isinstance(ch.cover_image, dict): + return ch.cover_image + return None + + +def _sections_to_content_and_images(ch): + """ + 从 chapter.sections 按 order_index 顺序拼出 content 与 images,保证每段文字与配图一一对应。 + 客户端依赖 content 中的占位符(与 images 中每项的 placeholder 一致)来切分正文并插入图片。 + """ + sections = getattr(ch, "sections", None) or [] + ordered = sorted(sections, key=lambda s: getattr(s, "order_index", 0)) + parts = [] + images = [] + for s in ordered: + text = (s.content or "").strip() + if text: + parts.append(text) + img = _section_image_to_dict(s) + if img: + images.append(img) + placeholder = (img.get("placeholder") or "").strip() + if placeholder: + parts.append(placeholder) + content = "\n\n".join(parts) if parts else "" + return content, images + + def _chapter_to_dict(ch: ChapterModel) -> dict: - normalized_images = _normalize_image_assets(ch.images) + content, images_list = _sections_to_content_and_images(ch) + normalized_images = _normalize_image_assets(images_list) + cover = _chapter_cover_to_dict(ch) + cover_normalized = _normalize_image_assets([cover] if cover else [])[0] if cover else None + sections_data = [] + if getattr(ch, "sections", None): + for s in sorted(ch.sections, key=lambda x: getattr(x, "order_index", 0)): + sec_img = _section_image_to_dict(s) + sec_img = _normalize_image_assets([sec_img] if sec_img else [])[0] if sec_img else None + sections_data.append({"content": (s.content or "").strip(), "image": sec_img}) return { "id": ch.id, "title": ch.title, - "content": ch.content, + "content": content, "order_index": ch.order_index, "status": ch.status, "category": ch.category, "images": normalized_images, + "cover_image": cover_normalized, + "sections": sections_data, "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, "is_new": ch.is_new, "source_segments": ch.source_segments or [], @@ -98,15 +152,23 @@ async def get_chapters( 获取用户所有章节(需要认证,仅返回 active 章节)。 始终返回全部 8 个预定义类别,没有内容的类别用占位符返回。 """ - stmt = select(ChapterModel).where( - ChapterModel.user_id == current_user.id, - ChapterModel.is_active == True + stmt = ( + select(ChapterModel) + .where( + ChapterModel.user_id == current_user.id, + ChapterModel.is_active == True, + ) + .options( + joinedload(ChapterModel.sections), + joinedload(ChapterModel.images), + joinedload(ChapterModel.sections).joinedload(ChapterSection.image_record), + ) + .order_by(ChapterModel.order_index) ) if is_new is True: stmt = stmt.where(ChapterModel.is_new == True) - stmt = stmt.order_by(ChapterModel.order_index) result = await db.execute(stmt) - chapters = result.scalars().all() + chapters = result.unique().scalars().all() chapter_by_category: dict[str, ChapterModel] = {} for ch in chapters: @@ -129,6 +191,8 @@ async def get_chapters( "status": "empty", "category": category, "images": [], + "cover_image": None, + "sections": [], "updated_at": None, "is_new": False, "source_segments": [], @@ -147,26 +211,22 @@ async def get_chapter( db: AsyncSession = Depends(get_async_db) ): """获取章节详情(需要认证,只能访问自己的章节)""" - chapter = await db.get(ChapterModel, chapter_id) + stmt = ( + select(ChapterModel) + .where(ChapterModel.id == chapter_id) + .options( + joinedload(ChapterModel.sections), + joinedload(ChapterModel.images), + joinedload(ChapterModel.sections).joinedload(ChapterSection.image_record), + ) + ) + result = await db.execute(stmt) + chapter = result.unique().scalar_one_or_none() if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") - - # 验证用户权限 if chapter.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权访问此章节") - - return { - "id": chapter.id, - "title": chapter.title, - "content": chapter.content, - "order_index": chapter.order_index, - "status": chapter.status, - "category": chapter.category, - "images": _normalize_image_assets(chapter.images), - "updated_at": chapter.updated_at.isoformat() if chapter.updated_at else None, - "is_new": chapter.is_new, - "source_segments": chapter.source_segments or [], - } + return _chapter_to_dict(chapter) @router.delete("/{chapter_id}") diff --git a/api/scripts/migrate_chapters_to_sections.py b/api/scripts/migrate_chapters_to_sections.py new file mode 100644 index 0000000..ea7d204 --- /dev/null +++ b/api/scripts/migrate_chapters_to_sections.py @@ -0,0 +1,98 @@ +""" +将 chapters 的 content + images 迁移到 chapter_sections,并删除 chapters.content / chapters.images。 + +前置:已执行 api/migrations/add_chapter_sections.sql(创建 chapter_sections 表、chapters.cover_image 列)。 + +用法(在 api 目录下): + python -m scripts.migrate_chapters_to_sections +""" +import json +import logging +import os +import sys +import uuid + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import text +from database.database import engine +from services.memoir_images.parser import split_narrative_to_sections + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +def run(): + with engine.connect() as conn: + # 检查是否还存在 content 列(若已删则跳过) + r = conn.execute(text(""" + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'content' + """)) + if r.fetchone() is None: + logger.info("chapters.content 已不存在,跳过迁移") + return + + # 读取所有有 content 的章节(原始列) + rows = conn.execute(text(""" + SELECT id, content, images FROM chapters WHERE content IS NOT NULL AND trim(content) != '' + """)).fetchall() + + for row in rows: + ch_id, content, images_raw = row[0], row[1], row[2] + images = json.loads(images_raw) if isinstance(images_raw, str) else (images_raw or []) + if not isinstance(images, list): + images = [] + + sections = split_narrative_to_sections(content or "") + if not sections: + # 无占位符:整段为一条 section,无图 + section_id = str(uuid.uuid4()).replace("-", "")[:32] + conn.execute(text(""" + INSERT INTO chapter_sections (id, chapter_id, order_index, content, image, updated_at) + VALUES (:id, :ch_id, 0, :content, NULL, NOW()) + """), {"id": section_id, "ch_id": ch_id, "content": (content or "").strip()}) + conn.commit() + logger.info("章节 %s: 1 条 section(无图)", ch_id) + continue + + first_cover = None + img_index = 0 + for order_idx, seg in enumerate(sections): + section_id = str(uuid.uuid4()).replace("-", "")[:32] + seg_content = seg.get("content") or "" + ph = seg.get("placeholder_info") + image_json = None + if ph is not None and img_index < len(images): + image_json = json.dumps(images[img_index]) if isinstance(images[img_index], dict) else None + if first_cover is None and image_json: + first_cover = image_json + img_index += 1 + + conn.execute(text(""" + INSERT INTO chapter_sections (id, chapter_id, order_index, content, image, updated_at) + VALUES (:id, :ch_id, :ord, :content, :img::jsonb, NOW()) + """), { + "id": section_id, + "ch_id": ch_id, + "ord": order_idx, + "content": seg_content, + "img": image_json, + }) + if first_cover: + conn.execute( + text("UPDATE chapters SET cover_image = :img::jsonb WHERE id = :id"), + {"img": first_cover, "id": ch_id}, + ) + conn.commit() + logger.info("章节 %s: %d 条 sections", ch_id, len(sections)) + + # 删除 chapters.content 和 chapters.images + conn.execute(text("ALTER TABLE chapters DROP COLUMN IF EXISTS content")) + conn.execute(text("ALTER TABLE chapters DROP COLUMN IF EXISTS images")) + conn.commit() + logger.info("已删除 chapters.content 与 chapters.images") + + +if __name__ == "__main__": + run() diff --git a/api/scripts/reprocess_user_memoir.py b/api/scripts/reprocess_user_memoir.py index bbab937..f2804c9 100644 --- a/api/scripts/reprocess_user_memoir.py +++ b/api/scripts/reprocess_user_memoir.py @@ -47,7 +47,7 @@ import signal from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker, Session -from database.models import Base, User, Conversation, Segment, Chapter, Book, MemoirState +from database.models import Base, User, Conversation, Segment, Chapter, ChapterSection, Book, MemoirState from services.llm_service import LLMService from agents.state_schema import MemoirStateSchema, SlotData, default_state from agents.prompts.memory_prompts import ( @@ -57,6 +57,7 @@ from agents.prompts.memory_prompts import ( inject_image_placeholder_template, STAGE_TO_ORDER, ) +from services.memoir_images.parser import split_narrative_to_sections logging.basicConfig( level=logging.INFO, @@ -350,23 +351,35 @@ def cmd_preview(phone: str, batch_size: int, skip_llm_slots: bool): nickname = user.nickname logger.info(f"用户: {nickname} (id={user_id})") - # 读取现有 active 章节 + # 读取现有 active 章节(含 sections,正文从 sections 拼接) + from sqlalchemy.orm import joinedload old_chapters = ( db.execute( select(Chapter) .where(Chapter.user_id == user_id, Chapter.is_active == True) + .options(joinedload(Chapter.sections)) .order_by(Chapter.order_index) ) + .unique() .scalars() .all() ) old_chapter_data = [] for ch in old_chapters: + content = "" + if getattr(ch, "sections", None): + content = "\n\n".join( + (s.content or "").strip() + for s in sorted(ch.sections, key=lambda x: x.order_index) + if (s.content or "").strip() + ) + content_len = len(content) + content_preview = (content[:200] + "…") if content_len > 200 else content old_chapter_data.append({ "category": ch.category, "title": ch.title, - "content_len": len(ch.content) if ch.content else 0, - "content_preview": (ch.content[:200] + "…") if ch.content and len(ch.content) > 200 else (ch.content or ""), + "content_len": content_len, + "content_preview": content_preview, }) logger.info(f"现有章节: {len(old_chapters)} 个") @@ -562,7 +575,7 @@ def cmd_apply(phone: str): slots={k: {sk: sv.model_dump() for sk, sv in v.items()} for k, v in ds.slots.items()}, )) - # 4. 插入新章节 + # 4. 插入新章节(无 content/images;正文与配图写入 chapter_sections) last_chapter_id = None for ch_data in new_chapters: ch_id = str(uuid.uuid4()) @@ -570,15 +583,34 @@ def cmd_apply(phone: str): id=ch_id, user_id=user_id, title=ch_data["title"], - content=ch_data["content"], order_index=ch_data["order_index"], status="completed", category=ch_data["category"], - images=[], + cover_image=None, is_new=True, source_segments=ch_data.get("source_segment_ids", []), ) db.add(chapter) + db.flush() + content = ch_data.get("content") or "" + sections = split_narrative_to_sections(content) + if not sections: + db.add(ChapterSection( + id=str(uuid.uuid4()).replace("-", "")[:32], + chapter_id=ch_id, + order_index=0, + content=content.strip(), + image=None, + )) + else: + for order_idx, seg in enumerate(sections): + db.add(ChapterSection( + id=str(uuid.uuid4()).replace("-", "")[:32], + chapter_id=ch_id, + order_index=order_idx, + content=(seg.get("content") or "").strip(), + image=None, + )) last_chapter_id = ch_id logger.info(f" 新建章节: [{ch_data['category']}] {ch_data['title']} — {ch_data['content_len']} 字") diff --git a/api/scripts/run_chapter_sections_migration.py b/api/scripts/run_chapter_sections_migration.py new file mode 100644 index 0000000..55f13e4 --- /dev/null +++ b/api/scripts/run_chapter_sections_migration.py @@ -0,0 +1,157 @@ +""" +一键执行 chapter_sections 迁移:先执行 SQL 建表/加列,再回填数据并删列。 + +依赖:.env 中 DATABASE_URL,以及 psycopg、python-dotenv。 +用法(在 api 目录下): + python -m scripts.run_chapter_sections_migration +""" +import json +import logging +import os +import sys +import uuid +from pathlib import Path + +# 仅加载 .env,不导入 database(避免 asyncpg 等依赖) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +import psycopg +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +def get_engine() -> Engine: + url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/life_echo") + return create_engine(url.replace("postgresql://", "postgresql+psycopg://"), pool_pre_ping=True) + + +def run_sql_migration(engine: Engine): + sql_path = Path(__file__).parent.parent / "migrations" / "add_chapter_sections.sql" + sql = sql_path.read_text(encoding="utf-8") + # 按 DO $$ ... $$; 与普通 ; 拆分,避免把 PL/pgSQL 块拆碎 + stmts = [] + rest = sql + while rest: + rest = rest.lstrip() + if rest.startswith("--"): + rest = rest[rest.find("\n") + 1:] if "\n" in rest else "" + continue + if rest.upper().startswith("DO "): + # 找到 $$; 或 $$ ; + i = rest.find("$$") + if i == -1: + break + j = rest.find("$$", i + 2) + if j == -1: + break + stmts.append(rest[: j + 2].strip() + ";") + rest = rest[j + 2:].lstrip().lstrip(";").lstrip() + continue + idx = rest.find(";") + if idx == -1: + break + part = rest[: idx].strip() + rest = rest[idx + 1:] + if part and not part.startswith("--"): + stmts.append(part + ";") + with engine.begin() as conn: + for i, s in enumerate(stmts): + try: + conn.execute(text(s)) + logger.info(" SQL %s OK", i + 1) + except Exception as e: + if "already exists" in str(e).lower(): + logger.info(" SQL %s (已存在)", i + 1) + continue + raise + logger.info("1/2 SQL 迁移完成") + + +def run_data_migration(engine: Engine): + from services.memoir_images.parser import split_narrative_to_sections + + with engine.connect() as conn: + r = conn.execute(text(""" + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'content' + """)) + if r.fetchone() is None: + logger.info("chapters.content 已不存在,跳过数据迁移") + return + + rows = conn.execute(text(""" + SELECT id, content, images FROM chapters WHERE content IS NOT NULL AND trim(content) != '' + """)).fetchall() + + for row in rows: + ch_id, content, images_raw = row[0], row[1], row[2] + if isinstance(images_raw, str): + try: + images = json.loads(images_raw) + except Exception: + images = [] + else: + images = images_raw if isinstance(images_raw, list) else [] + + sections = split_narrative_to_sections(content or "") + if not sections: + section_id = str(uuid.uuid4()).replace("-", "")[:32] + conn.execute(text(""" + INSERT INTO chapter_sections (id, chapter_id, order_index, content, image, updated_at) + VALUES (:id, :ch_id, 0, :content, NULL, NOW()) + """), {"id": section_id, "ch_id": ch_id, "content": (content or "").strip()}) + conn.commit() + logger.info("章节 %s: 1 条 section(无图)", ch_id) + continue + + first_cover = None + img_index = 0 + for order_idx, seg in enumerate(sections): + section_id = str(uuid.uuid4()).replace("-", "")[:32] + seg_content = seg.get("content") or "" + ph = seg.get("placeholder_info") + image_json = None + if ph is not None and img_index < len(images): + image_json = json.dumps(images[img_index]) if isinstance(images[img_index], dict) else None + if first_cover is None and image_json: + first_cover = image_json + img_index += 1 + + conn.execute(text(""" + INSERT INTO chapter_sections (id, chapter_id, order_index, content, image, updated_at) + VALUES (:id, :ch_id, :ord, :content, CAST(:img AS jsonb), NOW()) + """), { + "id": section_id, + "ch_id": ch_id, + "ord": order_idx, + "content": seg_content, + "img": image_json, + }) + if first_cover: + conn.execute( + text("UPDATE chapters SET cover_image = CAST(:img AS jsonb) WHERE id = :id"), + {"img": first_cover, "id": ch_id}, + ) + conn.commit() + logger.info("章节 %s: %d 条 sections", ch_id, len(sections)) + + conn.execute(text("ALTER TABLE chapters DROP COLUMN IF EXISTS content")) + conn.execute(text("ALTER TABLE chapters DROP COLUMN IF EXISTS images")) + conn.commit() + logger.info("已删除 chapters.content 与 chapters.images") + logger.info("2/2 数据迁移完成") + + +if __name__ == "__main__": + logger.info("开始 chapter_sections 迁移…") + engine = get_engine() + run_sql_migration(engine) + run_data_migration(engine) + logger.info("迁移全部完成") diff --git a/api/scripts/run_memoir_images_migration.py b/api/scripts/run_memoir_images_migration.py new file mode 100644 index 0000000..e33338c --- /dev/null +++ b/api/scripts/run_memoir_images_migration.py @@ -0,0 +1,196 @@ +""" +将 chapters.cover_image 与 chapter_sections.image 的 JSON 数据迁移到 memoir_images 表(字段独立列)。 + +前置:先执行 api/migrations/add_memoir_images_table.sql 建表。 +用法(在项目根目录或 api 目录下): + python -m api.scripts.run_memoir_images_migration +""" +import json +import logging +import os +import sys +import uuid + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +from sqlalchemy import text +from sqlalchemy.engine import Engine + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +def get_engine() -> Engine: + from sqlalchemy import create_engine + url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/life_echo") + return create_engine(url.replace("postgresql://", "postgresql+psycopg://"), pool_pre_ping=True) + + +def _row_from_image_json(img: dict | None, chapter_id: str, section_id: str | None, order_index: int) -> dict | None: + if not img or not isinstance(img, dict): + return None + placeholder = (img.get("placeholder") or "").strip() + description = (img.get("description") or "").strip() + if not placeholder and not description: + return None + if not placeholder: + placeholder = f'{{{{IMAGE:{description}}}}}' + created = img.get("created_at") + updated = img.get("updated_at") + if isinstance(created, str) and created: + try: + from datetime import datetime + created = datetime.fromisoformat(created.replace("Z", "+00:00")) + except Exception: + created = None + if isinstance(updated, str) and updated: + try: + from datetime import datetime + updated = datetime.fromisoformat(updated.replace("Z", "+00:00")) + except Exception: + updated = None + return { + "id": str(uuid.uuid4()).replace("-", "")[:32], + "chapter_id": chapter_id, + "section_id": section_id, + "order_index": order_index, + "placeholder": placeholder or None, + "description": description or None, + "status": (img.get("status") or "pending").strip() or "pending", + "prompt": img.get("prompt") or None, + "url": img.get("url") or None, + "storage_key": img.get("storage_key") or None, + "provider": img.get("provider") or None, + "style": img.get("style") or None, + "size": img.get("size") or None, + "error": img.get("error") or None, + "retryable": img.get("retryable") if img.get("retryable") is not None else None, + "created_at": created, + "updated_at": updated, + } + + +def run_sql_migration(engine: Engine): + from pathlib import Path + sql_path = Path(__file__).parent.parent / "migrations" / "add_memoir_images_table.sql" + if not sql_path.exists(): + logger.warning("未找到 %s,请先执行该 SQL 建表", sql_path) + return + sql = sql_path.read_text(encoding="utf-8") + stmts = [] + rest = sql + while rest: + rest = rest.lstrip() + if rest.startswith("--"): + rest = rest[rest.find("\n") + 1:] if "\n" in rest else "" + continue + if rest.upper().startswith("DO "): + i = rest.find("$$") + if i == -1: + break + j = rest.find("$$", i + 2) + if j == -1: + break + stmts.append(rest[: j + 2].strip() + ";") + rest = rest[j + 2:].lstrip().lstrip(";").lstrip() + continue + idx = rest.find(";") + if idx == -1: + break + part = rest[: idx].strip() + rest = rest[idx + 1:] + if part and not part.startswith("--"): + stmts.append(part + ";") + with engine.begin() as conn: + for i, s in enumerate(stmts): + try: + conn.execute(text(s)) + logger.info(" SQL %s OK", i + 1) + except Exception as e: + if "already exists" in str(e).lower(): + logger.info(" SQL %s (已存在)", i + 1) + continue + raise + logger.info("1/2 SQL 迁移完成") + + +def run_data_migration(engine: Engine): + ins = text(""" + INSERT INTO memoir_images ( + id, chapter_id, section_id, order_index, + placeholder, description, status, prompt, url, storage_key, + provider, style, size, error, retryable, created_at, updated_at + ) VALUES ( + :id, :chapter_id, :section_id, :order_index, + :placeholder, :description, :status, :prompt, :url, :storage_key, + :provider, :style, :size, :error, :retryable, :created_at, :updated_at + ) + """) + with engine.connect() as conn: + r = conn.execute(text(""" + SELECT id, cover_image FROM chapters WHERE cover_image IS NOT NULL + """)) + cover_count = 0 + for row in r: + ch_id, cover = row[0], row[1] + if isinstance(cover, str): + try: + cover = json.loads(cover) + except Exception: + cover = None + if not cover or not isinstance(cover, dict): + continue + exists = conn.execute( + text("SELECT 1 FROM memoir_images WHERE chapter_id = :ch_id AND section_id IS NULL"), + {"ch_id": ch_id}, + ).fetchone() + if exists: + continue + row_data = _row_from_image_json(cover, ch_id, None, 0) + if not row_data: + continue + conn.execute(ins, {**row_data, "updated_at": row_data.get("updated_at")}) + conn.commit() + cover_count += 1 + logger.info("封面图迁移: %d 条", cover_count) + + with engine.connect() as conn: + r = conn.execute(text(""" + SELECT id, chapter_id, order_index, image FROM chapter_sections WHERE image IS NOT NULL + """)) + sec_count = 0 + for row in r: + sec_id, ch_id, ord_idx, img = row[0], row[1], row[2], row[3] + if isinstance(img, str): + try: + img = json.loads(img) + except Exception: + img = None + if not img or not isinstance(img, dict): + continue + exists = conn.execute( + text("SELECT 1 FROM memoir_images WHERE section_id = :sec_id"), + {"sec_id": sec_id}, + ).fetchone() + if exists: + continue + row_data = _row_from_image_json(img, ch_id, sec_id, ord_idx + 1) + if not row_data: + continue + conn.execute(ins, {**row_data, "updated_at": row_data.get("updated_at")}) + conn.commit() + sec_count += 1 + logger.info("段落配图迁移: %d 条", sec_count) + logger.info("2/2 数据迁移完成") + + +if __name__ == "__main__": + logger.info("开始 memoir_images 迁移…") + engine = get_engine() + run_sql_migration(engine) + run_data_migration(engine) + logger.info("迁移全部完成") diff --git a/api/services/memoir_images/parser.py b/api/services/memoir_images/parser.py index 5d1a042..e6db4e5 100644 --- a/api/services/memoir_images/parser.py +++ b/api/services/memoir_images/parser.py @@ -52,3 +52,33 @@ def build_initial_image_assets( } for item in placeholders ] + + +def split_narrative_to_sections(narrative: str) -> list[dict[str, Any]]: + """ + 将带 {{IMAGE:...}} 占位符的正文按占位符拆成多段。 + 返回 list[dict],每项含: + - content: 本段纯文本(不含占位符) + - placeholder_info: 本段后的配图占位信息,或 None(最后一段无图) + """ + if not (narrative or narrative.strip()): + return [] + placeholders = parse_image_placeholders(narrative, max_images=None) + sections: list[dict[str, Any]] = [] + for i in range(len(placeholders) + 1): + if i == 0: + start = 0 + else: + prev = placeholders[i - 1] + start = prev["start_offset"] + len(prev["placeholder"]) + if i < len(placeholders): + end = placeholders[i]["start_offset"] + placeholder_info = placeholders[i] + else: + end = len(narrative) + placeholder_info = None + content = narrative[start:end] + if isinstance(content, str): + content = content.strip() + sections.append({"content": content or "", "placeholder_info": placeholder_info}) + return sections diff --git a/api/services/memoir_images/serializers.py b/api/services/memoir_images/serializers.py new file mode 100644 index 0000000..5e017fb --- /dev/null +++ b/api/services/memoir_images/serializers.py @@ -0,0 +1,70 @@ +""" +MemoirImage 模型与 API 用 dict 的互转(与 schema.normalize_image_asset 字段一致)。 +""" +from datetime import datetime +from typing import Any + +from database.models import MemoirImage + + +def _parse_optional_datetime(s: str | None): + if not s: + return None + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + +def memoir_image_to_dict(m: MemoirImage | None) -> dict[str, Any] | None: + """将 MemoirImage 转为前端/API 使用的单条图片 dict。""" + if not m: + return None + d: dict[str, Any] = { + "placeholder": m.placeholder or "", + "description": m.description or "", + "index": m.order_index, + "status": m.status or "pending", + "prompt": m.prompt, + "url": m.url, + "storage_key": m.storage_key, + "provider": m.provider, + "style": m.style, + "size": m.size, + "error": m.error, + "retryable": m.retryable, + "created_at": m.created_at.isoformat() if m.created_at else None, + "updated_at": m.updated_at.isoformat() if m.updated_at else None, + } + return d + + +def image_dict_to_row_kwargs(d: dict[str, Any] | None) -> dict[str, Any]: + """从单条图片 dict 提取可写入 MemoirImage 的字段(不含 id/chapter_id/section_id/order_index)。""" + if not d or not isinstance(d, dict): + return {} + created = d.get("created_at") + updated = d.get("updated_at") + if isinstance(created, str): + created = _parse_optional_datetime(created) + elif not isinstance(created, datetime): + created = None + if isinstance(updated, str): + updated = _parse_optional_datetime(updated) + elif not isinstance(updated, datetime): + updated = None + return { + "placeholder": d.get("placeholder") or None, + "description": d.get("description") or None, + "status": (d.get("status") or "pending").strip() or "pending", + "prompt": d.get("prompt") or None, + "url": d.get("url") or None, + "storage_key": d.get("storage_key") or None, + "provider": d.get("provider") or None, + "style": d.get("style") or None, + "size": d.get("size") or None, + "error": d.get("error") or None, + "retryable": d.get("retryable") if d.get("retryable") is not None else None, + "created_at": created, + "updated_at": updated, + } diff --git a/api/services/pdf_service.py b/api/services/pdf_service.py index 8c8623c..0a17b0c 100644 --- a/api/services/pdf_service.py +++ b/api/services/pdf_service.py @@ -13,6 +13,7 @@ from reportlab.lib.units import inch from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Image as ReportLabImage from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from services.memoir_images.serializers import memoir_image_to_dict from services.memoir_images.parser import PLACEHOLDER_RE from services.memoir_images.schema import IMAGE_STATUS_COMPLETED, normalize_image_assets from services.memoir_images.storage import ( @@ -48,6 +49,28 @@ def split_content_blocks(content: str, images: list[dict]) -> list[dict]: return blocks +def sections_to_blocks(sections: list, prepare_fn=None) -> list[dict]: + """ + 从 chapter_sections 生成 PDF 用的 blocks:按 order_index 顺序,每段正文 + 可选一张图。 + prepare_fn(images) 用于解析签名 URL,默认 _prepare_pdf_image_assets。 + """ + if prepare_fn is None: + prepare_fn = _prepare_pdf_image_assets + blocks: list[dict] = [] + for section in sorted(sections, key=lambda s: getattr(s, "order_index", 0)): + content = (getattr(section, "content", None) or "").strip() + if content: + blocks.append({"type": "text", "value": content}) + img = None + if getattr(section, "image_record", None): + img = memoir_image_to_dict(section.image_record) + if img: + prepared = prepare_fn([img]) + if prepared and prepared[0].get("url"): + blocks.append({"type": "image", "url": prepared[0]["url"]}) + return blocks + + def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]: storage = TencentCosStorageService.from_env() prepared_assets: list[dict] = [] @@ -148,8 +171,12 @@ class PDFService: story.append(Paragraph(chapter.title, heading_style)) story.append(Spacer(1, 0.2 * inch)) - images = _prepare_pdf_image_assets(getattr(chapter, "images", None) or []) - blocks = split_content_blocks(chapter.content, images) + sections = getattr(chapter, "sections", None) or [] + if sections: + blocks = sections_to_blocks(sections) + else: + images = _prepare_pdf_image_assets(getattr(chapter, "images", None) or []) + blocks = split_content_blocks(getattr(chapter, "content", "") or "", images) for block in blocks: if block["type"] == "text": diff --git a/api/tasks/memoir_tasks.py b/api/tasks/memoir_tasks.py index 4d9447b..3a9915f 100644 --- a/api/tasks/memoir_tasks.py +++ b/api/tasks/memoir_tasks.py @@ -12,11 +12,11 @@ from datetime import datetime, timezone import redis from celery import shared_task from PIL import Image -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, joinedload from database.database import SessionLocal -from database.models import Book, Chapter, Segment, MemoirState, User +from database.models import Book, Chapter, ChapterSection, MemoirImage, Segment, MemoirState, User from services.llm_service import llm_service from agents.state_schema import MemoirStateSchema, SlotData, default_state from agents.prompts.memory_prompts import ( @@ -31,7 +31,11 @@ from agents.prompts.memory_prompts import ( from agents.prompts.profile_prompts import format_user_profile_context import hashlib -from services.memoir_images.parser import build_initial_image_assets, parse_image_placeholders +from services.memoir_images.parser import ( + build_initial_image_assets, + parse_image_placeholders, + split_narrative_to_sections, +) from services.memoir_images.json_payload import extract_json_payload from services.memoir_images.prompting import MemoirImagePromptService from services.memoir_images.provider import LiblibImageProvider @@ -43,6 +47,7 @@ from services.memoir_images.schema import ( IMAGE_STATUS_PROCESSING, normalize_image_assets, ) +from services.memoir_images.serializers import image_dict_to_row_kwargs, memoir_image_to_dict from services.memoir_images.settings import MemoirImageSettings from services.memoir_images.storage import TencentCosStorageService, CosUploadError @@ -173,6 +178,38 @@ def chapter_has_images_to_generate(images: list[dict] | None) -> bool: ) +def _memoir_image_from_asset( + chapter_id: str, + section_id: str | None, + order_index: int, + image_asset: dict, +) -> MemoirImage: + """从单条图片 dict 构建 MemoirImage 行(用于写入 memoir_images 表)。""" + kwargs = image_dict_to_row_kwargs(image_asset) + return MemoirImage( + id=str(uuid.uuid4()).replace("-", "")[:32], + chapter_id=chapter_id, + section_id=section_id, + order_index=order_index, + **kwargs, + ) + + +def _section_has_image_to_generate(section) -> bool: + """章节段落是否有待生成的配图(从 image_record / image_id 关联的 memoir_images 读取)。""" + r = getattr(section, "image_record", None) + if not r: + return False + status = (getattr(r, "status") or "").strip() + return status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED) + + +def _chapter_has_any_section_images_to_generate(chapter) -> bool: + if not chapter or not getattr(chapter, "sections", None): + return False + return any(_section_has_image_to_generate(s) for s in chapter.sections) + + def _select_placeholders_for_effective_max( placeholders: list[dict], existing_images: list[dict] | None, @@ -201,41 +238,128 @@ def _select_placeholders_for_effective_max( return [{**item, "index": index} for index, item in enumerate(selected)] -def initialize_chapter_images(chapter) -> list[dict]: - """Parse IMAGE placeholders from chapter content and build pending image assets.""" - settings = MemoirImageSettings.from_env() - if not settings.enabled: - chapter.images = completed_image_assets(chapter.images) - logger.info(f"章节图片初始化跳过: chapter={chapter.id}, enabled=false") - return chapter.images +def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str, category: str, order_index: int, source_segments: list, user_id: str): + """ + 将带占位符的 narrative 拆成 chapter_sections 并写入;为每段占位符创建 pending 配图。 + 已有 section 与图片不删除,仅追加新内容。封面图先空着,不自动设置。 + chapter 可为已有章节或 None(会新建)。返回 chapter。 + """ + now_iso = datetime.now(timezone.utc).isoformat() + if chapter is None: + chapter = Chapter( + id=str(uuid.uuid4()), + user_id=user_id, + title=title, + order_index=order_index, + status="completed", + category=category, + cover_image=None, + is_new=True, + source_segments=source_segments or [], + ) + db.add(chapter) + db.flush() - prompt_service = MemoirImagePromptService(llm=None, settings=settings) - effective_max = settings.effective_max_images(len(chapter.content or "")) - all_placeholders = parse_image_placeholders(chapter.content, max_images=None) + # 已有 sections 不删除,只追加新内容 + existing_sections = ( + db.execute( + select(ChapterSection) + .where(ChapterSection.chapter_id == chapter.id) + .order_by(ChapterSection.order_index) + ) + .scalars().all() + ) + if existing_sections: + existing_content = "\n\n".join( + (s.content or "").strip() for s in existing_sections if (s.content or "").strip() + ) + if existing_content and narrative.startswith(existing_content): + new_part = narrative[len(existing_content):].lstrip() + else: + new_part = (narrative or "").strip() + if not new_part: + chapter.title = title + chapter.is_new = True + chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or []))) + return chapter + narrative_to_parse = new_part + order_base = max(s.order_index for s in existing_sections) + 1 + else: + narrative_to_parse = (narrative or "").strip() + order_base = 0 + + segments = split_narrative_to_sections(narrative_to_parse) + if not segments: + sec = ChapterSection( + id=str(uuid.uuid4()), + chapter_id=chapter.id, + order_index=order_base, + content=(narrative_to_parse or "").strip() or "", + image_id=None, + ) + db.add(sec) + db.flush() + chapter.title = title + chapter.is_new = True + chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or []))) + return chapter + + settings = MemoirImageSettings.from_env() + prompt_service = MemoirImagePromptService(llm=None, settings=settings) if settings.enabled else None + effective_max = settings.effective_max_images(len(narrative_to_parse)) if settings.enabled else 0 + all_placeholders = [s["placeholder_info"] for s in segments if s.get("placeholder_info")] placeholders = _select_placeholders_for_effective_max( placeholders=all_placeholders, - existing_images=chapter.images, + existing_images=[], effective_max=effective_max, - ) - style = prompt_service.CATEGORY_STYLE_MAP.get(chapter.category, settings.default_style) - chapter.images = _merge_chapter_image_assets( - existing_images=chapter.images, - placeholders=placeholders, - provider=settings.provider, - style=style, - size=settings.default_size, - now_iso=datetime.now(timezone.utc).isoformat(), - ) - logger.info( - "章节图片初始化完成: chapter=%s, effective_max=%d, total_placeholders=%d, selected_placeholders=%d, images=%d, statuses=%s", - chapter.id, - effective_max, - len(all_placeholders), - len(placeholders), - len(chapter.images or []), - [item.get("status") for item in (chapter.images or [])], - ) - return chapter.images + ) if settings.enabled else [] + selected_placeholder_set = {p.get("placeholder") for p in placeholders} + + # 按顺序创建 section,保证每个 section 的 content 与 image 一一对应(order_index 严格递增) + for i, seg in enumerate(segments): + order_idx = order_base + i + content = (seg.get("content") or "").strip() + ph = seg.get("placeholder_info") + image_asset = None + if ph and settings.enabled and ph.get("placeholder") in selected_placeholder_set: + style = prompt_service.CATEGORY_STYLE_MAP.get(category, settings.default_style) if prompt_service else settings.default_style + image_asset = build_initial_image_assets( + [ph], + settings.provider, + style, + settings.default_size, + now_iso, + )[0] + + sec = ChapterSection( + id=str(uuid.uuid4()), + chapter_id=chapter.id, + order_index=order_idx, + content=content, + image_id=None, + ) + db.add(sec) + db.flush() + if image_asset: + # 本段配图与当前 section 绑定,memoir_images.order_index = section.order_index + 1(封面 0 预留) + mi = _memoir_image_from_asset(chapter.id, sec.id, order_idx + 1, image_asset) + db.add(mi) + db.flush() + sec.image_id = mi.id + db.flush() + # 封面图先空着,不自动用首图做封面 + chapter.title = title + chapter.is_new = True + chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or []))) + return chapter + + +def initialize_chapter_images(_chapter): + """ + 兼容旧调用:若章节已改为 sections 存储,则图片初始化已在 _save_narrative_to_sections 中完成,直接返回。 + """ + logger.info("initialize_chapter_images: 已由 _save_narrative_to_sections 处理 section 配图,跳过") + return [] def _normalize_image_bytes_for_storage(image_bytes: bytes) -> bytes: @@ -464,14 +588,18 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): combined_text = "\n\n".join(segment_texts) source_ids = [seg.id for seg in category_segments] - # 查找 active 章节(被清除的章节不继续更新,而是创建新的) - stmt_chapter = select(Chapter).where( - Chapter.user_id == user_id, - Chapter.category == chapter_category, - Chapter.is_active == True, + # 查找 active 章节(被清除的章节不继续更新,而是创建新的),并预加载 sections + stmt_chapter = ( + select(Chapter) + .where( + Chapter.user_id == user_id, + Chapter.category == chapter_category, + Chapter.is_active == True, + ) + .options(joinedload(Chapter.sections)) ) result_chapter = db.execute(stmt_chapter) - chapter = result_chapter.scalar_one_or_none() + chapter = result_chapter.unique().scalar_one_or_none() # 获取 slot snippets slot_snippets = { @@ -480,9 +608,13 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): if value.snippet } - # 生成标题和内容 + # 生成标题和内容;已有章节的正文从 sections 拼接 title = chapter.title if chapter else f"{chapter_category} 回忆" - existing_content = chapter.content if chapter else "" + existing_content = "" + if chapter and getattr(chapter, "sections", None): + existing_content = "\n\n".join( + s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip() + ) narrative = combined_text if llm: @@ -529,34 +661,22 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): # 入库前:占位符位置用正则匹配后拼上固定模板 narrative = inject_image_placeholder_template(narrative) + calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) - # 更新或创建章节 - if chapter: - chapter.content = narrative - chapter.title = title - chapter.is_new = True - chapter.source_segments = list({*(chapter.source_segments or []), *source_ids}) - else: - # 根据 stage 计算正确的排序索引 - calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) - chapter = Chapter( - id=str(uuid.uuid4()), - user_id=user_id, - title=title, - content=narrative, - order_index=calculated_order_index, - status="completed", - category=chapter_category, - images=[], - is_new=True, - source_segments=source_ids, - ) - db.add(chapter) - + # 写入 sections(拆段 + 每段配图占位),新建或覆盖该章下所有 sections + chapter = _save_narrative_to_sections( + db, + chapter, + narrative, + title=title, + category=chapter_category, + order_index=calculated_order_index, + source_segments=source_ids, + user_id=user_id, + ) db.flush() - - initialize_chapter_images(chapter) - if image_settings.enabled and chapter_has_images_to_generate(chapter.images): + db.refresh(chapter) + if image_settings.enabled and _chapter_has_any_section_images_to_generate(chapter): chapters_to_enqueue.add(chapter.id) # 更新 Book @@ -628,17 +748,24 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): try: llm = llm_service.get_llm() - # 查找 active 章节(被清除的章节不继续更新,而是创建新的) - stmt = select(Chapter).where( - Chapter.user_id == user_id, - Chapter.category == stage, - Chapter.is_active == True, + # 查找 active 章节并预加载 sections + stmt = ( + select(Chapter) + .where( + Chapter.user_id == user_id, + Chapter.category == stage, + Chapter.is_active == True, + ) + .options(joinedload(Chapter.sections)) ) result = db.execute(stmt) - chapter = result.scalar_one_or_none() - - existing_content = chapter.content if chapter else "" - + chapter = result.unique().scalar_one_or_none() + existing_content = "" + if chapter and getattr(chapter, "sections", None): + existing_content = "\n\n".join( + s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip() + ) + if llm: prompt = get_narrative_prompt( stage=stage, @@ -666,27 +793,18 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): # 入库前:占位符位置用正则匹配后拼上固定模板 narrative = inject_image_placeholder_template(narrative) - - if chapter: - chapter.content = narrative - chapter.is_new = True - else: - # 根据 stage 计算正确的排序索引 - calculated_order_index = STAGE_TO_ORDER.get(stage, 999) - chapter = Chapter( - id=str(uuid.uuid4()), - user_id=user_id, - title=f"{stage} 回忆", - content=narrative, - order_index=calculated_order_index, - status="completed", - category=stage, - images=[], - is_new=True, - source_segments=[], - ) - db.add(chapter) - + calculated_order_index = STAGE_TO_ORDER.get(stage, 999) + title = chapter.title if chapter else f"{stage} 回忆" + chapter = _save_narrative_to_sections( + db, + chapter, + narrative, + title=title, + category=stage, + order_index=calculated_order_index, + source_segments=[], + user_id=user_id, + ) db.commit() return {"status": "success"} @@ -705,20 +823,33 @@ def build_cos_key(user_id: str, chapter_id: str, index: int, prompt: str) -> str @shared_task(bind=True, max_retries=3, default_retry_delay=30) def generate_chapter_images(self, chapter_id: str): - """Async task to generate images for a chapter's pending image assets.""" + """Async task to generate images for a chapter's sections (each section has at most one image).""" db = SessionLocal() lock_acquired = False provider = None try: - chapter = db.get(Chapter, chapter_id) - if not chapter or not chapter.images: - logger.info(f"章节补图跳过: chapter={chapter_id}, reason=no_images") + stmt = ( + select(Chapter) + .where(Chapter.id == chapter_id) + .options( + joinedload(Chapter.sections).joinedload(ChapterSection.image_record), + joinedload(Chapter.images), + ) + ) + chapter = db.execute(stmt).unique().scalar_one_or_none() + if not chapter: + logger.info("章节补图跳过: chapter=%s, reason=not_found", chapter_id) + return {"status": "no_chapter"} + sections = getattr(chapter, "sections", None) or [] + sections_with_pending = [ + (idx, s) for idx, s in enumerate(sections) if _section_has_image_to_generate(s) + ] + if not sections_with_pending: + logger.info("章节补图跳过: chapter=%s, reason=no_pending_images", chapter_id) return {"status": "no_images"} settings = MemoirImageSettings.from_env() if not settings.enabled: - chapter.images = completed_image_assets(chapter.images) - db.commit() logger.info("章节补图跳过: chapter=%s, reason=disabled", chapter_id) return {"status": "disabled"} @@ -730,42 +861,45 @@ def generate_chapter_images(self, chapter_id: str): prompt_service = MemoirImagePromptService(llm_service.get_llm(), settings) provider = LiblibImageProvider(template_uuid=settings.liblib_template_uuid) storage = TencentCosStorageService.from_env() - images = normalize_image_assets(chapter.images) - pending_count = sum( - 1 for item in images if item.get("status") in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED} - ) logger.info( - "章节补图开始: chapter=%s, total_images=%d, pending_images=%d", + "章节补图开始: chapter=%s, pending_sections=%d", chapter_id, - len(images), - pending_count, + len(sections_with_pending), ) retryable_failures: list[str] = [] permanent_failures: list[str] = [] - for index, item in enumerate(images): - if item.get("status") == IMAGE_STATUS_COMPLETED and (item.get("storage_key") or item.get("url")): - continue - if item.get("status") == IMAGE_STATUS_FAILED and item.get("retryable") is False: - continue - if item.get("status") not in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED}: - continue + def _apply_item_to_memoir_image(rec: MemoirImage, d: dict): + rec.placeholder = d.get("placeholder") + rec.description = d.get("description") + rec.status = (d.get("status") or "pending").strip() or "pending" + rec.prompt = d.get("prompt") + rec.url = d.get("url") + rec.storage_key = d.get("storage_key") + rec.provider = d.get("provider") + rec.style = d.get("style") + rec.size = d.get("size") + rec.error = d.get("error") + rec.retryable = d.get("retryable") + rec.updated_at = datetime.now(timezone.utc) - current_item = dict(item) + for sec_index, section in sections_with_pending: + item = memoir_image_to_dict(section.image_record) if section.image_record else {} + current_item = dict(item) if item else {} + current_item.setdefault("placeholder", "") + current_item.setdefault("description", "") current_item["status"] = IMAGE_STATUS_PROCESSING current_item["updated_at"] = datetime.now(timezone.utc).isoformat() - images[index] = current_item - chapter.images = images + _apply_item_to_memoir_image(section.image_record, current_item) db.commit() try: - context_lines = (chapter.content or "").split("\n") - context_excerpt = " ".join(context_lines[:5])[:200] - + context_lines = (section.content or "").strip().split("\n")[:5] + context_excerpt = " ".join(context_lines)[:200] prompt_data = prompt_service.build_prompt( chapter_title=chapter.title, chapter_category=chapter.category or "", - description=item.get("description", ""), + description=current_item.get("description", ""), context_excerpt=context_excerpt, ) job = provider.submit_generation( @@ -780,7 +914,7 @@ def generate_chapter_images(self, chapter_id: str): max_attempts=settings.max_attempts, ) image_bytes = _normalize_image_bytes_for_storage(provider.download_image(job)) - key = build_cos_key(chapter.user_id, chapter.id, current_item["index"], prompt_data["prompt"]) + key = build_cos_key(chapter.user_id, chapter.id, sec_index, prompt_data["prompt"]) current_item["storage_key"] = key current_item["url"] = storage.upload_bytes(image_bytes, key, "image/png") current_item["prompt"] = prompt_data["prompt"] @@ -790,15 +924,15 @@ def generate_chapter_images(self, chapter_id: str): current_item["error"] = None current_item["retryable"] = None logger.info( - "章节补图成功: chapter=%s, index=%s, url=%s", + "章节补图成功: chapter=%s, section_index=%s, url=%s", chapter_id, - current_item.get("index"), + sec_index, current_item["url"], ) except Exception as exc: current_item["status"] = IMAGE_STATUS_FAILED current_item["error"] = str(exc) - failure_msg = f"index={current_item.get('index')}, error={exc}" + failure_msg = f"section_index={sec_index}, error={exc}" if isinstance(exc, CosUploadError) and not exc.retryable: current_item["retryable"] = False permanent_failures.append(failure_msg) @@ -809,10 +943,10 @@ def generate_chapter_images(self, chapter_id: str): logger.warning("图片生成失败(可重试): chapter=%s, %s", chapter_id, failure_msg) current_item["updated_at"] = datetime.now(timezone.utc).isoformat() - images[index] = current_item - chapter.images = images + _apply_item_to_memoir_image(section.image_record, current_item) db.commit() + # 封面图先空着,不自动用首张完成图做封面 if retryable_failures: raise RuntimeError( f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}" @@ -821,7 +955,6 @@ def generate_chapter_images(self, chapter_id: str): raise PermanentImageGenerationError( f"章节补图存在不可重试失败项: chapter={chapter_id}, failures={'; '.join(permanent_failures)}" ) - return {"status": "success"} except PermanentImageGenerationError as exc: logger.error("章节补图任务失败(不重试): chapter=%s, error=%s", chapter_id, exc) diff --git a/api/tests/test_generate_chapter_images_task.py b/api/tests/test_generate_chapter_images_task.py index 607195f..5a29ba5 100644 --- a/api/tests/test_generate_chapter_images_task.py +++ b/api/tests/test_generate_chapter_images_task.py @@ -9,6 +9,58 @@ from api.tasks import memoir_tasks from api.tasks.memoir_tasks import generate_chapter_images +def _section_image_record(img_dict): + """把图片 dict 转成 image_record 用的 SimpleNamespace(可被任务更新属性)。""" + d = dict(img_dict or {}) + return SimpleNamespace( + placeholder=d.get("placeholder"), + description=d.get("description"), + status=d.get("status"), + prompt=d.get("prompt"), + url=d.get("url"), + storage_key=d.get("storage_key"), + error=d.get("error"), + retryable=d.get("retryable"), + ) + + +def _chapter_with_sections(sections_data): + """构造带 sections 的 chapter stub,供 generate_chapter_images 使用(任务从 section.image_record 读/写)。""" + sections = [] + for i, d in enumerate(sections_data): + img = d.get("image") + if img: + rec = _section_image_record(img) + sec = SimpleNamespace( + content=d.get("content", ""), + image_id="img-%s-%s" % (i, id(rec)), + image_record=rec, + order_index=d.get("order_index", i), + ) + else: + sec = SimpleNamespace( + content=d.get("content", ""), + image_id=None, + image_record=None, + order_index=d.get("order_index", i), + ) + sections.append(sec) + return SimpleNamespace( + id="chapter-1", + user_id="user-1", + title="童年的夏天", + category="childhood", + cover_image=None, + images=[], + sections=sections, + ) + + +def _bind_db_execute_to_chapter(db_mock, chapter): + """让 db.execute(select(...)).unique().scalar_one_or_none() 返回 chapter。""" + db_mock.execute.return_value.unique.return_value.scalar_one_or_none.return_value = chapter + + class GenerateChapterImagesTaskTest(unittest.TestCase): def setUp(self): memoir_tasks._REDIS_CLIENTS.clear() @@ -26,29 +78,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): session_local_cls, redis_from_url, ): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "user_id": "user-1", - "title": "童年的夏天", - "category": "childhood", - "content": "那条路我一直记得。", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - } - ], - }, - )() - + chapter = _chapter_with_sections([ + {"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}}, + ]) db = Mock() - db.get.return_value = chapter + _bind_db_execute_to_chapter(db, chapter) session_local_cls.return_value = db redis_from_url.return_value.set.return_value = False @@ -74,29 +108,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, session_local_cls, ): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "user_id": "user-1", - "title": "童年的夏天", - "category": "childhood", - "content": "那条路我一直记得。", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - } - ], - }, - )() - + chapter = _chapter_with_sections([ + {"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}}, + ]) db = Mock() - db.get.return_value = chapter + _bind_db_execute_to_chapter(db, chapter) session_local_cls.return_value = db prompt_service_cls.return_value.build_prompt.return_value = { "prompt": "A serene southern China town", @@ -113,8 +129,8 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): generate_chapter_images.run.__func__(task_self, "chapter-1") self.assertIs(ctx.exception, retry_error) - self.assertEqual(chapter.images[0]["status"], "failed") - self.assertEqual(chapter.images[0]["error"], "transient provider error") + self.assertEqual(chapter.sections[0].image_record.status, "failed") + self.assertEqual(chapter.sections[0].image_record.error, "transient provider error") task_self.retry.assert_called_once() storage_cls.from_env.return_value.upload_bytes.assert_not_called() @@ -133,29 +149,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, session_local_cls, ): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "user_id": "user-1", - "title": "童年的夏天", - "category": "childhood", - "content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - } - ], - }, - )() - + chapter = _chapter_with_sections([ + {"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}}, + ]) db = Mock() - db.get.return_value = chapter + _bind_db_execute_to_chapter(db, chapter) session_local_cls.return_value = db prompt_service_cls.return_value.build_prompt.return_value = { "prompt": "A serene southern China town", @@ -176,10 +174,10 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): generate_chapter_images.run("chapter-1") - self.assertEqual(chapter.images[0]["status"], "completed") - self.assertEqual(chapter.images[0]["storage_key"], "memoirs/user-1/chapter-1/0-7e1f860790.png") - self.assertEqual(chapter.images[0]["url"], "https://cos.example.com/memoirs/u1/c1/0.png") - self.assertEqual(chapter.images[0]["prompt"], "A serene southern China town") + self.assertEqual(chapter.sections[0].image_record.status, "completed") + self.assertEqual(chapter.sections[0].image_record.storage_key, "memoirs/user-1/chapter-1/0-7e1f860790.png") + self.assertEqual(chapter.sections[0].image_record.url, "https://cos.example.com/memoirs/u1/c1/0.png") + self.assertEqual(chapter.sections[0].image_record.prompt, "A serene southern China town") provider_inst.close.assert_called_once() db.commit.assert_called() @@ -196,27 +194,9 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, session_local_cls, ): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "user_id": "user-1", - "title": "童年的夏天", - "category": "childhood", - "content": "那条路我一直记得。", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - } - ], - }, - )() - + chapter = _chapter_with_sections([ + {"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}}, + ]) settings_from_env.return_value = SimpleNamespace( enabled=False, max_per_chapter=2, @@ -227,15 +207,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): max_attempts=20, liblib_template_uuid="tpl-uuid", ) - db = Mock() - db.get.return_value = chapter + _bind_db_execute_to_chapter(db, chapter) session_local_cls.return_value = db result = generate_chapter_images.run("chapter-1") self.assertEqual(result, {"status": "disabled"}) - self.assertEqual(chapter.images, []) prompt_service_cls.assert_not_called() provider_cls.assert_not_called() storage_cls.from_env.return_value.upload_bytes.assert_not_called() @@ -256,33 +234,15 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, session_local_cls, ): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "user_id": "user-1", - "title": "童年的夏天", - "category": "childhood", - "content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - } - ], - }, - )() - + chapter = _chapter_with_sections([ + {"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}}, + ]) image_buffer = BytesIO() Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG") jpeg_bytes = image_buffer.getvalue() db = Mock() - db.get.return_value = chapter + _bind_db_execute_to_chapter(db, chapter) session_local_cls.return_value = db prompt_service_cls.return_value.build_prompt.return_value = { "prompt": "A serene southern China town", @@ -320,33 +280,15 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, session_local_cls, ): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "user_id": "user-1", - "title": "童年的夏天", - "category": "childhood", - "content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - } - ], - }, - )() - + chapter = _chapter_with_sections([ + {"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}}, + ]) image_buffer = BytesIO() Image.new("RGB", (1, 1), color="white").save(image_buffer, format="PNG") png_bytes = image_buffer.getvalue() db = Mock() - db.get.return_value = chapter + _bind_db_execute_to_chapter(db, chapter) session_local_cls.return_value = db prompt_service_cls.return_value.build_prompt.return_value = { "prompt": "A serene southern China town", @@ -370,8 +312,8 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): generate_chapter_images.run.__func__(task_self, "chapter-1") self.assertIn("AccessDenied", str(ctx.exception)) - self.assertEqual(chapter.images[0]["status"], "failed") - self.assertIn("AccessDenied", chapter.images[0]["error"]) + self.assertEqual(chapter.sections[0].image_record.status, "failed") + self.assertIn("AccessDenied", chapter.sections[0].image_record.error) task_self.retry.assert_not_called() @patch("api.tasks.memoir_tasks.SessionLocal") @@ -389,29 +331,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, session_local_cls, ): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "user_id": "user-1", - "title": "童年的夏天", - "category": "childhood", - "content": "那条路我一直记得。", - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "completed", - "url": "https://cos.example.com/already-there.png", - } - ], - }, - )() - + chapter = _chapter_with_sections([ + {"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "completed", "url": "https://cos.example.com/already-there.png"}}, + ]) db = Mock() - db.get.return_value = chapter + _bind_db_execute_to_chapter(db, chapter) session_local_cls.return_value = db generate_chapter_images.run("chapter-1") diff --git a/api/tests/test_memoir_image_bootstrap.py b/api/tests/test_memoir_image_bootstrap.py index 45cb03c..a049082 100644 --- a/api/tests/test_memoir_image_bootstrap.py +++ b/api/tests/test_memoir_image_bootstrap.py @@ -7,6 +7,7 @@ from api.tasks.memoir_tasks import initialize_chapter_images class MemoirImageBootstrapTest(unittest.TestCase): def test_initialize_chapter_images_keeps_only_completed_assets_when_disabled(self): + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op,直接返回 []""" chapter = type( "ChapterStub", (), @@ -14,35 +15,16 @@ class MemoirImageBootstrapTest(unittest.TestCase): "id": "chapter-1", "title": "童年的夏天", "category": "childhood", - "content": "那条路我一直记得。", - "images": [ - { - "index": 0, - "placeholder": "{{IMAGE:南方小镇的青石板路}}", - "description": "南方小镇的青石板路", - "status": "completed", - "url": "https://cos.example.com/existing.png", - }, - { - "index": 1, - "placeholder": "{{IMAGE:奶奶坐在院子里的藤椅上}}", - "description": "奶奶坐在院子里的藤椅上", - "status": "pending", - "url": None, - }, - ], }, )() with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "false"}, clear=False): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 1) - self.assertEqual(assets[0]["status"], "completed") - self.assertEqual(assets[0]["url"], "https://cos.example.com/existing.png") - self.assertEqual(chapter.images, assets) + self.assertEqual(assets, []) def test_initialize_chapter_images_sets_pending_assets_when_enabled(self): + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" chapter = type( "ChapterStub", (), @@ -50,234 +32,68 @@ class MemoirImageBootstrapTest(unittest.TestCase): "id": "chapter-1", "title": "童年的夏天", "category": "childhood", - "content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}", - "images": [], }, )() with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 1) - self.assertEqual(assets[0]["status"], "pending") + self.assertEqual(assets, []) def test_initialize_chapter_images_preserves_completed_assets_and_adds_only_new_placeholders(self): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - "content": ( - "那条路我一直记得。\n\n" - "{{{{IMAGE:南方小镇的青石板路}}}}\n\n" - "奶奶总坐在门口。\n\n" - "{{{{IMAGE:奶奶坐在院子里的藤椅上}}}}" - ), - "images": [ - { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "prompt": "A serene southern China town", - "url": "https://cos.example.com/existing.png", - "status": "completed", - "provider": "liblib", - "style": "watercolor", - "size": "1024x1024", - "error": None, - "created_at": "2026-03-10T10:00:00Z", - "updated_at": "2026-03-10T10:00:00Z", - } - ], - }, - )() + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" + chapter = type("ChapterStub", (), {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"})() with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 2) - self.assertEqual(assets[0]["status"], "completed") - self.assertEqual(assets[0]["url"], "https://cos.example.com/existing.png") - self.assertEqual(assets[1]["status"], "pending") - self.assertEqual(assets[1]["description"], "奶奶坐在院子里的藤椅上") + self.assertEqual(assets, []) def test_initialize_chapter_images_accepts_double_brace_placeholders(self): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - "content": "开头。\n\n{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}\n\n结尾。", - "images": [], - }, - )() + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" + chapter = type("ChapterStub", (), {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"})() with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 1) - self.assertEqual(assets[0]["status"], "pending") - self.assertEqual(assets[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}") + self.assertEqual(assets, []) def test_initialize_chapter_images_normalizes_invalid_existing_asset_status(self): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - "content": "开头。\n\n{{IMAGE:南方小镇的青石板路}}\n\n结尾。", - "images": [ - { - "index": 0, - "placeholder": "{{IMAGE:南方小镇的青石板路}}", - "description": "南方小镇的青石板路", - "status": "mystery", - } - ], - }, - )() + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" + chapter = type("ChapterStub", (), {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"})() with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 1) - self.assertEqual(assets[0]["status"], "failed") - self.assertEqual(assets[0]["error"], "invalid image status: mystery") + self.assertEqual(assets, []) def test_initialize_chapter_images_preserves_existing_completed_assets_beyond_effective_max(self): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - "content": ( - "{{IMAGE:南方小镇的青石板路}}\n" - "{{IMAGE:奶奶坐在院子里的藤椅上}}\n" - "{{IMAGE:门前的老槐树}}" - ), - "images": [ - { - "index": 0, - "placeholder": "{{IMAGE:南方小镇的青石板路}}", - "description": "南方小镇的青石板路", - "status": "completed", - "url": "https://cos.example.com/1.png", - }, - { - "index": 1, - "placeholder": "{{IMAGE:奶奶坐在院子里的藤椅上}}", - "description": "奶奶坐在院子里的藤椅上", - "status": "completed", - "url": "https://cos.example.com/2.png", - }, - { - "index": 2, - "placeholder": "{{IMAGE:门前的老槐树}}", - "description": "门前的老槐树", - "status": "completed", - "url": "https://cos.example.com/3.png", - }, - ], - }, - )() + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" + chapter = type("ChapterStub", (), {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"})() with unittest.mock.patch.dict( os.environ, - { - "MEMOIR_IMAGE_ENABLED": "true", - "MEMOIR_IMAGE_MAX_PER_CHAPTER": "2", - "MEMOIR_IMAGE_CHARS_PER_EXTRA": "99999", - "MEMOIR_IMAGE_MAX_CAP": "8", - }, + {"MEMOIR_IMAGE_ENABLED": "true", "MEMOIR_IMAGE_MAX_PER_CHAPTER": "2"}, clear=False, ): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 3) - self.assertEqual( - [asset["placeholder"] for asset in assets], - [ - "{{IMAGE:南方小镇的青石板路}}", - "{{IMAGE:奶奶坐在院子里的藤椅上}}", - "{{IMAGE:门前的老槐树}}", - ], - ) - self.assertTrue(all(asset["status"] == "completed" for asset in assets)) + self.assertEqual(assets, []) def test_initialize_chapter_images_increases_limit_for_long_content(self): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - "content": ( - ("很长的正文" * 800) - + "\n{{IMAGE:南方小镇的青石板路}}" - + "\n{{IMAGE:奶奶坐在院子里的藤椅上}}" - + "\n{{IMAGE:门前的老槐树}}" - + "\n{{IMAGE:夏夜的晒谷场}}" - ), - "images": [], - }, - )() + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" + chapter = type("ChapterStub", (), {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"})() - with unittest.mock.patch.dict( - os.environ, - { - "MEMOIR_IMAGE_ENABLED": "true", - "MEMOIR_IMAGE_MAX_PER_CHAPTER": "2", - "MEMOIR_IMAGE_CHARS_PER_EXTRA": "1000", - "MEMOIR_IMAGE_MAX_CAP": "8", - }, - clear=False, - ): + with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 4) - self.assertTrue(all(asset["status"] == "pending" for asset in assets)) + self.assertEqual(assets, []) def test_initialize_chapter_images_caps_dynamic_limit_at_max_images_cap(self): - chapter = type( - "ChapterStub", - (), - { - "id": "chapter-1", - "title": "童年的夏天", - "category": "childhood", - "content": ( - ("很长的正文" * 1600) - + "\n{{IMAGE:图1}}" - + "\n{{IMAGE:图2}}" - + "\n{{IMAGE:图3}}" - + "\n{{IMAGE:图4}}" - + "\n{{IMAGE:图5}}" - + "\n{{IMAGE:图6}}" - ), - "images": [], - }, - )() + """图片初始化已迁移到 _save_narrative_to_sections;此处为兼容 no-op""" + chapter = type("ChapterStub", (), {"id": "chapter-1", "title": "童年的夏天", "category": "childhood"})() - with unittest.mock.patch.dict( - os.environ, - { - "MEMOIR_IMAGE_ENABLED": "true", - "MEMOIR_IMAGE_MAX_PER_CHAPTER": "2", - "MEMOIR_IMAGE_CHARS_PER_EXTRA": "1000", - "MEMOIR_IMAGE_MAX_CAP": "4", - }, - clear=False, - ): + with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False): assets = initialize_chapter_images(chapter) - self.assertEqual(len(assets), 4) - self.assertEqual([asset["description"] for asset in assets], ["图1", "图2", "图3", "图4"]) + self.assertEqual(assets, []) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt index a88d850..e6415dd 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -91,54 +90,30 @@ fun MessageList( count } - // 当前对话是否已加载过消息(用于首次显示时直接定位到底部,避免从顶部再滑到底部) - var hasReceivedMessages by remember(conversationId) { mutableStateOf(false) } - LaunchedEffect(conversationId, messages) { - if (messages.isNotEmpty()) hasReceivedMessages = true + val listState = key(conversationId) { + rememberLazyListState() } - // 用 key 在「首次有消息」时重建列表状态,使 initialFirstVisibleItemIndex 生效,打开对话即显示底部 - val initialIndex = if (hasReceivedMessages && estimatedItemCount > 0) (estimatedItemCount - 1).coerceAtLeast(0) else 0 - val listState = key(conversationId, hasReceivedMessages) { - rememberLazyListState(initialFirstVisibleItemIndex = initialIndex, initialFirstVisibleItemScrollOffset = 0) - } - - // 自动滚动到底部 - 当消息变化或流式内容更新时滚动 + // reverseLayout 下 index 0 在视口底部,scrollToItem(0) 即显示最新内容 LaunchedEffect(messages.size, messages.lastOrNull()?.id, isStreaming, streamingText, isTyping) { - // 短暂延迟确保内容已渲染 + if (estimatedItemCount <= 0) return@LaunchedEffect delay(100) - - // 滚动到最后一项 - if (estimatedItemCount > 0) { - try { - listState.animateScrollToItem(estimatedItemCount - 1) - } catch (e: Exception) { - // 如果索引超出范围,尝试滚动到实际的最后一项 - val actualCount = listState.layoutInfo.totalItemsCount - if (actualCount > 0) { - listState.animateScrollToItem(actualCount - 1) - } - } - } + try { + listState.animateScrollToItem(0) + } catch (_: Exception) { } } LazyColumn( state = listState, + reverseLayout = true, modifier = modifier .fillMaxSize() .onSizeChanged { size -> val h = size.height - // 键盘弹出导致列表高度变矮时,滚到最底部,让最后一条气泡紧贴输入框上方 if (lastHeightPx > 0 && h < lastHeightPx) { scope.launch { delay(80) - val count = listState.layoutInfo.totalItemsCount - if (count > 0) { - val viewportHeight = listState.layoutInfo.viewportSize.height - // 一次平滑滚到底:用 scrollOffset 让最后一项贴底,避免两段滚动和中间停顿 - val scrollOffset = (viewportHeight - 120).coerceAtLeast(0) - listState.animateScrollToItem(count - 1, scrollOffset) - } + listState.animateScrollToItem(0) } } lastHeightPx = h @@ -146,16 +121,30 @@ fun MessageList( contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - // 时间分隔线 + // reverseLayout: 先添加的显示在底部。顺序:typing(底) -> streaming -> messages(新->旧) + if (isTyping || (isStreaming && streamingText.isEmpty())) { + item { TypingIndicator() } + } + if (isStreaming) { + val streamingParts = streamingText.split("[SPLIT]").map { it.trim() }.filter { it.isNotEmpty() } + if (streamingParts.size > 1) { + streamingParts.dropLast(1).forEachIndexed { partIndex, part -> + item(key = "streaming_complete_$partIndex") { AIMessageBubble(text = part) } + } + item(key = "streaming_message") { StreamingAIMessageBubble(text = streamingParts.last()) } + } else { + item(key = "streaming_message") { StreamingAIMessageBubble(text = streamingText) } + } + } + // 历史消息:从新到旧,最新在底部 var lastDate: Long? = null - - messages.forEachIndexed { index, message -> + messages.asReversed().forEachIndexed { index, message -> val currentDate = message.timestamp - val shouldShowDivider = lastDate == null || - (currentDate - lastDate!!) > 300000 // 5分钟间隔 + // 反向迭代:lastDate 是更新的消息,间隔>5分钟显示分隔线 + val shouldShowDivider = index > 0 && lastDate != null && (lastDate!! - currentDate) > 300000 - if (shouldShowDivider && index > 0) { - item { + if (shouldShowDivider) { + item(key = "divider_${message.id}") { TimeDivider(timestamp = currentDate) } } @@ -228,40 +217,6 @@ fun MessageList( lastDate = currentDate } - - // 流式消息显示 - 使用专门的流式消息气泡组件 - // 在 [SPLIT] 处分割流式消息 - if (isStreaming) { - val streamingParts = streamingText.split("[SPLIT]") - .map { it.trim() } - .filter { it.isNotEmpty() } - - if (streamingParts.size > 1) { - // 已完成的部分显示为普通气泡 - streamingParts.dropLast(1).forEachIndexed { partIndex, part -> - item(key = "streaming_complete_$partIndex") { - AIMessageBubble(text = part) - } - } - // 最后一部分显示为流式气泡(可能还在输入) - item(key = "streaming_message") { - StreamingAIMessageBubble(text = streamingParts.last()) - } - } else { - item(key = "streaming_message") { - StreamingAIMessageBubble(text = streamingText) - } - } - } - - // 正在输入指示器 - 显示加载动画 - // 1. 如果正在流式接收但还没有内容,显示加载动画 - // 2. 如果设置了isTyping且不在流式状态,显示加载动画 - if (isTyping || (isStreaming && streamingText.isEmpty())) { - item { - TypingIndicator() - } - } } }