fix:
1. 修复安卓部分机型顶部安全区遮挡回忆录标题的问题; 2. 降低封面图生成阈值和展示逻辑,独立封面图未生成时,使用正文图; 3. 去掉“嗯。”生硬回答,去掉不合理段首承接词; 4. 新增章节封面所需最少插图数的配置项
This commit is contained in:
@@ -273,6 +273,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20
|
|||||||
MEMOIR_IMAGE_PROVIDER=liblib
|
MEMOIR_IMAGE_PROVIDER=liblib
|
||||||
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
||||||
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
||||||
|
# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(默认 1=有一张正文图即可)
|
||||||
|
MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1
|
||||||
# Story 正文至少多少字才生成主图 intent / 调图(0=不限制)
|
# Story 正文至少多少字才生成主图 intent / 调图(0=不限制)
|
||||||
STORY_IMAGE_MIN_BODY_CHARS=400
|
STORY_IMAGE_MIN_BODY_CHARS=400
|
||||||
# 叙事模型输出相对口述过短则回退为口述原文
|
# 叙事模型输出相对口述过短则回退为口述原文
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20
|
|||||||
MEMOIR_IMAGE_PROVIDER=liblib
|
MEMOIR_IMAGE_PROVIDER=liblib
|
||||||
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
||||||
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
||||||
|
# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(≥1 即有一张图可出封面)
|
||||||
|
MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1
|
||||||
# Story 正文至少多少字才生成主图 intent / 调图(0=不限制)
|
# Story 正文至少多少字才生成主图 intent / 调图(0=不限制)
|
||||||
STORY_IMAGE_MIN_BODY_CHARS=800
|
STORY_IMAGE_MIN_BODY_CHARS=800
|
||||||
# 叙事模型输出相对口述过短则回退为口述原文
|
# 叙事模型输出相对口述过短则回退为口述原文
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20
|
|||||||
MEMOIR_IMAGE_PROVIDER=liblib
|
MEMOIR_IMAGE_PROVIDER=liblib
|
||||||
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
||||||
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
||||||
|
# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(≥1 即有一张图可出封面)
|
||||||
|
MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1
|
||||||
# 可选,Liblib 返回图片域名不在默认白名单时(逗号分隔)
|
# 可选,Liblib 返回图片域名不在默认白名单时(逗号分隔)
|
||||||
# MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud
|
# MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ def chat_output_rules() -> str:
|
|||||||
"`[SPLIT]`(仅此一处方括号用法);**禁止**输出全角或半角括号及其中任何内容,包括:"
|
"`[SPLIT]`(仅此一处方括号用法);**禁止**输出全角或半角括号及其中任何内容,包括:"
|
||||||
"策略/舞台说明(如「(先接住情绪)」「(共情)」),以及**表演性、声效、动作描写**"
|
"策略/舞台说明(如「(先接住情绪)」「(共情)」),以及**表演性、声效、动作描写**"
|
||||||
"(如「(轻轻笑)」「(笑)」「(叹气)」「(顿了顿)」「(低声)」「(咳嗽)」「(清了清嗓子)」等——对用户说话就当口播,不要剧本括注);"
|
"(如「(轻轻笑)」「(笑)」「(叹气)」「(顿了顿)」「(低声)」「(咳嗽)」「(清了清嗓子)」等——对用户说话就当口播,不要剧本括注);"
|
||||||
"若需停顿或语气,用口语里的「嗯」「唉」或省略号自然写出,**不要**用括号包装动作或旁白;"
|
"**禁止**以「嗯。」**起头**(含「嗯。」后立刻接任何正文——一律不得用这种停顿起手)、禁止单独成泡只有「嗯。」——生硬、像生冷打字机;"
|
||||||
|
"若需停顿或语气,优先用省略号、或把承接半句直接钉在对方原词上;可用「唉」等;**避免**每条消息都以「好。」「对。」单独打头再接一大段(易像程式客服);"
|
||||||
|
"**不要**用括号包装动作或旁白;"
|
||||||
"思考过程或任何元注释同样**绝不可**出现在对用户说的话中;"
|
"思考过程或任何元注释同样**绝不可**出现在对用户说的话中;"
|
||||||
"主持人口吻与播报腔(「那么接下来」「让我们」「首先」「感谢您的分享」类串联或晚会导语感);"
|
"主持人口吻与播报腔(「那么接下来」「让我们」「首先」「感谢您的分享」类串联或晚会导语感);"
|
||||||
"课文式硬切话题(「下面我们聊聊」「接下来我想了解」「换个话题」「让我们把话题转向…」等未承接就上段话的起手或硬转向);"
|
"课文式硬切话题(「下面我们聊聊」「接下来我想了解」「换个话题」「让我们把话题转向…」等未承接就上段话的起手或硬转向);"
|
||||||
"推白话轮与总结腔(空泛的「听起来你…」「听起来当时…」「听起来挺…」「听你这么说…」「照你这么说…」"
|
"推白话轮与总结腔(空泛的「听起来你…」「听起来当时…」「听起来挺…」「听你这么说…」「照你这么说…」"
|
||||||
"等阶段总结或程序性过渡,而非贴着对方上一轮话头半句并肩地往下长);"
|
"等阶段总结或程序性过渡,而非贴着对方上一轮话头半句并肩地往下长);"
|
||||||
"强行搭话式「这让我想起…」接**与当前画面不沾边**的自己的故事或常识,制造虚假亲密;"
|
"强行搭话式「这让我想起…」接**与当前画面不沾边**的自己的故事或常识,制造虚假亲密;"
|
||||||
"采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「嗯」「好」「明白」等),须至少有半句贴着对方原词的承接;"
|
"采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「好」「明白」等),须至少有半句贴着对方原词的承接;"
|
||||||
|
"连续多轮都以「好,……」「对,……」式**同一套路起句**(发语词后接泛共情),须主动轮换——尽量**直接**从对方刚说的物象、人或半句并肩起笔;"
|
||||||
"书面评介腔(「值得一提的是」「总的来说」「从某种意义上」);"
|
"书面评介腔(「值得一提的是」「总的来说」「从某种意义上」);"
|
||||||
"空话铺垫(「这确实是个好问题」类);**以核对为名**重复对方已明确说过的基础信息(如「所以您是……对吗」「刚才您说的是……吗」),"
|
"空话铺垫(「这确实是个好问题」类);**以核对为名**重复对方已明确说过的基础信息(如「所以您是……对吗」「刚才您说的是……吗」),"
|
||||||
"对方已交代清楚的事实应直接当作前提,在其上深化、延伸或关联提问;"
|
"对方已交代清楚的事实应直接当作前提,在其上深化、延伸或关联提问;"
|
||||||
@@ -31,6 +34,7 @@ def chat_voice_style() -> str:
|
|||||||
"语气像**温暖的谈话场主持人**:口语、自然、能接住人,但心里始终为**回忆录口述**服务——"
|
"语气像**温暖的谈话场主持人**:口语、自然、能接住人,但心里始终为**回忆录口述**服务——"
|
||||||
"不是冷冰冰盘问,也不是无底洞式的日常闲聊;更像懂行的老友在帮你把故事讲清楚。"
|
"不是冷冰冰盘问,也不是无底洞式的日常闲聊;更像懂行的老友在帮你把故事讲清楚。"
|
||||||
"接话允许带一点画面感或感官细节(一两句即可,不要堆砌);对方情绪重时别让整段只剩一个字。"
|
"接话允许带一点画面感或感官细节(一两句即可,不要堆砌);对方情绪重时别让整段只剩一个字。"
|
||||||
|
"起句尽量从对方**原词或具体画面**带入;**不要**用「嗯。」开场(**含**「嗯。」后立刻接正文),也不要「好。」「对。」单独一顿再接长句当习惯起手。"
|
||||||
"用对方刚说的**那个具体细节**回应,不要写成泛泛的总结。"
|
"用对方刚说的**那个具体细节**回应,不要写成泛泛的总结。"
|
||||||
"不要用总结腔('听起来你的童年很快乐'),要用对话腔('那种……的感觉,现在想起来都觉得……')。"
|
"不要用总结腔('听起来你的童年很快乐'),要用对话腔('那种……的感觉,现在想起来都觉得……')。"
|
||||||
"追问优先顺着对方刚说的具体细节往里走一层,不要跳到泛泛的新问题。"
|
"追问优先顺着对方刚说的具体细节往里走一层,不要跳到泛泛的新问题。"
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ def get_guided_conversation_prompt(
|
|||||||
|
|
||||||
### 第一步:先接住——让对方觉得你真的听进了情绪与细节
|
### 第一步:先接住——让对方觉得你真的听进了情绪与细节
|
||||||
- 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"。
|
- 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"。
|
||||||
- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`:**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。
|
- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯。」「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`:**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。
|
||||||
- **跟随—沉浸**:长段叙述后,可插入**极短**一两句**并肩式画面或体感**(像朋友旁听时轻轻嘬一口气),打破纯「一问一答」节拍;须**贴着对方刚讲的物象/动作**,可用「那种…」「换我可能也会…」等**泛指**,**禁止**宣称自己身上发生的具体人名地名事件,**禁止**用「这让我想起…」硬接无关轶事抢戏。
|
- **跟随—沉浸**:长段叙述后,可插入**极短**一两句**并肩式画面或体感**(像朋友旁听时轻轻嘬一口气),打破纯「一问一答」节拍;须**贴着对方刚讲的物象/动作**,可用「那种…」「换我可能也会…」等**泛指**,**禁止**宣称自己身上发生的具体人名地名事件,**禁止**用「这让我想起…」硬接无关轶事抢戏。
|
||||||
- **留白**:用户抛出**强烈情绪**或**金句式人生总结**(如「爱是流动的」一类)时,允许**本轮不接任何问题**,只作更深一层的、贴着原句的共情,让情绪**淌一小会儿**;若用 `[SPLIT]`,第一泡可以**只有共情不讲题**,但仍须是**有内容的短句**(贴原词或并肩),第二泡仍可不问,或只在末尾留极轻的一句勾子,勿赶着交卷。
|
- **留白**:用户抛出**强烈情绪**或**金句式人生总结**(如「爱是流动的」一类)时,允许**本轮不接任何问题**,只作更深一层的、贴着原句的共情,让情绪**淌一小会儿**;若用 `[SPLIT]`,第一泡可以**只有共情不讲题**,但仍须是**有内容的短句**(贴原词或并肩),第二泡仍可不问,或只在末尾留极轻的一句勾子,勿赶着交卷。
|
||||||
- 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"。
|
- 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"。
|
||||||
@@ -467,11 +467,13 @@ def get_guided_conversation_prompt(
|
|||||||
- 不要每轮都像第一次见面。
|
- 不要每轮都像第一次见面。
|
||||||
|
|
||||||
## 语言与文笔(隐性执行,勿念给用户听)
|
## 语言与文笔(隐性执行,勿念给用户听)
|
||||||
|
- **句首习惯**:**禁止**「嗯。」起头(**含**「嗯。」后立刻接正文,一律不要);**禁止**单独成泡只有「嗯。」。「好。」「对。」也少当每轮固定发语词;更像真人时**直接**咬对方原词往下长——短停顿用省略号或半句并肩即可。
|
||||||
- 长短句掺着来;能少说一个字就不堆「很、特别、真的」。
|
- 长短句掺着来;能少说一个字就不堆「很、特别、真的」。
|
||||||
- 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。
|
- 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。
|
||||||
- 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。
|
- 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。
|
||||||
|
|
||||||
## 绝对不要做的
|
## 绝对不要做的
|
||||||
|
- **禁止**以「嗯。」起头,**即使**后面还有长正文也不行(不要用「嗯。」当停顿再接句);禁止单独成泡只有「嗯。」。
|
||||||
- 不要为了赶大纲无视用户刚露出来的情绪。
|
- 不要为了赶大纲无视用户刚露出来的情绪。
|
||||||
- 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。
|
- 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。
|
||||||
- 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。
|
- 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ def strip_parenthetical_asides_for_chat(text: str) -> str:
|
|||||||
return s.strip()
|
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(
|
def segments_from_llm_response(
|
||||||
response_text: str,
|
response_text: str,
|
||||||
*,
|
*,
|
||||||
@@ -76,13 +89,22 @@ def segments_from_llm_response(
|
|||||||
text = strip_parenthetical_asides_for_chat(text)
|
text = strip_parenthetical_asides_for_chat(text)
|
||||||
if not text:
|
if not text:
|
||||||
return []
|
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:
|
if len(primary) > 1:
|
||||||
return primary[:max_segments]
|
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:
|
if "\n" not in blob:
|
||||||
return [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:
|
if len(paras) < 2:
|
||||||
return [blob]
|
return [blob]
|
||||||
paras = [p for p in paras if len(p) >= min_paragraph_chars]
|
paras = [p for p in paras if len(p) >= min_paragraph_chars]
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ class Settings(BaseSettings):
|
|||||||
memoir_image_style_default: str = "watercolor"
|
memoir_image_style_default: str = "watercolor"
|
||||||
memoir_image_size_default: str = "1280x720"
|
memoir_image_size_default: str = "1280x720"
|
||||||
memoir_image_download_hosts: str = ""
|
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 正文至少多少字才创建主图 intent / 调图(0 表示不限制)
|
||||||
story_image_min_body_chars: int = 400
|
story_image_min_body_chars: int = 400
|
||||||
# generate_story_image 入队去重(Redis SET NX,秒)
|
# generate_story_image 入队去重(Redis SET NX,秒)
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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 (
|
from app.features.memoir.memoir_images.schema import (
|
||||||
IMAGE_STATUS_FAILED,
|
IMAGE_STATUS_FAILED,
|
||||||
IMAGE_STATUS_PENDING,
|
IMAGE_STATUS_PENDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 正文内  数量需 **大于** 此值才生成/展示章节封面(与故事头图、正文配图任务独立)
|
|
||||||
MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER = 3
|
|
||||||
|
|
||||||
|
|
||||||
def chapter_has_story_links(chapter: Any) -> bool:
|
def chapter_has_story_links(chapter: Any) -> bool:
|
||||||
return any(
|
return any(
|
||||||
@@ -21,18 +22,47 @@ def chapter_has_story_links(chapter: Any) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_chapter_inline_body_images(chapter: Any) -> int:
|
def effective_chapter_markdown_for_cover_gates(chapter: Any) -> str:
|
||||||
"""统计章节 canonical_markdown 中正文插图(asset:// 图片引用)次数。"""
|
"""
|
||||||
md = getattr(chapter, "canonical_markdown", None) or ""
|
用于封面闸门计数:优先 DB canonical;若为空且已挂 stories,则用内存物化串
|
||||||
return len(parse_asset_refs(md))
|
(与列表/详情在 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:
|
def count_chapter_inline_body_images(
|
||||||
"""仅当正文内插图数量 > MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER 时才生成/展示章节封面。"""
|
chapter: Any, *, markdown: str | None = None
|
||||||
return (
|
) -> int:
|
||||||
count_chapter_inline_body_images(chapter)
|
"""统计 asset:// 插图次数;未传 markdown 时用 effective_chapter_markdown_for_cover_gates。"""
|
||||||
> MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER
|
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:
|
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:
|
def chapter_needs_cover_enqueue(chapter) -> bool:
|
||||||
"""尚无 cover_asset、有正文、且正文内 asset 插图多于阈值时,可派发 generate_chapter_cover。"""
|
"""尚无 cover_asset、有正文、且正文内 asset 插图达到 env 阈值时,可派发 generate_chapter_cover。"""
|
||||||
if not chapter:
|
if not chapter:
|
||||||
return False
|
return False
|
||||||
if not chapter_has_story_links(chapter):
|
if not chapter_has_story_links(chapter):
|
||||||
return False
|
return False
|
||||||
if getattr(chapter, "cover_asset_id", None):
|
if getattr(chapter, "cover_asset_id", None):
|
||||||
return False
|
return False
|
||||||
md = (getattr(chapter, "canonical_markdown", None) or "").strip()
|
view = effective_chapter_markdown_for_cover_gates(chapter)
|
||||||
if not md:
|
body = strip_image_placeholders(view).strip()
|
||||||
|
if not body:
|
||||||
return False
|
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:
|
def chapter_has_cover_to_generate(chapter) -> bool:
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import get_logger
|
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 (
|
from app.features.memoir.cover_eligibility import (
|
||||||
chapter_eligible_for_cover_by_inline_body_image_count,
|
chapter_eligible_for_cover_by_inline_body_image_count,
|
||||||
|
chapter_has_story_links,
|
||||||
primary_chapter_memoir_image,
|
primary_chapter_memoir_image,
|
||||||
)
|
)
|
||||||
from app.features.memoir.memoir_images.schema import (
|
from app.features.memoir.memoir_images.schema import (
|
||||||
@@ -89,36 +93,82 @@ def is_image_permanently_unavailable(rec) -> bool:
|
|||||||
return False
|
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(
|
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:
|
) -> 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
|
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)
|
m = primary_chapter_memoir_image(ch)
|
||||||
if m and is_image_permanently_unavailable(m):
|
if m and is_image_permanently_unavailable(m):
|
||||||
m = None
|
m = None
|
||||||
if m:
|
if m:
|
||||||
return memoir_image_to_dict(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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +189,9 @@ def chapter_to_list_dict(
|
|||||||
markdown_for_response: str | None = None,
|
markdown_for_response: str | None = None,
|
||||||
) -> dict:
|
) -> 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)
|
cover_normalized = first_normalized_image_for_api(cover)
|
||||||
canonical_raw = _chapter_markdown(ch, override=markdown_for_response)
|
canonical_raw = _chapter_markdown(ch, override=markdown_for_response)
|
||||||
wcount = len(canonical_raw.strip()) if canonical_raw else 0
|
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 {}
|
asset_url_map = asset_url_map or {}
|
||||||
resolve = lambda aid: asset_url_map.get(aid) # noqa: E731
|
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)
|
cover_normalized = first_normalized_image_for_api(cover)
|
||||||
# 正文真源:优先 canonical_markdown
|
# 正文真源:优先 canonical_markdown
|
||||||
canonical_md = _chapter_markdown(ch, override=markdown_for_response)
|
canonical_md = _chapter_markdown(ch, override=markdown_for_response)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.features.memoir.cover_eligibility import (
|
|||||||
chapter_eligible_for_cover_by_inline_body_image_count,
|
chapter_eligible_for_cover_by_inline_body_image_count,
|
||||||
chapter_has_story_links,
|
chapter_has_story_links,
|
||||||
chapter_needs_cover_enqueue,
|
chapter_needs_cover_enqueue,
|
||||||
|
effective_chapter_markdown_for_cover_gates,
|
||||||
primary_chapter_memoir_image,
|
primary_chapter_memoir_image,
|
||||||
)
|
)
|
||||||
from app.features.memoir.models import Chapter, ChapterStoryLink
|
from app.features.memoir.models import Chapter, ChapterStoryLink
|
||||||
@@ -42,14 +43,13 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
if getattr(chapter, "cover_asset_id", None):
|
if getattr(chapter, "cover_asset_id", None):
|
||||||
return False
|
return False
|
||||||
md = (chapter.canonical_markdown or "").strip()
|
view = effective_chapter_markdown_for_cover_gates(chapter)
|
||||||
body = md or ""
|
body = strip_image_placeholders(view).strip()
|
||||||
if not body.strip():
|
|
||||||
return False
|
|
||||||
body = strip_image_placeholders(body).strip()
|
|
||||||
if not body:
|
if not body:
|
||||||
return False
|
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
|
return False
|
||||||
cover_rec = primary_chapter_memoir_image(chapter)
|
cover_rec = primary_chapter_memoir_image(chapter)
|
||||||
if cover_rec and (cover_rec.status or "").strip() == "completed":
|
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:
|
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))
|
return bool(chapter_needs_cover_enqueue(chapter))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
api/tests/test_chapter_cover_fallback_inline.py
Normal file
57
api/tests/test_chapter_cover_fallback_inline.py
Normal 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"
|
||||||
|
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": ""}
|
||||||
|
]
|
||||||
|
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 = ""
|
||||||
|
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"
|
||||||
54
api/tests/test_cover_eligibility_effective_markdown.py
Normal file
54
api/tests/test_cover_eligibility_effective_markdown.py
Normal 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",
|
||||||
|
):
|
||||||
|
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="",
|
||||||
|
):
|
||||||
|
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=""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
):
|
||||||
|
assert chapter_needs_cover_enqueue(ch) is True
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from app.agents.chat.reply_limits import (
|
from app.agents.chat.reply_limits import (
|
||||||
nonempty_segments_or_fallback,
|
nonempty_segments_or_fallback,
|
||||||
segments_from_llm_response,
|
segments_from_llm_response,
|
||||||
|
strip_leading_en_period_ack_for_chat,
|
||||||
strip_markdown_for_chat,
|
strip_markdown_for_chat,
|
||||||
strip_parenthetical_asides_for_chat,
|
strip_parenthetical_asides_for_chat,
|
||||||
)
|
)
|
||||||
@@ -58,3 +59,15 @@ def test_segments_strip_parentheticals_before_split():
|
|||||||
|
|
||||||
def test_strip_parenthetical_multiple_passes():
|
def test_strip_parenthetical_multiple_passes():
|
||||||
assert strip_parenthetical_asides_for_chat("a(一)b(二)c") == "abc"
|
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"]
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Icon } from '@/components/ui/icon';
|
import { Icon } from '@/components/ui/icon';
|
||||||
import { Text } from '@/components/ui/text';
|
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 { ScreenGutter } from '@/constants/layout';
|
||||||
|
import { useTypography } from '@/core/typography-context';
|
||||||
|
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||||
import {
|
import {
|
||||||
MarkdownRenderer,
|
MarkdownRenderer,
|
||||||
ReadingMarkdownHorizontalRuleInColumn,
|
ReadingMarkdownHorizontalRuleInColumn,
|
||||||
@@ -353,6 +358,8 @@ function ReadingSettingsModal({
|
|||||||
export default function ChapterScreen() {
|
export default function ChapterScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { largeText } = useAppSettings();
|
||||||
|
const typography = useTypography();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const { t } = useTranslation('memoir');
|
const { t } = useTranslation('memoir');
|
||||||
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
|
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
|
||||||
@@ -429,8 +436,13 @@ export default function ChapterScreen() {
|
|||||||
const useReadingSegments =
|
const useReadingSegments =
|
||||||
Array.isArray(readingSegments) && readingSegments.length > 0;
|
Array.isArray(readingSegments) && readingSegments.length > 0;
|
||||||
|
|
||||||
/** 与 ScreenHeader(reading、useSafeArea)可视高度对齐,避免返回栏与首屏内容之间出现空隙 */
|
/** 与 ScreenHeader(reading、useSafeArea)实际总高度一致,避免章节标题被顶栏或安全区遮挡 */
|
||||||
const headerOccupiedHeight = Math.max(insets.top, 12) + 56;
|
const headerOccupiedHeight = getScreenHeaderLayoutMetrics(insets, {
|
||||||
|
useSafeArea: true,
|
||||||
|
variant: 'reading',
|
||||||
|
largeText,
|
||||||
|
typography,
|
||||||
|
}).totalHeight;
|
||||||
|
|
||||||
const handleDeletePress = () => {
|
const handleDeletePress = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useTypography } from '@/core/typography-context';
|
|||||||
import { ScreenGutter } from '@/constants/layout';
|
import { ScreenGutter } from '@/constants/layout';
|
||||||
import { useAppSettings } from '@/hooks/use-app-settings';
|
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||||
|
|
||||||
|
import type { TypographyTokens } from '@/core/typography-context';
|
||||||
|
|
||||||
/** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */
|
/** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */
|
||||||
const BACK_HIT_MIN = 48;
|
const BACK_HIT_MIN = 48;
|
||||||
const BACK_HIT_MIN_LARGE = 56;
|
const BACK_HIT_MIN_LARGE = 56;
|
||||||
@@ -18,6 +20,52 @@ const BACK_EXTRA_HIT_LEFT = 4;
|
|||||||
|
|
||||||
export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
|
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 = {
|
const VARIANT_COLORS = {
|
||||||
default: {
|
default: {
|
||||||
title: undefined, // use theme foreground
|
title: undefined, // use theme foreground
|
||||||
@@ -76,7 +124,6 @@ export function ScreenHeader({
|
|||||||
const typography = useTypography();
|
const typography = useTypography();
|
||||||
const colors = VARIANT_COLORS[variant];
|
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 backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE;
|
||||||
|
|
||||||
const handleBack = onBack ?? (() => router.back());
|
const handleBack = onBack ?? (() => router.back());
|
||||||
@@ -88,27 +135,26 @@ export function ScreenHeader({
|
|||||||
* 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。
|
* 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。
|
||||||
* 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。
|
* 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。
|
||||||
*/
|
*/
|
||||||
const barPaddingBottom = largeText ? 18 : 16;
|
const {
|
||||||
const titleRowPaddingV = largeText ? 8 : 4;
|
barPaddingBottom,
|
||||||
const titleFontSize =
|
titleRowPaddingV,
|
||||||
variant === 'chat'
|
titleFontSize,
|
||||||
? largeText
|
titleRowMinHeight,
|
||||||
? typography.headingMedium
|
paddingTop: headerPaddingTop,
|
||||||
: typography.headingSmall
|
backTouchMin,
|
||||||
: Math.max(typography.titleLarge, typography.headingSmall);
|
} = getScreenHeaderLayoutMetrics(insets, {
|
||||||
const titleLineMin =
|
useSafeArea,
|
||||||
variant === 'chat'
|
variant,
|
||||||
? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) +
|
largeText,
|
||||||
(largeText ? 14 : 10)
|
typography,
|
||||||
: Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8);
|
});
|
||||||
const titleRowMinHeight = Math.max(backTouchMin, titleLineMin);
|
|
||||||
|
|
||||||
const containerStyle = {
|
const containerStyle = {
|
||||||
flexDirection: 'row' as const,
|
flexDirection: 'row' as const,
|
||||||
alignItems: 'center' as const,
|
alignItems: 'center' as const,
|
||||||
justifyContent: 'space-between' as const,
|
justifyContent: 'space-between' as const,
|
||||||
paddingHorizontal: Math.max(ScreenGutter, 16),
|
paddingHorizontal: Math.max(ScreenGutter, 16),
|
||||||
paddingTop: useSafeArea ? Math.max(insets.top, 12) : 12,
|
paddingTop: headerPaddingTop,
|
||||||
paddingBottom: barPaddingBottom,
|
paddingBottom: barPaddingBottom,
|
||||||
...(bgColor && { backgroundColor: bgColor }),
|
...(bgColor && { backgroundColor: bgColor }),
|
||||||
...(absolute && {
|
...(absolute && {
|
||||||
|
|||||||
Reference in New Issue
Block a user