From 9af2060259f8ccd6b5982c3eb89a8bfb755f1075 Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Thu, 16 Apr 2026 20:42:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=201.=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E5=8D=93=E9=83=A8=E5=88=86=E6=9C=BA=E5=9E=8B=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8C=BA=E9=81=AE=E6=8C=A1=E5=9B=9E=E5=BF=86?= =?UTF-8?q?=E5=BD=95=E6=A0=87=E9=A2=98=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B?= =?UTF-8?q?=202.=20=E9=99=8D=E4=BD=8E=E5=B0=81=E9=9D=A2=E5=9B=BE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=98=88=E5=80=BC=E5=92=8C=E5=B1=95=E7=A4=BA=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=8B=AC=E7=AB=8B=E5=B0=81=E9=9D=A2=E5=9B=BE?= =?UTF-8?q?=E6=9C=AA=E7=94=9F=E6=88=90=E6=97=B6=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=9B=BE=EF=BC=9B=203.=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E2=80=9C=E5=97=AF=E3=80=82=E2=80=9D=E7=94=9F=E7=A1=AC=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=EF=BC=8C=E5=8E=BB=E6=8E=89=E4=B8=8D=E5=90=88=E7=90=86?= =?UTF-8?q?=E6=AE=B5=E9=A6=96=E6=89=BF=E6=8E=A5=E8=AF=8D=EF=BC=9B=204.=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=AB=A0=E8=8A=82=E5=B0=81=E9=9D=A2=E6=89=80?= =?UTF-8?q?=E9=9C=80=E6=9C=80=E5=B0=91=E6=8F=92=E5=9B=BE=E6=95=B0=E7=9A=84?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/.env.example | 2 + api/.env.production | 2 + api/.env.staging | 2 + api/app/agents/chat/output_rules.py | 8 +- api/app/agents/chat/prompts_conversation.py | 4 +- api/app/agents/chat/reply_limits.py | 28 ++++- api/app/core/config.py | 2 + api/app/features/memoir/cover_eligibility.py | 65 ++++++++--- api/app/features/memoir/helpers.py | 104 +++++++++++++----- api/app/tasks/chapter_cover_enqueue.py | 14 +-- .../test_chapter_cover_fallback_inline.py | 57 ++++++++++ ...st_cover_eligibility_effective_markdown.py | 54 +++++++++ api/tests/test_reply_segments.py | 13 +++ app-expo/src/app/(main)/chapter/[id].tsx | 18 ++- app-expo/src/components/screen-header.tsx | 78 ++++++++++--- 15 files changed, 377 insertions(+), 74 deletions(-) create mode 100644 api/tests/test_chapter_cover_fallback_inline.py create mode 100644 api/tests/test_cover_eligibility_effective_markdown.py diff --git a/api/.env.example b/api/.env.example index 9cca3f7..8c1484f 100644 --- a/api/.env.example +++ b/api/.env.example @@ -273,6 +273,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20 MEMOIR_IMAGE_PROVIDER=liblib MEMOIR_IMAGE_STYLE_DEFAULT=watercolor MEMOIR_IMAGE_SIZE_DEFAULT=1280x720 +# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(默认 1=有一张正文图即可) +MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1 # Story 正文至少多少字才生成主图 intent / 调图(0=不限制) STORY_IMAGE_MIN_BODY_CHARS=400 # 叙事模型输出相对口述过短则回退为口述原文 diff --git a/api/.env.production b/api/.env.production index 5d7a0a9..d909f42 100644 --- a/api/.env.production +++ b/api/.env.production @@ -235,6 +235,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20 MEMOIR_IMAGE_PROVIDER=liblib MEMOIR_IMAGE_STYLE_DEFAULT=watercolor MEMOIR_IMAGE_SIZE_DEFAULT=1280x720 +# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(≥1 即有一张图可出封面) +MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1 # Story 正文至少多少字才生成主图 intent / 调图(0=不限制) STORY_IMAGE_MIN_BODY_CHARS=800 # 叙事模型输出相对口述过短则回退为口述原文 diff --git a/api/.env.staging b/api/.env.staging index 1274ebb..5a8ab42 100644 --- a/api/.env.staging +++ b/api/.env.staging @@ -168,6 +168,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20 MEMOIR_IMAGE_PROVIDER=liblib MEMOIR_IMAGE_STYLE_DEFAULT=watercolor MEMOIR_IMAGE_SIZE_DEFAULT=1280x720 +# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(≥1 即有一张图可出封面) +MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1 # 可选,Liblib 返回图片域名不在默认白名单时(逗号分隔) # MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud diff --git a/api/app/agents/chat/output_rules.py b/api/app/agents/chat/output_rules.py index 9aa7376..cbfeece 100644 --- a/api/app/agents/chat/output_rules.py +++ b/api/app/agents/chat/output_rules.py @@ -9,14 +9,17 @@ def chat_output_rules() -> str: "`[SPLIT]`(仅此一处方括号用法);**禁止**输出全角或半角括号及其中任何内容,包括:" "策略/舞台说明(如「(先接住情绪)」「(共情)」),以及**表演性、声效、动作描写**" "(如「(轻轻笑)」「(笑)」「(叹气)」「(顿了顿)」「(低声)」「(咳嗽)」「(清了清嗓子)」等——对用户说话就当口播,不要剧本括注);" - "若需停顿或语气,用口语里的「嗯」「唉」或省略号自然写出,**不要**用括号包装动作或旁白;" + "**禁止**以「嗯。」**起头**(含「嗯。」后立刻接任何正文——一律不得用这种停顿起手)、禁止单独成泡只有「嗯。」——生硬、像生冷打字机;" + "若需停顿或语气,优先用省略号、或把承接半句直接钉在对方原词上;可用「唉」等;**避免**每条消息都以「好。」「对。」单独打头再接一大段(易像程式客服);" + "**不要**用括号包装动作或旁白;" "思考过程或任何元注释同样**绝不可**出现在对用户说的话中;" "主持人口吻与播报腔(「那么接下来」「让我们」「首先」「感谢您的分享」类串联或晚会导语感);" "课文式硬切话题(「下面我们聊聊」「接下来我想了解」「换个话题」「让我们把话题转向…」等未承接就上段话的起手或硬转向);" "推白话轮与总结腔(空泛的「听起来你…」「听起来当时…」「听起来挺…」「听你这么说…」「照你这么说…」" "等阶段总结或程序性过渡,而非贴着对方上一轮话头半句并肩地往下长);" "强行搭话式「这让我想起…」接**与当前画面不沾边**的自己的故事或常识,制造虚假亲密;" - "采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「嗯」「好」「明白」等),须至少有半句贴着对方原词的承接;" + "采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「好」「明白」等),须至少有半句贴着对方原词的承接;" + "连续多轮都以「好,……」「对,……」式**同一套路起句**(发语词后接泛共情),须主动轮换——尽量**直接**从对方刚说的物象、人或半句并肩起笔;" "书面评介腔(「值得一提的是」「总的来说」「从某种意义上」);" "空话铺垫(「这确实是个好问题」类);**以核对为名**重复对方已明确说过的基础信息(如「所以您是……对吗」「刚才您说的是……吗」)," "对方已交代清楚的事实应直接当作前提,在其上深化、延伸或关联提问;" @@ -31,6 +34,7 @@ def chat_voice_style() -> str: "语气像**温暖的谈话场主持人**:口语、自然、能接住人,但心里始终为**回忆录口述**服务——" "不是冷冰冰盘问,也不是无底洞式的日常闲聊;更像懂行的老友在帮你把故事讲清楚。" "接话允许带一点画面感或感官细节(一两句即可,不要堆砌);对方情绪重时别让整段只剩一个字。" + "起句尽量从对方**原词或具体画面**带入;**不要**用「嗯。」开场(**含**「嗯。」后立刻接正文),也不要「好。」「对。」单独一顿再接长句当习惯起手。" "用对方刚说的**那个具体细节**回应,不要写成泛泛的总结。" "不要用总结腔('听起来你的童年很快乐'),要用对话腔('那种……的感觉,现在想起来都觉得……')。" "追问优先顺着对方刚说的具体细节往里走一层,不要跳到泛泛的新问题。" diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index a2c358d..7f7e9db 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -438,7 +438,7 @@ def get_guided_conversation_prompt( ### 第一步:先接住——让对方觉得你真的听进了情绪与细节 - 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"。 -- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`:**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。 +- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯。」「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`:**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。 - **跟随—沉浸**:长段叙述后,可插入**极短**一两句**并肩式画面或体感**(像朋友旁听时轻轻嘬一口气),打破纯「一问一答」节拍;须**贴着对方刚讲的物象/动作**,可用「那种…」「换我可能也会…」等**泛指**,**禁止**宣称自己身上发生的具体人名地名事件,**禁止**用「这让我想起…」硬接无关轶事抢戏。 - **留白**:用户抛出**强烈情绪**或**金句式人生总结**(如「爱是流动的」一类)时,允许**本轮不接任何问题**,只作更深一层的、贴着原句的共情,让情绪**淌一小会儿**;若用 `[SPLIT]`,第一泡可以**只有共情不讲题**,但仍须是**有内容的短句**(贴原词或并肩),第二泡仍可不问,或只在末尾留极轻的一句勾子,勿赶着交卷。 - 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"。 @@ -467,11 +467,13 @@ def get_guided_conversation_prompt( - 不要每轮都像第一次见面。 ## 语言与文笔(隐性执行,勿念给用户听) +- **句首习惯**:**禁止**「嗯。」起头(**含**「嗯。」后立刻接正文,一律不要);**禁止**单独成泡只有「嗯。」。「好。」「对。」也少当每轮固定发语词;更像真人时**直接**咬对方原词往下长——短停顿用省略号或半句并肩即可。 - 长短句掺着来;能少说一个字就不堆「很、特别、真的」。 - 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。 - 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。 ## 绝对不要做的 +- **禁止**以「嗯。」起头,**即使**后面还有长正文也不行(不要用「嗯。」当停顿再接句);禁止单独成泡只有「嗯。」。 - 不要为了赶大纲无视用户刚露出来的情绪。 - 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。 - 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。 diff --git a/api/app/agents/chat/reply_limits.py b/api/app/agents/chat/reply_limits.py index 89db164..5d8761a 100644 --- a/api/app/agents/chat/reply_limits.py +++ b/api/app/agents/chat/reply_limits.py @@ -62,6 +62,19 @@ def strip_parenthetical_asides_for_chat(text: str) -> str: return s.strip() +def strip_leading_en_period_ack_for_chat(text: str) -> str: + """ + 去掉段首生硬的「嗯。」(可重复),即使后面还有正文;只剥字符串开头,不误伤句中「嗯。」。 + 支持全角/半角句号。 + """ + s = (text or "").strip() + if not s: + return s + # 允许多次「嗯。」/「嗯嗯。」叠在段首;句号仅匹配全角 。、. 与 ASCII `.` + s2 = re.sub(r"^(?:嗯+(?:。|.|\.)+\s*)+", "", s) + return s2.strip() + + def segments_from_llm_response( response_text: str, *, @@ -76,13 +89,22 @@ def segments_from_llm_response( text = strip_parenthetical_asides_for_chat(text) if not text: return [] - primary = [p.strip() for p in text.split("[SPLIT]") if p.strip()] + primary = [ + strip_leading_en_period_ack_for_chat(p) + for p in text.split("[SPLIT]") + if strip_leading_en_period_ack_for_chat(p).strip() + ] if len(primary) > 1: return primary[:max_segments] - blob = primary[0] if primary else text + blob = primary[0] if primary else strip_leading_en_period_ack_for_chat(text) + blob = strip_leading_en_period_ack_for_chat(blob) if "\n" not in blob: return [blob] - paras = [p.strip() for p in re.split(r"\n\s*\n+", blob) if p.strip()] + paras = [ + strip_leading_en_period_ack_for_chat(p) + for p in re.split(r"\n\s*\n+", blob) + if strip_leading_en_period_ack_for_chat(p).strip() + ] if len(paras) < 2: return [blob] paras = [p for p in paras if len(p) >= min_paragraph_chars] diff --git a/api/app/core/config.py b/api/app/core/config.py index c868f02..f0595b5 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -264,6 +264,8 @@ class Settings(BaseSettings): memoir_image_style_default: str = "watercolor" memoir_image_size_default: str = "1280x720" memoir_image_download_hosts: str = "" + # 章节 canonical_markdown 中至少含多少张 asset:// 正文插图才生成/展示章节封面(≥ 该值即满足;0 表示不以此条件拦截) + memoir_min_inline_images_for_chapter_cover: int = Field(default=1, ge=0, le=100) # Story 正文至少多少字才创建主图 intent / 调图(0 表示不限制) story_image_min_body_chars: int = 400 # generate_story_image 入队去重(Redis SET NX,秒) diff --git a/api/app/features/memoir/cover_eligibility.py b/api/app/features/memoir/cover_eligibility.py index fffd6f8..16a09db 100644 --- a/api/app/features/memoir/cover_eligibility.py +++ b/api/app/features/memoir/cover_eligibility.py @@ -4,15 +4,16 @@ from __future__ import annotations from typing import Any -from app.features.memoir.asset_resolver import parse_asset_refs +from app.core.config import settings +from app.features.memoir.asset_resolver import ( + parse_asset_refs, + strip_image_placeholders, +) from app.features.memoir.memoir_images.schema import ( IMAGE_STATUS_FAILED, IMAGE_STATUS_PENDING, ) -# 正文内 ![...](asset://...) 数量需 **大于** 此值才生成/展示章节封面(与故事头图、正文配图任务独立) -MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER = 3 - def chapter_has_story_links(chapter: Any) -> bool: return any( @@ -21,18 +22,47 @@ def chapter_has_story_links(chapter: Any) -> bool: ) -def count_chapter_inline_body_images(chapter: Any) -> int: - """统计章节 canonical_markdown 中正文插图(asset:// 图片引用)次数。""" - md = getattr(chapter, "canonical_markdown", None) or "" - return len(parse_asset_refs(md)) +def effective_chapter_markdown_for_cover_gates(chapter: Any) -> str: + """ + 用于封面闸门计数:优先 DB canonical;若为空且已挂 stories,则用内存物化串 + (与列表/详情在 compose_dirty 时的临时正文对齐,避免「有图但 canonical 未落库」导致永不出封面)。 + """ + md = (getattr(chapter, "canonical_markdown", None) or "").strip() + if md: + return md + if chapter_has_story_links(chapter): + from app.features.memoir.chapter_markdown_compose import ( + materialize_chapter_markdown_from_loaded_chapter, + ) + + try: + alt = ( + materialize_chapter_markdown_from_loaded_chapter(chapter) or "" + ).strip() + except Exception: + return "" + return alt + return "" -def chapter_eligible_for_cover_by_inline_body_image_count(chapter: Any) -> bool: - """仅当正文内插图数量 > MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER 时才生成/展示章节封面。""" - return ( - count_chapter_inline_body_images(chapter) - > MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER +def count_chapter_inline_body_images( + chapter: Any, *, markdown: str | None = None +) -> int: + """统计 asset:// 插图次数;未传 markdown 时用 effective_chapter_markdown_for_cover_gates。""" + source = ( + markdown + if markdown is not None + else effective_chapter_markdown_for_cover_gates(chapter) ) + return len(parse_asset_refs(source)) + + +def chapter_eligible_for_cover_by_inline_body_image_count( + chapter: Any, *, markdown: str | None = None +) -> bool: + """正文内 asset:// 数量 ≥ 配置阈值时允许封面;markdown 非 None 时仅用该串计数。""" + min_required = int(settings.memoir_min_inline_images_for_chapter_cover) + return count_chapter_inline_body_images(chapter, markdown=markdown) >= min_required def primary_chapter_memoir_image(chapter: Any) -> Any | None: @@ -45,17 +75,18 @@ def primary_chapter_memoir_image(chapter: Any) -> Any | None: def chapter_needs_cover_enqueue(chapter) -> bool: - """尚无 cover_asset、有正文、且正文内 asset 插图多于阈值时,可派发 generate_chapter_cover。""" + """尚无 cover_asset、有正文、且正文内 asset 插图达到 env 阈值时,可派发 generate_chapter_cover。""" if not chapter: return False if not chapter_has_story_links(chapter): return False if getattr(chapter, "cover_asset_id", None): return False - md = (getattr(chapter, "canonical_markdown", None) or "").strip() - if not md: + view = effective_chapter_markdown_for_cover_gates(chapter) + body = strip_image_placeholders(view).strip() + if not body: return False - return chapter_eligible_for_cover_by_inline_body_image_count(chapter) + return chapter_eligible_for_cover_by_inline_body_image_count(chapter, markdown=view) def chapter_has_cover_to_generate(chapter) -> bool: diff --git a/api/app/features/memoir/helpers.py b/api/app/features/memoir/helpers.py index f048d30..1dbb089 100644 --- a/api/app/features/memoir/helpers.py +++ b/api/app/features/memoir/helpers.py @@ -2,9 +2,13 @@ from app.core.config import settings from app.core.logging import get_logger -from app.features.memoir.asset_resolver import resolve_asset_refs_in_markdown +from app.features.memoir.asset_resolver import ( + collect_asset_ids_from_markdown, + resolve_asset_refs_in_markdown, +) from app.features.memoir.cover_eligibility import ( chapter_eligible_for_cover_by_inline_body_image_count, + chapter_has_story_links, primary_chapter_memoir_image, ) from app.features.memoir.memoir_images.schema import ( @@ -89,36 +93,82 @@ def is_image_permanently_unavailable(rec) -> bool: return False +def _markdown_for_cover_asset_gate( + ch: Chapter, *, markdown_for_response: str | None = None +) -> str: + """用于封面闸门与首张 asset 回落:canonical / override、物化 stories、分段快照 body 合并(去重 asset 计数由 parse 完成)。""" + md = _chapter_markdown(ch, override=markdown_for_response) + parts: list[str] = [] + if (md or "").strip(): + parts.append(md.strip()) + elif chapter_has_story_links(ch): + from app.features.memoir.chapter_markdown_compose import ( + materialize_chapter_markdown_from_loaded_chapter, + ) + + alt = (materialize_chapter_markdown_from_loaded_chapter(ch) or "").strip() + if alt: + parts.append(alt) + # 仅走 reading_segments 时:DB canonical 可能未写回或不含 asset://,从快照段补图 + for row in getattr(ch, "reading_segments_json", None) or []: + b = (row.get("body_markdown") or "").strip() + if b: + parts.append(b) + return "\n\n".join(parts) if parts else (md or "") + + +def _synthetic_cover_asset_dict(url: str, *, description: str) -> dict: + """列表/详情用:无 MemoirImage 行时,用 COS 签名 URL 拼一条 completed 封面 dict。""" + return { + "placeholder": "", + "description": description, + "index": 0, + "status": IMAGE_STATUS_COMPLETED, + "prompt": None, + "url": url, + "storage_key": None, + "provider": None, + "style": None, + "size": None, + "error": None, + "retryable": None, + "created_at": None, + "updated_at": None, + } + + def chapter_cover_to_dict( - ch: Chapter, asset_url_map: dict[str, str] | None = None + ch: Chapter, + asset_url_map: dict[str, str] | None = None, + *, + markdown_for_response: str | None = None, ) -> dict | None: - if not chapter_eligible_for_cover_by_inline_body_image_count(ch): + view_md = _markdown_for_cover_asset_gate( + ch, markdown_for_response=markdown_for_response + ) + if not chapter_eligible_for_cover_by_inline_body_image_count(ch, markdown=view_md): return None + asset_url_map = asset_url_map or {} + + # 1) 独立章节封面(Celery generate_chapter_cover 写入的 cover_asset_id) + aid = getattr(ch, "cover_asset_id", None) + if aid and asset_url_map.get(str(aid)): + return _synthetic_cover_asset_dict( + asset_url_map[str(aid)], description="章节封面" + ) + + # 2) 尚无独立封面时:用正文里首张 asset:// 的 URL 作卡片封面(故事主图已在正文内) + for asset_id in collect_asset_ids_from_markdown(view_md): + u = asset_url_map.get(str(asset_id)) + if u: + return _synthetic_cover_asset_dict(u, description="章节封面") + + # 3) 兼容旧数据:章节级 MemoirImage 首行 m = primary_chapter_memoir_image(ch) if m and is_image_permanently_unavailable(m): m = None if m: return memoir_image_to_dict(m) - asset_url_map = asset_url_map or {} - aid = getattr(ch, "cover_asset_id", None) - if aid and asset_url_map.get(str(aid)): - url = asset_url_map[str(aid)] - return { - "placeholder": "", - "description": "章节封面", - "index": 0, - "status": IMAGE_STATUS_COMPLETED, - "prompt": None, - "url": url, - "storage_key": None, - "provider": None, - "style": None, - "size": None, - "error": None, - "retryable": None, - "created_at": None, - "updated_at": None, - } return None @@ -139,7 +189,9 @@ def chapter_to_list_dict( markdown_for_response: str | None = None, ) -> dict: """列表视图:与详情字段对齐的最小子集。""" - cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map) + cover = chapter_cover_to_dict( + ch, asset_url_map=asset_url_map, markdown_for_response=markdown_for_response + ) cover_normalized = first_normalized_image_for_api(cover) canonical_raw = _chapter_markdown(ch, override=markdown_for_response) wcount = len(canonical_raw.strip()) if canonical_raw else 0 @@ -170,7 +222,9 @@ def chapter_to_dict( asset_url_map = asset_url_map or {} resolve = lambda aid: asset_url_map.get(aid) # noqa: E731 - cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map) + cover = chapter_cover_to_dict( + ch, asset_url_map=asset_url_map, markdown_for_response=markdown_for_response + ) cover_normalized = first_normalized_image_for_api(cover) # 正文真源:优先 canonical_markdown canonical_md = _chapter_markdown(ch, override=markdown_for_response) diff --git a/api/app/tasks/chapter_cover_enqueue.py b/api/app/tasks/chapter_cover_enqueue.py index ce1173a..4fec856 100644 --- a/api/app/tasks/chapter_cover_enqueue.py +++ b/api/app/tasks/chapter_cover_enqueue.py @@ -18,6 +18,7 @@ from app.features.memoir.cover_eligibility import ( chapter_eligible_for_cover_by_inline_body_image_count, chapter_has_story_links, chapter_needs_cover_enqueue, + effective_chapter_markdown_for_cover_gates, primary_chapter_memoir_image, ) from app.features.memoir.models import Chapter, ChapterStoryLink @@ -42,14 +43,13 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool: return False if getattr(chapter, "cover_asset_id", None): return False - md = (chapter.canonical_markdown or "").strip() - body = md or "" - if not body.strip(): - return False - body = strip_image_placeholders(body).strip() + view = effective_chapter_markdown_for_cover_gates(chapter) + body = strip_image_placeholders(view).strip() if not body: return False - if not chapter_eligible_for_cover_by_inline_body_image_count(chapter): + if not chapter_eligible_for_cover_by_inline_body_image_count( + chapter, markdown=view + ): return False cover_rec = primary_chapter_memoir_image(chapter) if cover_rec and (cover_rec.status or "").strip() == "completed": @@ -58,7 +58,7 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool: def _chapter_eligible_for_pipeline_enqueue(chapter: Chapter | None) -> bool: - """尚无 cover_asset、正文插图数 > 3(与 HTTP 闸门共用 chapter_needs_cover_enqueue 核心)。""" + """尚无 cover_asset、正文插图数达 MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER(与 HTTP 闸门共用)。""" return bool(chapter_needs_cover_enqueue(chapter)) diff --git a/api/tests/test_chapter_cover_fallback_inline.py b/api/tests/test_chapter_cover_fallback_inline.py new file mode 100644 index 0000000..c4dbecc --- /dev/null +++ b/api/tests/test_chapter_cover_fallback_inline.py @@ -0,0 +1,57 @@ +"""章节封面:无 cover_asset_id 时用正文首张 asset:// 作列表封面。""" + +from unittest.mock import MagicMock + +from app.features.memoir.helpers import chapter_cover_to_dict + + +def test_cover_falls_back_to_first_inline_asset_url() -> None: + ch = MagicMock(spec=[]) + ch.canonical_markdown = "正文\n\n![场景](asset://img-1)" + ch.cover_asset_id = None + ch.story_links = [] + ch.images = [] + m = chapter_cover_to_dict( + ch, + asset_url_map={"img-1": "https://cos.example.com/signed-1"}, + markdown_for_response=None, + ) + assert m is not None + assert m["url"] == "https://cos.example.com/signed-1" + assert m["status"] == "completed" + + +def test_cover_from_reading_segments_when_canonical_has_no_asset() -> None: + """分段快照里有 asset://,章节 canonical 未带图时仍能出封面 URL。""" + ch = MagicMock(spec=[]) + ch.canonical_markdown = "只有文字没有图" * 20 + ch.cover_asset_id = None + ch.story_links = [] + ch.images = [] + ch.reading_segments_json = [ + {"story_id": "s1", "body_markdown": "![景](asset://seg-1)"} + ] + m = chapter_cover_to_dict( + ch, + asset_url_map={"seg-1": "https://cos.example.com/seg"}, + markdown_for_response=None, + ) + assert m is not None + assert m["url"] == "https://cos.example.com/seg" + + +def test_cover_prefers_cover_asset_id_over_inline() -> None: + ch = MagicMock(spec=[]) + ch.canonical_markdown = "![a](asset://inline-1)" + ch.cover_asset_id = "cover-99" + ch.story_links = [] + ch.images = [] + m = chapter_cover_to_dict( + ch, + asset_url_map={ + "inline-1": "https://cos.example.com/inline", + "cover-99": "https://cos.example.com/cover", + }, + markdown_for_response=None, + ) + assert m["url"] == "https://cos.example.com/cover" diff --git a/api/tests/test_cover_eligibility_effective_markdown.py b/api/tests/test_cover_eligibility_effective_markdown.py new file mode 100644 index 0000000..363d068 --- /dev/null +++ b/api/tests/test_cover_eligibility_effective_markdown.py @@ -0,0 +1,54 @@ +"""封面闸门:canonical 未落库时须用物化正文计数 asset://。""" + +from unittest.mock import MagicMock, patch + +from app.features.memoir.cover_eligibility import ( + chapter_eligible_for_cover_by_inline_body_image_count, + chapter_needs_cover_enqueue, + count_chapter_inline_body_images, + effective_chapter_markdown_for_cover_gates, +) + + +def test_effective_markdown_falls_back_to_materialize_when_canonical_empty() -> None: + ch = MagicMock() + ch.canonical_markdown = "" + ch.story_links = [MagicMock()] + with patch( + "app.features.memoir.chapter_markdown_compose.materialize_chapter_markdown_from_loaded_chapter", + return_value="正文\n\n![x](asset://a1)", + ): + assert "asset://" in effective_chapter_markdown_for_cover_gates(ch) + + +def test_count_uses_effective_when_canonical_empty() -> None: + ch = MagicMock() + ch.canonical_markdown = "" + ch.story_links = [MagicMock()] + with patch( + "app.features.memoir.chapter_markdown_compose.materialize_chapter_markdown_from_loaded_chapter", + return_value="![alt](asset://id1)", + ): + assert count_chapter_inline_body_images(ch) == 1 + + +def test_eligible_with_explicit_markdown_override() -> None: + ch = MagicMock() + ch.canonical_markdown = "" + assert chapter_eligible_for_cover_by_inline_body_image_count( + ch, markdown="![a](asset://x)" + ) + + +def test_needs_cover_enqueue_uses_materialized_body() -> None: + ch = MagicMock() + ch.canonical_markdown = "" + ch.cover_asset_id = None + ch.story_links = [MagicMock(story=MagicMock())] + link = ch.story_links[0] + link.story = MagicMock() + with patch( + "app.features.memoir.chapter_markdown_compose.materialize_chapter_markdown_from_loaded_chapter", + return_value="故事\n\n![a](asset://z)", + ): + assert chapter_needs_cover_enqueue(ch) is True diff --git a/api/tests/test_reply_segments.py b/api/tests/test_reply_segments.py index 4caeb7c..410a92c 100644 --- a/api/tests/test_reply_segments.py +++ b/api/tests/test_reply_segments.py @@ -3,6 +3,7 @@ from app.agents.chat.reply_limits import ( nonempty_segments_or_fallback, segments_from_llm_response, + strip_leading_en_period_ack_for_chat, strip_markdown_for_chat, strip_parenthetical_asides_for_chat, ) @@ -58,3 +59,15 @@ def test_segments_strip_parentheticals_before_split(): def test_strip_parenthetical_multiple_passes(): assert strip_parenthetical_asides_for_chat("a(一)b(二)c") == "abc" + + +def test_strip_leading_en_period_ack(): + assert strip_leading_en_period_ack_for_chat("嗯。后面正文") == "后面正文" + assert strip_leading_en_period_ack_for_chat("嗯嗯。后面") == "后面" + assert strip_leading_en_period_ack_for_chat(" 嗯。 第二句") == "第二句" + assert strip_leading_en_period_ack_for_chat("句中嗯。不打头") == "句中嗯。不打头" + + +def test_segments_strip_leading_en_ack(): + assert segments_from_llm_response("嗯。只有一句", max_segments=3) == ["只有一句"] + assert segments_from_llm_response("嗯。A[SPLIT]嗯。B", max_segments=3) == ["A", "B"] diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index c6ad3a2..43608dd 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -18,8 +18,13 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; -import { ScreenHeader } from '@/components/screen-header'; +import { + getScreenHeaderLayoutMetrics, + ScreenHeader, +} from '@/components/screen-header'; import { ScreenGutter } from '@/constants/layout'; +import { useTypography } from '@/core/typography-context'; +import { useAppSettings } from '@/hooks/use-app-settings'; import { MarkdownRenderer, ReadingMarkdownHorizontalRuleInColumn, @@ -353,6 +358,8 @@ function ReadingSettingsModal({ export default function ChapterScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); + const { largeText } = useAppSettings(); + const typography = useTypography(); const { width } = useWindowDimensions(); const { t } = useTranslation('memoir'); const { data: chapter, isLoading } = useChapterDetail(id ?? ''); @@ -429,8 +436,13 @@ export default function ChapterScreen() { const useReadingSegments = Array.isArray(readingSegments) && readingSegments.length > 0; - /** 与 ScreenHeader(reading、useSafeArea)可视高度对齐,避免返回栏与首屏内容之间出现空隙 */ - const headerOccupiedHeight = Math.max(insets.top, 12) + 56; + /** 与 ScreenHeader(reading、useSafeArea)实际总高度一致,避免章节标题被顶栏或安全区遮挡 */ + const headerOccupiedHeight = getScreenHeaderLayoutMetrics(insets, { + useSafeArea: true, + variant: 'reading', + largeText, + typography, + }).totalHeight; const handleDeletePress = () => { Alert.alert( diff --git a/app-expo/src/components/screen-header.tsx b/app-expo/src/components/screen-header.tsx index 1f5b376..0d7c69a 100644 --- a/app-expo/src/components/screen-header.tsx +++ b/app-expo/src/components/screen-header.tsx @@ -9,6 +9,8 @@ import { useTypography } from '@/core/typography-context'; import { ScreenGutter } from '@/constants/layout'; import { useAppSettings } from '@/hooks/use-app-settings'; +import type { TypographyTokens } from '@/core/typography-context'; + /** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */ const BACK_HIT_MIN = 48; const BACK_HIT_MIN_LARGE = 56; @@ -18,6 +20,52 @@ const BACK_EXTRA_HIT_LEFT = 4; export type ScreenHeaderVariant = 'default' | 'chat' | 'reading'; +export type ScreenHeaderLayoutOpts = { + useSafeArea: boolean; + variant: ScreenHeaderVariant; + largeText: boolean; + typography: TypographyTokens; +}; + +/** + * 与组件内布局一致的总高度(含顶部安全区内边距),供绝对定位顶栏下的 ScrollView paddingTop 等使用。 + */ +export function getScreenHeaderLayoutMetrics( + insets: { top: number }, + opts: ScreenHeaderLayoutOpts, +) { + const barPaddingBottom = opts.largeText ? 18 : 16; + const titleRowPaddingV = opts.largeText ? 8 : 4; + const backTouchMin = opts.largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN; + const { variant, typography, largeText } = opts; + const titleFontSize = + variant === 'chat' + ? largeText + ? typography.headingMedium + : typography.headingSmall + : Math.max(typography.titleLarge, typography.headingSmall); + const titleLineMin = + variant === 'chat' + ? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) + + (largeText ? 14 : 10) + : Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8); + const titleRowMinHeight = Math.max(backTouchMin, titleLineMin); + const titleRowOuterHeight = titleRowMinHeight + 2 * titleRowPaddingV; + const paddingTop = opts.useSafeArea ? Math.max(insets.top, 12) : 12; + const totalHeight = paddingTop + titleRowOuterHeight + barPaddingBottom; + return { + barPaddingBottom, + titleRowPaddingV, + titleFontSize, + titleLineMin, + titleRowMinHeight, + titleRowOuterHeight, + paddingTop, + totalHeight, + backTouchMin, + }; +} + const VARIANT_COLORS = { default: { title: undefined, // use theme foreground @@ -76,7 +124,6 @@ export function ScreenHeader({ const typography = useTypography(); const colors = VARIANT_COLORS[variant]; - const backTouchMin = largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN; const backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE; const handleBack = onBack ?? (() => router.back()); @@ -88,27 +135,26 @@ export function ScreenHeader({ * 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。 * 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。 */ - const barPaddingBottom = largeText ? 18 : 16; - const titleRowPaddingV = largeText ? 8 : 4; - const titleFontSize = - variant === 'chat' - ? largeText - ? typography.headingMedium - : typography.headingSmall - : Math.max(typography.titleLarge, typography.headingSmall); - const titleLineMin = - variant === 'chat' - ? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) + - (largeText ? 14 : 10) - : Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8); - const titleRowMinHeight = Math.max(backTouchMin, titleLineMin); + const { + barPaddingBottom, + titleRowPaddingV, + titleFontSize, + titleRowMinHeight, + paddingTop: headerPaddingTop, + backTouchMin, + } = getScreenHeaderLayoutMetrics(insets, { + useSafeArea, + variant, + largeText, + typography, + }); const containerStyle = { flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'space-between' as const, paddingHorizontal: Math.max(ScreenGutter, 16), - paddingTop: useSafeArea ? Math.max(insets.top, 12) : 12, + paddingTop: headerPaddingTop, paddingBottom: barPaddingBottom, ...(bgColor && { backgroundColor: bgColor }), ...(absolute && {