diff --git a/api/app/agents/chat/interview_turn_plan.py b/api/app/agents/chat/interview_turn_plan.py
index 8fa8efe..718b82a 100644
--- a/api/app/agents/chat/interview_turn_plan.py
+++ b/api/app/agents/chat/interview_turn_plan.py
@@ -88,7 +88,11 @@ def _is_emotion_heavy(text: str) -> bool:
return False
if any(m in t for m in _EMOTION_MARKERS):
return True
- if len(t) >= 40 and ("!" in t or "!" in t) and (".." in t or "…" in t or "唉" in t):
+ if (
+ len(t) >= 40
+ and ("!" in t or "!" in t)
+ and (".." in t or "…" in t or "唉" in t)
+ ):
return True
return False
diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py
index 638b1f2..a2c358d 100644
--- a/api/app/agents/chat/prompts_conversation.py
+++ b/api/app/agents/chat/prompts_conversation.py
@@ -366,7 +366,11 @@ def get_guided_conversation_prompt(
current_stage, empty_slots
)
- _prefix = f"{turn_directive_block.rstrip()}\n\n" if (turn_directive_block or "").strip() else ""
+ _prefix = (
+ f"{turn_directive_block.rstrip()}\n\n"
+ if (turn_directive_block or "").strip()
+ else ""
+ )
return f"""{_prefix}你是「岁月知己」——**主持式知己**:语气像最懂我的老朋友,**职责是帮用户把人生故事口述清楚**。{tone_line}
diff --git a/api/app/agents/memoir/batch_phase1_prep.py b/api/app/agents/memoir/batch_phase1_prep.py
index b2aa8dc..f76ac87 100644
--- a/api/app/agents/memoir/batch_phase1_prep.py
+++ b/api/app/agents/memoir/batch_phase1_prep.py
@@ -124,12 +124,8 @@ def _run_batch_phase1_prep_chunk_with_bisect(
mid = len(segments) // 2
if mid < 1:
raise
- left = _run_batch_phase1_prep_chunk_with_bisect(
- segments[:mid], state, llm
- )
- right = _run_batch_phase1_prep_chunk_with_bisect(
- segments[mid:], state, llm
- )
+ left = _run_batch_phase1_prep_chunk_with_bisect(segments[:mid], state, llm)
+ right = _run_batch_phase1_prep_chunk_with_bisect(segments[mid:], state, llm)
merged = {**left, **right}
expected = {str(s.id) for s in segments}
if merged.keys() != expected:
@@ -181,7 +177,5 @@ def run_batch_phase1_prep_chunked(
missing,
extra,
)
- raise ValueError(
- "batch phase1 chunked: merged segment ids do not match input"
- )
+ raise ValueError("batch phase1 chunked: merged segment ids do not match input")
return merged
diff --git a/api/app/core/memoir_pipeline_progress.py b/api/app/core/memoir_pipeline_progress.py
index 232302d..01986c2 100644
--- a/api/app/core/memoir_pipeline_progress.py
+++ b/api/app/core/memoir_pipeline_progress.py
@@ -83,7 +83,9 @@ def _merge_phase2_list(
return list(by_tid.values())
-def _fanout_list_merge_key(items: list[dict], patch_items: list[dict], id_key: str) -> None:
+def _fanout_list_merge_key(
+ items: list[dict], patch_items: list[dict], id_key: str
+) -> None:
by_id: dict[str, dict[str, Any]] = {}
for x in items:
k = str(x.get(id_key) or "").strip()
@@ -104,9 +106,11 @@ def _fanout_list_merge_key(items: list[dict], patch_items: list[dict], id_key: s
def _merge_fanout(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
out = dict(base)
for k, v in patch.items():
- if k in ("story_images", "recompose_chapters", "memory_enrichment") and isinstance(
- v, list
- ):
+ if k in (
+ "story_images",
+ "recompose_chapters",
+ "memory_enrichment",
+ ) and isinstance(v, list):
id_key = (
"story_id"
if k == "story_images"
@@ -132,14 +136,14 @@ def _merge_doc(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
if k == "phase2" and isinstance(v, list):
out["phase2"] = _merge_phase2_list(list(out.get("phase2") or []), v)
elif k == "fanout" and isinstance(v, dict):
- out["fanout"] = _merge_fanout(
- dict(out.get("fanout") or _empty_fanout()), v
- )
+ out["fanout"] = _merge_fanout(dict(out.get("fanout") or _empty_fanout()), v)
elif k == "phase1" and isinstance(v, dict):
cur = dict(out.get("phase1") or {})
for pk, pv in v.items():
- if pk == "detail" and isinstance(pv, dict) and isinstance(
- cur.get("detail"), dict
+ if (
+ pk == "detail"
+ and isinstance(pv, dict)
+ and isinstance(cur.get("detail"), dict)
):
cur["detail"] = {**cur["detail"], **pv}
else:
diff --git a/api/app/features/evaluation/judge_manual_service.py b/api/app/features/evaluation/judge_manual_service.py
index a16a949..01c0b43 100644
--- a/api/app/features/evaluation/judge_manual_service.py
+++ b/api/app/features/evaluation/judge_manual_service.py
@@ -712,7 +712,9 @@ class EvalJudgeManualService:
def _nonempty_chapters(cols: list[Any]) -> int:
return sum(
- 1 for x in cols if (getattr(x, "canonical_markdown", None) or "").strip()
+ 1
+ for x in cols
+ if (getattr(x, "canonical_markdown", None) or "").strip()
)
conc = max(1, min(32, int(settings.eval_judge_memoir_chapter_concurrency)))
@@ -849,9 +851,7 @@ class EvalJudgeManualService:
judged = await asyncio.gather(*[_judge_one(p) for p in prepared])
judged.sort(
key=lambda r: (
- r["order_index"]
- if r["order_index"] is not None
- else 10**9,
+ r["order_index"] if r["order_index"] is not None else 10**9,
r["enum_idx"],
)
)
@@ -900,7 +900,11 @@ class EvalJudgeManualService:
"""Streaming SSE: one event per chapter judge result, concurrent LLM calls."""
uid = (user_id or "").strip()
if not uid:
- yield {"event": "error", "phase": "validate", "message": "user_id is required"}
+ yield {
+ "event": "error",
+ "phase": "validate",
+ "message": "user_id is required",
+ }
return
judge, resolved_model = _make_eval_judge(judge_provider, judge_model)
@@ -912,7 +916,10 @@ class EvalJudgeManualService:
trace_svc = EvalTraceService(self._db)
def _chapter_evidence_notes(
- lineage_tier: str, evidence_summary: str, truncated: bool, dropped: list[str]
+ lineage_tier: str,
+ evidence_summary: str,
+ truncated: bool,
+ dropped: list[str],
) -> str:
drops = ",".join(dropped[:12]) if dropped else ""
return (
@@ -926,8 +933,14 @@ class EvalJudgeManualService:
uid, self._db, active_only=True, is_new_only=None
)
except Exception as e:
- logger.exception("manual memoir stream: chapter list failed user_id={}", uid)
- yield {"event": "error", "phase": "load", "message": f"加载章节列表失败:{e}"}
+ logger.exception(
+ "manual memoir stream: chapter list failed user_id={}", uid
+ )
+ yield {
+ "event": "error",
+ "phase": "load",
+ "message": f"加载章节列表失败:{e}",
+ }
return
yield {
@@ -957,15 +970,22 @@ class EvalJudgeManualService:
cb = await trace_svc.build_chapter_bundle(uid, ch)
formatted, cb2 = await trace_svc.format_chapter_bundle(cb)
fm = formatted.format_meta
- prepared.append({
- "ch": ch, "bl": bl, "md": md,
- "baseline_excerpt": baseline_excerpt,
- "formatted": formatted, "cb2": cb2, "fm": fm,
- })
+ prepared.append(
+ {
+ "ch": ch,
+ "bl": bl,
+ "md": md,
+ "baseline_excerpt": baseline_excerpt,
+ "formatted": formatted,
+ "cb2": cb2,
+ "fm": fm,
+ }
+ )
except Exception as e:
logger.exception(
"manual memoir stream: chapter prepare failed user_id={} chapter_id={}",
- uid, ch.id,
+ uid,
+ ch.id,
)
yield {
"event": "chapter_error",
@@ -1025,7 +1045,8 @@ class EvalJudgeManualService:
except Exception as exc:
logger.warning(
"memoir stream: baseline judge failed ch={} err={}",
- ch.id, exc,
+ ch.id,
+ exc,
)
baseline_error = str(exc)
@@ -1040,14 +1061,17 @@ class EvalJudgeManualService:
except Exception as e:
logger.exception(
"manual memoir stream: chapter judge failed user_id={} chapter_id={}",
- uid, ch.id,
+ uid,
+ ch.id,
+ )
+ await result_queue.put(
+ {
+ "event": "chapter_error",
+ "chapter_id": ch.id,
+ "title": ch.title,
+ "message": f"评审失败:{e}",
+ }
)
- await result_queue.put({
- "event": "chapter_error",
- "chapter_id": ch.id,
- "title": ch.title,
- "message": f"评审失败:{e}",
- })
return
cj = cj_res.output
compare_summary = build_memoir_compare_summary(
@@ -1073,12 +1097,14 @@ class EvalJudgeManualService:
row["judge_error"] = cj_res.error
if not cj and not cj_res.error:
row["judge_error"] = "empty_output"
- await result_queue.put({
- "event": "chapter_judge",
- "index": idx,
- "chapter": row,
- "ok": cj is not None,
- })
+ await result_queue.put(
+ {
+ "event": "chapter_judge",
+ "index": idx,
+ "chapter": row,
+ "ok": cj is not None,
+ }
+ )
tasks = [asyncio.create_task(_judge_one(i, p)) for i, p in enumerate(prepared)]
diff --git a/api/app/features/evaluation/judge_schemas.py b/api/app/features/evaluation/judge_schemas.py
index c79e947..bdf4627 100644
--- a/api/app/features/evaluation/judge_schemas.py
+++ b/api/app/features/evaluation/judge_schemas.py
@@ -229,8 +229,12 @@ class MemoirJudgeOutput(BaseModel):
# 细项:校验放宽到 0–100;真实满分仍以 rubric 为准,由 after 钳制
mem_fidelity: float = Field(default=0, ge=0, le=100, description="记忆忠实度")
- mem_factual_accuracy: float = Field(default=0, ge=0, le=100, description="事实准确性")
- mem_factual_coverage: float = Field(default=0, ge=0, le=100, description="事实覆盖率")
+ mem_factual_accuracy: float = Field(
+ default=0, ge=0, le=100, description="事实准确性"
+ )
+ mem_factual_coverage: float = Field(
+ default=0, ge=0, le=100, description="事实覆盖率"
+ )
mem_traceability: float = Field(default=0, ge=0, le=100, description="记忆可追溯性")
info_slot_coverage: float = Field(default=0, ge=0, le=100, description="槽位覆盖度")
@@ -290,7 +294,9 @@ class MemoirJudgeOutput(BaseModel):
def _coerce_memoir_judge_input(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
- data["rationale"] = "" if data.get("rationale") is None else str(data["rationale"])
+ data["rationale"] = (
+ "" if data.get("rationale") is None else str(data["rationale"])
+ )
for key in ("major_strengths", "major_issues", "insufficient_evidence"):
data[key] = _coerce_judge_str_list(data.get(key))
raw_refs = data.get("evidence_refs")
diff --git a/api/app/features/evaluation/judge_service.py b/api/app/features/evaluation/judge_service.py
index 748ee41..2b93758 100644
--- a/api/app/features/evaluation/judge_service.py
+++ b/api/app/features/evaluation/judge_service.py
@@ -492,7 +492,9 @@ class EvalJudgeService:
self._llm,
prompt,
MemoirJudgeOutput,
- max_tokens=max(512, int(settings.eval_judge_memoir_completion_max_tokens)),
+ max_tokens=max(
+ 512, int(settings.eval_judge_memoir_completion_max_tokens)
+ ),
agent="EvalJudgeService.judge_memoir",
)
return JudgeCallResult(output=out)
diff --git a/api/app/features/evaluation/memoir_compare_summary.py b/api/app/features/evaluation/memoir_compare_summary.py
index 7eec251..29db0d0 100644
--- a/api/app/features/evaluation/memoir_compare_summary.py
+++ b/api/app/features/evaluation/memoir_compare_summary.py
@@ -106,9 +106,7 @@ def build_memoir_compare_summary(
key_regressions = [
v["label"] for v in leaf_deltas.values() if float(v["delta"]) <= -0.5
]
- key_gains = [
- v["label"] for v in leaf_deltas.values() if float(v["delta"]) >= 0.5
- ]
+ key_gains = [v["label"] for v in leaf_deltas.values() if float(v["delta"]) >= 0.5]
parity_passed = total_delta >= -2.0 and len(key_regressions) <= 3
surpass_passed = total_delta >= 2.0 and len(key_regressions) <= 1
diff --git a/api/app/features/memory/service.py b/api/app/features/memory/service.py
index 9d3a923..0477d91 100644
--- a/api/app/features/memory/service.py
+++ b/api/app/features/memory/service.py
@@ -363,9 +363,7 @@ def ingest_transcripts_batch_sync(
if not text:
continue
primary_mid = (
- primary_user_message_id_from_lineage(lineage_json)
- if lineage_json
- else None
+ primary_user_message_id_from_lineage(lineage_json) if lineage_json else None
)
source = create_source_sync(
session,
@@ -419,9 +417,7 @@ def ingest_transcripts_batch_sync(
)
emb_ok = (
- embedding_provider.is_available()
- if embedding_provider is not None
- else False
+ embedding_provider.is_available() if embedding_provider is not None else False
)
logger.info(
"event=memory_ingest_batch_done user_id={} sources={} chunks={} "
diff --git a/api/app/tasks/celery_app.py b/api/app/tasks/celery_app.py
index ce5bece..20cdff8 100644
--- a/api/app/tasks/celery_app.py
+++ b/api/app/tasks/celery_app.py
@@ -128,7 +128,9 @@ def _log_task_prerun(
@task_success.connect
-def _log_task_success(sender: object | None = None, result: object | None = None, **_: object) -> None:
+def _log_task_success(
+ sender: object | None = None, result: object | None = None, **_: object
+) -> None:
"""仅成功路径;失败见 ``task_failure``(避免 ``task_postrun`` 在异常态仍触发)。"""
name = getattr(sender, "name", None) if sender is not None else None
name = name or "?"
diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py
index ac9b73d..a7eedc6 100644
--- a/api/app/tasks/memoir_tasks.py
+++ b/api/app/tasks/memoir_tasks.py
@@ -185,7 +185,9 @@ def _update_task_status_sync(
logger.debug("任务状态已更新: task_id={} status={}", task_id, status)
except Exception as e:
- logger.error("event=memoir_task_status_update_failed msg=更新任务状态失败 exc={}", e)
+ logger.error(
+ "event=memoir_task_status_update_failed msg=更新任务状态失败 exc={}", e
+ )
def _merge_chapter_image_assets(
diff --git a/api/app/tasks/story_image_tasks.py b/api/app/tasks/story_image_tasks.py
index 8982b13..50ad6f8 100644
--- a/api/app/tasks/story_image_tasks.py
+++ b/api/app/tasks/story_image_tasks.py
@@ -151,9 +151,7 @@ def _claim_story_image_intent_sync(db, story_id: str, claim_token: str):
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
-def generate_story_image(
- self, story_id: str, memoir_correlation_id: str | None = None
-):
+def generate_story_image(self, story_id: str, memoir_correlation_id: str | None = None):
"""
为 story 生成主插图。
从 story_image_intents 原子认领 primary intent,生成后写入 assets 并更新 intent。
diff --git a/api/tests/test_batch_phase1_chunked.py b/api/tests/test_batch_phase1_chunked.py
index c927bfd..de0af98 100644
--- a/api/tests/test_batch_phase1_chunked.py
+++ b/api/tests/test_batch_phase1_chunked.py
@@ -47,9 +47,7 @@ def test_run_batch_phase1_prep_chunked_splits_95_into_four_calls(
"app.agents.memoir.batch_phase1_prep.run_batch_phase1_prep",
fake_prep,
)
- segments = [
- SimpleNamespace(id=f"s{i}", user_input_text="hello") for i in range(95)
- ]
+ segments = [SimpleNamespace(id=f"s{i}", user_input_text="hello") for i in range(95)]
by_id = run_batch_phase1_prep_chunked(
segments,
_state(),
@@ -85,9 +83,7 @@ def test_chunked_bisect_on_value_error(monkeypatch: pytest.MonkeyPatch) -> None:
"app.agents.memoir.batch_phase1_prep.run_batch_phase1_prep",
fake_prep,
)
- segments = [
- SimpleNamespace(id=f"b{i}", user_input_text="x") for i in range(4)
- ]
+ segments = [SimpleNamespace(id=f"b{i}", user_input_text="x") for i in range(4)]
by_id = run_batch_phase1_prep_chunked(
segments,
_state(),
diff --git a/api/tests/test_memoir_pipeline_progress.py b/api/tests/test_memoir_pipeline_progress.py
index 202ca9d..81c55e8 100644
--- a/api/tests/test_memoir_pipeline_progress.py
+++ b/api/tests/test_memoir_pipeline_progress.py
@@ -83,23 +83,15 @@ def test_merge_fanout_lists_merge_by_id(fake_redis: _FakeRedis) -> None:
def test_init_and_index_resolve(fake_redis: _FakeRedis) -> None:
- mpp.init_pipeline_run_from_phase1(
- "user-a", "cid-4", "p1tid", segment_count=3
- )
+ mpp.init_pipeline_run_from_phase1("user-a", "cid-4", "p1tid", segment_count=3)
cid = mpp.resolve_correlation_id_for_phase1_task("p1tid")
assert cid == "cid-4"
- snap = mpp.get_pipeline_run_for_eval(
- "user-a", phase1_task_id="p1tid"
- )
+ snap = mpp.get_pipeline_run_for_eval("user-a", phase1_task_id="p1tid")
assert snap is not None
assert snap["user_id"] == "user-a"
assert snap["phase1"]["task_id"] == "p1tid"
def test_get_pipeline_run_for_eval_user_mismatch(fake_redis: _FakeRedis) -> None:
- mpp.init_pipeline_run_from_phase1(
- "user-a", "cid-5", "p1b", segment_count=1
- )
- assert (
- mpp.get_pipeline_run_for_eval("other", phase1_task_id="p1b") is None
- )
+ mpp.init_pipeline_run_from_phase1("user-a", "cid-5", "p1b", segment_count=1)
+ assert mpp.get_pipeline_run_for_eval("other", phase1_task_id="p1b") is None
diff --git a/api/tests/test_memory_enrichment_baseline.py b/api/tests/test_memory_enrichment_baseline.py
index 45f86b7..f18cc1d 100644
--- a/api/tests/test_memory_enrichment_baseline.py
+++ b/api/tests/test_memory_enrichment_baseline.py
@@ -26,7 +26,9 @@ def test_enrichment_payload_roundtrip() -> None:
assert p.facts[0].subject == "王伟"
-def test_enrich_memory_after_ingest_sync_single_llm_call(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_enrich_memory_after_ingest_sync_single_llm_call(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
from app.features.memory import enrichment as mod
monkeypatch.setattr("app.core.config.settings.memory_enrichment_enabled", True)
@@ -81,7 +83,9 @@ def test_enrich_memory_after_ingest_sync_single_llm_call(monkeypatch: pytest.Mon
assert facts[0]["status"] == "confirmed"
-def test_enrich_memory_skips_when_parse_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_enrich_memory_skips_when_parse_returns_none(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
from app.features.memory import enrichment as mod
monkeypatch.setattr("app.core.config.settings.memory_enrichment_enabled", True)
diff --git a/api/tests/test_memory_evidence.py b/api/tests/test_memory_evidence.py
index 160daee..a16eed2 100644
--- a/api/tests/test_memory_evidence.py
+++ b/api/tests/test_memory_evidence.py
@@ -46,7 +46,9 @@ def test_retrieve_evidence_bundle_sync_uses_vector_search(
}
monkeypatch.setattr(evidence_mod, "search_chunks_vector_sync", fake_search)
- monkeypatch.setattr(evidence_mod, "fetch_evidence_metadata_parallel_sync", fake_meta)
+ monkeypatch.setattr(
+ evidence_mod, "fetch_evidence_metadata_parallel_sync", fake_meta
+ )
out = retrieve_evidence_bundle_sync(
session=object(),
diff --git a/app-expo/design-tokens.json b/app-expo/design-tokens.json
index ccc716f..506c93b 100644
--- a/app-expo/design-tokens.json
+++ b/app-expo/design-tokens.json
@@ -200,6 +200,26 @@
"lineHeightTight": 24,
"lineHeightLoose": 29,
"lineHeightXLoose": 34
+ },
+ "xlarge": {
+ "headingLarge": 44,
+ "headingMedium": 33,
+ "headingSmall": 28,
+ "titleLarge": 28,
+ "titleMedium": 25,
+ "titleSmall": 21,
+ "bodyLarge": 28,
+ "bodyMedium": 26,
+ "bodySmall": 18,
+ "captionLarge": 18,
+ "captionMedium": 16,
+ "captionSmall": 15,
+ "sectionTitle": 16,
+ "badge": 13,
+ "lineHeightNormal": 30,
+ "lineHeightTight": 28,
+ "lineHeightLoose": 34,
+ "lineHeightXLoose": 40
}
}
}
diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx
index 8f1696c..6bee70e 100644
--- a/app-expo/src/app/(main)/conversation/[id].tsx
+++ b/app-expo/src/app/(main)/conversation/[id].tsx
@@ -960,13 +960,14 @@ export default function ConversationScreen() {
/** 大字模式:朗读图标与触控区与气泡字号同档放大 */
const chatReadAloudIconSize = largeText ? 24 : 20;
const chatReadAloudButtonSize = largeText ? 52 : 44;
- const chatVoiceDurationStyle = useMemo(
- () => ({
- fontSize: largeText ? typography.headingSmall : typography.titleMedium,
+ const chatVoiceDurationStyle = useMemo(() => {
+ const fs = largeText ? typography.headingSmall : typography.titleMedium;
+ return {
+ fontSize: fs,
+ lineHeight: Math.ceil(fs * 1.25),
fontWeight: '500' as const,
- }),
- [typography, largeText],
- );
+ };
+ }, [typography, largeText]);
const chatTypingLabelStyle = useMemo(
() => ({
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
@@ -977,6 +978,9 @@ export default function ConversationScreen() {
const headerTitleFontStyle = useMemo(
() => ({
fontSize: largeText ? typography.headingMedium : typography.headingSmall,
+ lineHeight: largeText
+ ? Math.ceil(typography.headingMedium * 1.4)
+ : Math.ceil(typography.headingSmall * 1.28),
}),
[typography, largeText],
);
@@ -992,13 +996,14 @@ export default function ConversationScreen() {
}),
[typography, inputLineHeight, largeText],
);
- const connectionNoticeTitleStyle = useMemo(
- () => ({
- fontSize: largeText ? typography.titleSmall : typography.captionLarge,
+ const connectionNoticeTitleStyle = useMemo(() => {
+ const fs = largeText ? typography.titleSmall : typography.captionLarge;
+ return {
+ fontSize: fs,
+ lineHeight: Math.ceil(fs * 1.28),
fontWeight: '700' as const,
- }),
- [typography, largeText],
- );
+ };
+ }, [typography, largeText]);
const connectionNoticeBodyStyle = useMemo(
() => ({
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
@@ -1401,7 +1406,10 @@ export default function ConversationScreen() {
variant="chat"
title={
-
+
{tApp('name')}
{showConnectionBadge ? (
@@ -1757,6 +1765,7 @@ const styles = StyleSheet.create({
},
voiceDurationText: {
fontSize: 18,
+ lineHeight: 24,
fontWeight: '500',
},
voiceDurationTextUser: {
@@ -1879,15 +1888,13 @@ const styles = StyleSheet.create({
},
voiceRecordDurationWrap: {
minWidth: 40,
- height: 20,
+ minHeight: 20,
alignItems: 'center',
justifyContent: 'center',
+ paddingVertical: 1,
},
+ /** 字号/行高由 voiceRecordDurationStyle 提供,避免大字模式仍锁 20px 高裁切 */
voiceRecordDuration: {
- fontSize: 12,
- /** 与容器等高,避免 Android/iOS 数字相对胶囊上下偏移 */
- height: 20,
- lineHeight: 20,
paddingVertical: 0,
marginVertical: 0,
color: 'rgba(27, 27, 31, 0.86)',
@@ -1905,7 +1912,7 @@ const styles = StyleSheet.create({
width: '100%',
},
sendButton: {
- height: 44,
+ minHeight: 44,
paddingHorizontal: 16,
paddingVertical: 11,
borderRadius: 22,
diff --git a/app-expo/src/app/(tabs)/_layout.tsx b/app-expo/src/app/(tabs)/_layout.tsx
index 012cdf4..a5c7d7e 100644
--- a/app-expo/src/app/(tabs)/_layout.tsx
+++ b/app-expo/src/app/(tabs)/_layout.tsx
@@ -6,6 +6,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
import { useTranslation } from 'react-i18next';
import { TabBarIcon } from '@/components/tab-bar-icon';
+import { useTypography } from '@/core/typography-context';
import { useSession } from '@/features/auth/hooks';
// Life-Echo bottom nav colors (from HTML reference)
@@ -27,6 +28,7 @@ const TAB_BAR_PADDING_HORIZONTAL = 12;
export default function TabsLayout() {
const { status } = useSession();
+ const typography = useTypography();
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
const isDark = colorScheme === 'dark';
@@ -75,7 +77,8 @@ export default function TabsLayout() {
marginBottom: 0,
},
tabBarLabelStyle: {
- fontSize: 11,
+ fontSize: typography.captionLarge,
+ lineHeight: typography.lineHeightTight,
fontWeight: '600',
textTransform: 'uppercase',
marginTop: 4,
diff --git a/app-expo/src/app/(tabs)/index.tsx b/app-expo/src/app/(tabs)/index.tsx
index d5290e1..ff28f30 100644
--- a/app-expo/src/app/(tabs)/index.tsx
+++ b/app-expo/src/app/(tabs)/index.tsx
@@ -154,14 +154,16 @@ function ConversationCard({
{item.title}
{formatRelativeConversationListTime(item.latestMessageTime, t)}
@@ -202,7 +204,8 @@ function SwipeableConversationCard({
>
{t('delete')}
@@ -405,11 +408,13 @@ export default function ConversationsScreen() {
{t('greetingTitle')}
-
+
{t('emptyGreetingSubtitle')}
@@ -429,13 +434,15 @@ export default function ConversationsScreen() {
{showResumeEntry
? t('resumeChatTitle')
: t('greetingTitle')}
-
+
{showResumeEntry
? t('resumeChatSubtitle')
: t('emptyGreetingSubtitle')}
@@ -449,10 +456,14 @@ export default function ConversationsScreen() {
{t('recentChats')}
{}}
>
-
+
{t('viewAll')}
diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx
index 81244ea..5b539c7 100644
--- a/app-expo/src/app/(tabs)/memoir.tsx
+++ b/app-expo/src/app/(tabs)/memoir.tsx
@@ -23,6 +23,7 @@ import { Icon } from '@/components/ui/icon';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { ScreenGutter } from '@/constants/layout';
+import { useTypography } from '@/core/typography-context';
import { useCreateConversation } from '@/features/conversation/hooks';
import {
buildFrameworkChapterPlaceholders,
@@ -77,9 +78,18 @@ function ChapterCard({
onReadPress: () => void;
onContinuePress: () => void;
}) {
+ const typography = useTypography();
const { width } = useWindowDimensions();
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
const gutter = ScreenGutter;
+ const completedTitleLineHeight = Math.max(
+ typography.lineHeightTight + 6,
+ typography.headingMedium + 6,
+ );
+ const draftingTitleLineHeight = Math.max(
+ typography.lineHeightTight + 4,
+ typography.titleLarge + 4,
+ );
const chapterIndex = item.orderIndex + 1;
const chapterLabel = t('chapterLabel').replace(
@@ -142,7 +152,8 @@ function ChapterCard({
{chapterLabel}
@@ -150,7 +161,7 @@ function ChapterCard({
@@ -161,7 +172,8 @@ function ChapterCard({
@@ -174,7 +186,10 @@ function ChapterCard({
style={{ borderCurve: 'continuous' }}
onPress={onReadPress}
>
-
+
{t('readMemory')}
@@ -197,7 +212,8 @@ function ChapterCard({
{chapterLabel}
@@ -205,7 +221,7 @@ function ChapterCard({
@@ -213,7 +229,11 @@ function ChapterCard({
-
+
{t('statusDrafting')}
@@ -223,7 +243,7 @@ function ChapterCard({
style={{ borderCurve: 'continuous' }}
onPress={onContinuePress}
>
-
+
{t('continueWriting')}
@@ -237,7 +257,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation('memoir');
return (
-
+
{t('loadErrorMessage')}
void }) {
style={{ borderCurve: 'continuous' }}
onPress={onRetry}
>
-
+
{t('loadErrorRetry')}
diff --git a/app-expo/src/app/(tabs)/profile.tsx b/app-expo/src/app/(tabs)/profile.tsx
index 5add6f6..9922d82 100644
--- a/app-expo/src/app/(tabs)/profile.tsx
+++ b/app-expo/src/app/(tabs)/profile.tsx
@@ -90,11 +90,15 @@ function LanguageRow({
{label}
- {description}
+
+ {description}
+
- {currentLabel}
+
+ {currentLabel}
+
@@ -120,7 +124,9 @@ function SettingRow({
{label}
- {description}
+
+ {description}
+
@@ -203,7 +209,7 @@ export default function ProfileScreen() {
{user?.nickname ?? t('userNamePlaceholder')}
-
+
{t('userTier', { tier: tierLabel })}
diff --git a/app-expo/src/components/screen-header.tsx b/app-expo/src/components/screen-header.tsx
index d9b4202..1f5b376 100644
--- a/app-expo/src/components/screen-header.tsx
+++ b/app-expo/src/components/screen-header.tsx
@@ -1,12 +1,20 @@
import { router } from 'expo-router';
import { ArrowLeft } from 'lucide-react-native';
import React from 'react';
-import { Pressable, View } from 'react-native';
+import { Platform, Pressable, Text as RNText, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Icon } from '@/components/ui/icon';
-import { Text } from '@/components/ui/text';
+import { useTypography } from '@/core/typography-context';
import { ScreenGutter } from '@/constants/layout';
+import { useAppSettings } from '@/hooks/use-app-settings';
+
+/** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */
+const BACK_HIT_MIN = 48;
+const BACK_HIT_MIN_LARGE = 56;
+const BACK_ICON_SIZE = 24;
+const BACK_ICON_SIZE_LARGE = 30;
+const BACK_EXTRA_HIT_LEFT = 4;
export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
@@ -64,21 +72,44 @@ export function ScreenHeader({
useSafeArea = true,
}: ScreenHeaderProps) {
const insets = useSafeAreaInsets();
+ const { largeText } = useAppSettings();
+ 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());
const bgColor = backgroundColor ?? colors.background;
const titleColor = colors.title;
const iconColor = colors.icon ?? colors.iconSecondary;
+ /**
+ * 不要用外层 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 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,
- paddingBottom: 16,
- minHeight: 56,
+ paddingBottom: barPaddingBottom,
...(bgColor && { backgroundColor: bgColor }),
...(absolute && {
position: 'absolute' as const,
@@ -105,41 +136,74 @@ export function ScreenHeader({
gap: 12,
flex: 1,
minWidth: 0,
+ paddingVertical: titleRowPaddingV,
+ minHeight: titleRowMinHeight,
}}
>
{showBack && (
({
- padding: 8,
+ minWidth: backTouchMin,
+ minHeight: backTouchMin,
+ marginLeft: -BACK_EXTRA_HIT_LEFT,
+ alignItems: 'center',
+ justifyContent: 'center',
borderRadius: 9999,
- opacity: pressed ? 0.7 : 1,
+ opacity: Platform.OS === 'android' ? 1 : pressed ? 0.7 : 1,
})}
accessibilityRole="button"
accessibilityLabel={backAccessibilityLabel}
>
{iconColor ? (
-
+
) : (
-
+
)}
)}
{typeof title === 'string' ? (
-
{title}
-
+
) : (
{title}
)}
diff --git a/app-expo/src/components/ui/alert-dialog.tsx b/app-expo/src/components/ui/alert-dialog.tsx
index 98de67b..c146b70 100644
--- a/app-expo/src/components/ui/alert-dialog.tsx
+++ b/app-expo/src/components/ui/alert-dialog.tsx
@@ -1,6 +1,7 @@
import { buttonTextVariants, buttonVariants } from '@/components/ui/button';
import { NativeOnlyAnimatedView } from '@/components/ui/native-only-animated-view';
import { TextClassContext } from '@/components/ui/text';
+import { useTypography } from '@/core/typography-context';
import { cn } from '@/lib/utils';
import * as AlertDialogPrimitive from '@rn-primitives/alert-dialog';
import * as React from 'react';
@@ -97,11 +98,20 @@ function AlertDialogFooter({ className, ...props }: ViewProps) {
function AlertDialogTitle({
className,
+ style,
...props
}: AlertDialogPrimitive.TitleProps & React.RefAttributes) {
+ const typography = useTypography();
return (
);
@@ -113,15 +123,20 @@ function AlertDialogDescription({
...props
}: AlertDialogPrimitive.DescriptionProps &
React.RefAttributes) {
+ const typography = useTypography();
return (
) {
return (
-
+
);
}
@@ -147,7 +170,11 @@ function AlertDialogCancel({
return (
diff --git a/app-expo/src/components/ui/text.tsx b/app-expo/src/components/ui/text.tsx
index 6167be3..3dc1fc3 100644
--- a/app-expo/src/components/ui/text.tsx
+++ b/app-expo/src/components/ui/text.tsx
@@ -3,7 +3,13 @@ import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
-import { Platform, Text as RNText, type Role } from 'react-native';
+import {
+ Platform,
+ StyleSheet,
+ Text as RNText,
+ type Role,
+ type TextStyle,
+} from 'react-native';
/**
* Maps Text variant to design-token keys for fontSize and lineHeight.
@@ -122,6 +128,22 @@ const ARIA_LEVEL: Partial> = {
const TextClassContext = React.createContext(undefined);
+/** Android 上 lineHeight < fontSize 会裁切字形(如标题配 lineHeightTight) */
+const MIN_LINE_HEIGHT_RATIO = 1.2;
+
+function ensureMinimumLineHeight(style: TextStyle): TextStyle {
+ const fs = style.fontSize;
+ const lh = style.lineHeight;
+ if (typeof fs !== 'number' || typeof lh !== 'number') {
+ return style;
+ }
+ const minLh = Math.ceil(fs * MIN_LINE_HEIGHT_RATIO);
+ if (lh < minLh) {
+ return { ...style, lineHeight: minLh };
+ }
+ return style;
+}
+
function Text({
className,
asChild = false,
@@ -151,12 +173,19 @@ function Text({
}
: undefined;
+ const flatStyle = StyleSheet.flatten([
+ { textAlign: 'left' as const },
+ typographyStyle,
+ style,
+ ]) as TextStyle;
+ const resolvedStyle = ensureMinimumLineHeight(flatStyle);
+
return (
);
diff --git a/app-expo/src/core/typography-context.tsx b/app-expo/src/core/typography-context.tsx
index 713a059..61b7f55 100644
--- a/app-expo/src/core/typography-context.tsx
+++ b/app-expo/src/core/typography-context.tsx
@@ -1,12 +1,14 @@
/**
- * Typography context for large text mode (大字模式).
+ * Global typography from design tokens.
+ *
+ * - **关大字模式** → `typography.large`(舒适基准,不用过小的 `normal` 档)
+ * - **开大字模式** → `typography.xlarge`(一级页、正文等整体再放大一档)
+ *
+ * 对话气泡等仍可在页面内用 `largeText` 做更大一级的 token 选择。
*
* Uses React Context instead of NativeWind vars() because vars() returns empty
- * objects on native (iOS/Android), so CSS variables never cascade to children.
- * See: https://github.com/nativewind/nativewind/issues/1113
- *
- * NativeWind vars() docs (works on web only):
- * https://www.nativewind.dev/docs/api/vars
+ * objects on native (iOS/Android). See:
+ * https://github.com/nativewind/nativewind/issues/1113
*/
import React, {
createContext,
@@ -19,18 +21,23 @@ import { useAppSettings } from '@/hooks/use-app-settings';
import tokens from '../../design-tokens.json';
-export type TypographyMode = 'normal' | 'large';
+export type TypographyScale = 'large' | 'xlarge';
export type TypographyTokens = Record;
const TypographyContext = createContext(null);
+const SCALE: Record = {
+ large: tokens.typography.large as TypographyTokens,
+ xlarge: tokens.typography.xlarge as TypographyTokens,
+};
+
export function TypographyProvider({ children }: PropsWithChildren) {
const { largeText } = useAppSettings();
- const typography = useMemo(() => {
- const mode: TypographyMode = largeText ? 'large' : 'normal';
- return tokens.typography[mode] as TypographyTokens;
- }, [largeText]);
+ const typography = useMemo(
+ () => SCALE[largeText ? 'xlarge' : 'large'],
+ [largeText],
+ );
return (
diff --git a/app-expo/tailwind.config.js b/app-expo/tailwind.config.js
index e9da026..9a4ea2e 100644
--- a/app-expo/tailwind.config.js
+++ b/app-expo/tailwind.config.js
@@ -25,7 +25,9 @@ const typographyVars = (mode) =>
]),
);
-// Use default theme for Tailwind base (runtime theme switch via ThemeProvider)
+// Use default theme for Tailwind base (runtime theme switch via ThemeProvider).
+// Typography vars use the same comfortable scale as TypographyProvider (large),
+// not the compact `normal` tier — see typography-context.tsx
const defaultLight = tokens.colors.default.light;
const defaultDark = tokens.colors.default.dark;
@@ -35,7 +37,7 @@ const rootVariables = Object.fromEntries([
value,
]),
['--radius', px(tokens.radius.default)],
- ...Object.entries(typographyVars('normal')),
+ ...Object.entries(typographyVars('large')),
]);
const darkVariables = Object.fromEntries(