1. 修复安卓部分机型顶部安全区遮挡回忆录标题的问题;
2. 降低封面图生成阈值和展示逻辑,独立封面图未生成时,使用正文图;
3. 去掉“嗯。”生硬回答,去掉不合理段首承接词;
4. 新增章节封面所需最少插图数的配置项
This commit is contained in:
yangshilin
2026-04-16 20:42:54 +08:00
parent 17b9fa3466
commit 9af2060259
15 changed files with 377 additions and 74 deletions

View File

@@ -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
# 叙事模型输出相对口述过短则回退为口述原文

View File

@@ -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
# 叙事模型输出相对口述过短则回退为口述原文

View File

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

View File

@@ -9,14 +9,17 @@ def chat_output_rules() -> str:
"`[SPLIT]`(仅此一处方括号用法);**禁止**输出全角或半角括号及其中任何内容,包括:"
"策略/舞台说明(如「(先接住情绪)」「(共情)」),以及**表演性、声效、动作描写**"
"(如「(轻轻笑)」「(笑)」「(叹气)」「(顿了顿)」「(低声)」「(咳嗽)」「(清了清嗓子)」等——对用户说话就当口播,不要剧本括注);"
"若需停顿或语气,用口语里的「嗯」「唉」或省略号自然写出,**不要**用括号包装动作或旁白"
"**禁止**以「嗯。」**起头**(含「嗯。」后立刻接任何正文——一律不得用这种停顿起手)、禁止单独成泡只有「嗯。」——生硬、像生冷打字机"
"若需停顿或语气,优先用省略号、或把承接半句直接钉在对方原词上;可用「唉」等;**避免**每条消息都以「好。」「对。」单独打头再接一大段(易像程式客服);"
"**不要**用括号包装动作或旁白;"
"思考过程或任何元注释同样**绝不可**出现在对用户说的话中;"
"主持人口吻与播报腔(「那么接下来」「让我们」「首先」「感谢您的分享」类串联或晚会导语感);"
"课文式硬切话题(「下面我们聊聊」「接下来我想了解」「换个话题」「让我们把话题转向…」等未承接就上段话的起手或硬转向);"
"推白话轮与总结腔(空泛的「听起来你…」「听起来当时…」「听起来挺…」「听你这么说…」「照你这么说…」"
"等阶段总结或程序性过渡,而非贴着对方上一轮话头半句并肩地往下长);"
"强行搭话式「这让我想起…」接**与当前画面不沾边**的自己的故事或常识,制造虚假亲密;"
"采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「嗯」「好」「明白」等),须至少有半句贴着对方原词的承接;"
"采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「好」「明白」等),须至少有半句贴着对方原词的承接;"
"连续多轮都以「好,……」「对,……」式**同一套路起句**(发语词后接泛共情),须主动轮换——尽量**直接**从对方刚说的物象、人或半句并肩起笔;"
"书面评介腔(「值得一提的是」「总的来说」「从某种意义上」);"
"空话铺垫(「这确实是个好问题」类);**以核对为名**重复对方已明确说过的基础信息(如「所以您是……对吗」「刚才您说的是……吗」),"
"对方已交代清楚的事实应直接当作前提,在其上深化、延伸或关联提问;"
@@ -31,6 +34,7 @@ def chat_voice_style() -> str:
"语气像**温暖的谈话场主持人**:口语、自然、能接住人,但心里始终为**回忆录口述**服务——"
"不是冷冰冰盘问,也不是无底洞式的日常闲聊;更像懂行的老友在帮你把故事讲清楚。"
"接话允许带一点画面感或感官细节(一两句即可,不要堆砌);对方情绪重时别让整段只剩一个字。"
"起句尽量从对方**原词或具体画面**带入;**不要**用「嗯。」开场(**含**「嗯。」后立刻接正文),也不要「好。」「对。」单独一顿再接长句当习惯起手。"
"用对方刚说的**那个具体细节**回应,不要写成泛泛的总结。"
"不要用总结腔('听起来你的童年很快乐'),要用对话腔('那种……的感觉,现在想起来都觉得……')。"
"追问优先顺着对方刚说的具体细节往里走一层,不要跳到泛泛的新问题。"

View File

@@ -438,7 +438,7 @@ def get_guided_conversation_prompt(
### 第一步:先接住——让对方觉得你真的听进了情绪与细节
- 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"
- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。
- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯。」「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。
- **跟随—沉浸**:长段叙述后,可插入**极短**一两句**并肩式画面或体感**(像朋友旁听时轻轻嘬一口气),打破纯「一问一答」节拍;须**贴着对方刚讲的物象/动作**,可用「那种…」「换我可能也会…」等**泛指****禁止**宣称自己身上发生的具体人名地名事件,**禁止**用「这让我想起…」硬接无关轶事抢戏。
- **留白**:用户抛出**强烈情绪**或**金句式人生总结**(如「爱是流动的」一类)时,允许**本轮不接任何问题**,只作更深一层的、贴着原句的共情,让情绪**淌一小会儿**;若用 `[SPLIT]`,第一泡可以**只有共情不讲题**,但仍须是**有内容的短句**(贴原词或并肩),第二泡仍可不问,或只在末尾留极轻的一句勾子,勿赶着交卷。
- 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"
@@ -467,11 +467,13 @@ def get_guided_conversation_prompt(
- 不要每轮都像第一次见面。
## 语言与文笔(隐性执行,勿念给用户听)
- **句首习惯****禁止**「嗯。」起头(**含**「嗯。」后立刻接正文,一律不要);**禁止**单独成泡只有「嗯。」。「好。」「对。」也少当每轮固定发语词;更像真人时**直接**咬对方原词往下长——短停顿用省略号或半句并肩即可。
- 长短句掺着来;能少说一个字就不堆「很、特别、真的」。
- 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。
- 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。
## 绝对不要做的
- **禁止**以「嗯。」起头,**即使**后面还有长正文也不行(不要用「嗯。」当停顿再接句);禁止单独成泡只有「嗯。」。
- 不要为了赶大纲无视用户刚露出来的情绪。
- 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。
- 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。

View File

@@ -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]

View File

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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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))

View File

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

View File

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

View File

@@ -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("abc") == "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"]

View File

@@ -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;
/** 与 ScreenHeaderreading、useSafeArea可视高度对齐,避免返回栏与首屏内容之间出现空隙 */
const headerOccupiedHeight = Math.max(insets.top, 12) + 56;
/** 与 ScreenHeaderreading、useSafeArea实际总高度一致,避免章节标题被顶栏或安全区遮挡 */
const headerOccupiedHeight = getScreenHeaderLayoutMetrics(insets, {
useSafeArea: true,
variant: 'reading',
largeText,
typography,
}).totalHeight;
const handleDeletePress = () => {
Alert.alert(

View File

@@ -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 && {