From 186375648d694d7364c6f7934501d9105e98ded2 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 12 May 2026 15:25:09 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E8=BD=AF=E8=91=97pdf=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + api/scripts/copyright_source_pdf.py | 584 ++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 api/scripts/copyright_source_pdf.py diff --git a/.gitignore b/.gitignore index 5cc752f..0890d63 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,9 @@ api/models/whisper/ # 脚本输出(预览 JSON/Markdown) api/scripts/output/ +# 软著:源码摘录 PDF(默认生成在仓库根目录) +/copyright_source_listing.pdf + certs/ # Git worktrees diff --git a/api/scripts/copyright_source_pdf.py b/api/scripts/copyright_source_pdf.py new file mode 100644 index 0000000..68d6829 --- /dev/null +++ b/api/scripts/copyright_source_pdf.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +软著申报用:源码整理生成 PDF(reportlab) + +默认扫描整个 monorepo 根目录(本脚本位于 api/scripts/,向上两级即为仓库根), +会包含后端 api、Expo 客户端 app-expo、评测 Web app-eval-web 等;也可用 --root 只扫某一子项目。 + +使用说明(简要): +1) 安装依赖:在 api 目录执行 uv sync(本仓库已含 reportlab) +2) 中文字体:可设置 CJK_MONO_FONT_PATH;默认可用 STSong-Light(Adobe CID)。苹方/冬青等常为 CFF, + ReportLab 无法直接嵌入。使用 STSong-Light 时,ASCII(含空格、英文代码)用内置 Courier 分段绘制, + 中文用 STSong,避免整行用 CID 时拉丁字距错位、挤在一起。 + 需要退回纯 TrueType 单字体(Menlo 等)时用 --no-cid。 +3) 修改下方「配置区」常量:软件全称、版本号、必要时改 SOURCE_ROOT / 输出路径、字体路径、后缀与跳过目录 +4) 运行(全仓默认,仅 .py / .ts(x) / .js(x) / .vue 等源码):cd api && uv run python scripts/copyright_source_pdf.py + 仅后端示例:... --root ../api + +注意: +- 空行会从统计与输出中剔除;行首行尾以外的空白(缩进)保留。 +- 总页数按 ceil(非空行总数/50) 估算;>60 页则取前 1500 行 + 后 1500 行,最终 60 页、页码 1–60。 +- 不足 60 页时输出全部非空行;最后一页若不足 50 行,用空行补齐到 50 行。 +""" + +from __future__ import annotations + +import argparse +import math +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import cm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas + +# 本文件位于 /api/scripts/,仓库根为其上两级目录 +REPO_ROOT = Path(__file__).resolve().parents[2] + +# ========================= +# 配置区(按项目修改;也可用命令行覆盖部分项) +# ========================= +SOFTWARE_FULL_NAME = "岁月留书" +VERSION = "V1.0.0" + +# 默认:整仓源码根(api + 各前端/工具子项目);仅申报名某一模块时可改为子路径或 CLI --root +SOURCE_ROOT = REPO_ROOT +OUTPUT_PDF = REPO_ROOT / "copyright_source_listing.pdf" + +# PDF 页眉/材料中出现的文件路径:将本机目录前缀替换为申报用虚拟根(如 heguangtongkun/life-echo/...) +FILE_PATH_ABSOLUTE_PREFIX = Path("/Users/kevin/Codes/hgtk") +FILE_PATH_DISPLAY_ROOT = "heguangtongkun" + +# 设为 None 时自动探测(macOS:Menlo.ttc / Songti.ttc 等);也可填本机 TTF/OTF 或 TTC 路径 +CJK_MONO_FONT_PATH: Path | None = None + +# STSong-Light(CID)与拉丁混排时 ReportLab 常把 ASCII 字距算错;ASCII 段单独用内置 Courier。 +ASCII_LATIN_FONT = "Courier" + + +@dataclass(frozen=True) +class RenderFonts: + """primary:中文等非 ASCII;latin_font 非空时 U+0000–U+007F 用 Courier。""" + + primary: str + latin_font: str | None = None + + +def _segment_latin_cjk(line: str) -> list[tuple[str, str]]: + if not line: + return [] + out: list[tuple[str, str]] = [] + buf: list[str] = [] + is_lat = ord(line[0]) < 128 + for ch in line: + lat = ord(ch) < 128 + if lat != is_lat: + out.append(("lat" if is_lat else "cjk", "".join(buf))) + buf = [ch] + is_lat = lat + else: + buf.append(ch) + out.append(("lat" if is_lat else "cjk", "".join(buf))) + return out + + +def _mixed_line_width(line: str, fonts: RenderFonts, font_size: float) -> float: + if fonts.latin_font is None: + return pdfmetrics.stringWidth(line, fonts.primary, font_size) + w = 0.0 + for kind, chunk in _segment_latin_cjk(line): + fn = fonts.latin_font if kind == "lat" else fonts.primary + w += pdfmetrics.stringWidth(chunk, fn, font_size) + return w + + +def _draw_line( + c: canvas.Canvas, + x: float, + y: float, + line: str, + fonts: RenderFonts, + font_size: float, +) -> None: + if fonts.latin_font is None: + c.setFont(fonts.primary, font_size) + c.drawString(x, y, line) + return + xpos = x + for kind, chunk in _segment_latin_cjk(line): + if not chunk: + continue + fn = fonts.latin_font if kind == "lat" else fonts.primary + c.setFont(fn, font_size) + c.drawString(xpos, y, chunk) + xpos += pdfmetrics.stringWidth(chunk, fn, font_size) + + +# 仅收录「可执行/可编译」源码:本仓为 Python + TS/JS 栈;排除 .md、.json、.yaml 等配置与文档。 +# 其它语言可自行向集合内追加后缀。 +INCLUDE_SUFFIXES: set[str] = { + ".py", + ".pyi", + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".vue", +} + +# 需要跳过的目录名(只比对路径每一段 name) +SKIP_DIR_NAMES: set[str] = { + ".git", + ".svn", + ".hg", + "node_modules", + "venv", + ".venv", + "env", + ".env", + ".idea", + ".vscode", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".next", + ".nuxt", + ".output", + ".turbo", + ".parcel-cache", + ".expo", + "dist", + "build", + "target", + "out", + "coverage", + "htmlcov", + "storybook-static", + "Pods", + ".gradle", + "DerivedData", +} + +# 是否在文件边界插入标记行 +ADD_FILE_MARKERS = True +FILE_MARKER_PREFIX = "// ===== " + +LINES_PER_PAGE = 50 +MAX_PAGES = 60 + +# 苹方在多数 macOS 上为 .ttc 内嵌 CFF 轮廓,ReportLab TTFont 无法载入;仍会先尝试以下路径(以防特例)。 +_PINGFANG_CANDIDATES: tuple[Path, ...] = ( + Path("/System/Library/Fonts/PingFang.ttc"), + Path( + "/System/Library/Fonts/Hiragino Sans GB.ttc" + ), # 冬青黑体,常与苹方同源 CFF,多半失败 + Path("/Library/Fonts/PingFang.ttc"), +) + + +def _try_register_stsong_cid() -> bool: + try: + pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) + return True + except Exception: + return False + + +def _register_stsong_cid_with_notice(*, pingfang_failed: bool) -> str: + if not _try_register_stsong_cid(): + return "" + if pingfang_failed: + print( + "已载入 PDF 简体中文:STSong-Light(Adobe Unicode CID)。" + "说明:系统「苹方/冬青」等常为 OpenType-CFF,ReportLab 无法写入该轮廓;" + "已改用内置宋体轮廓以保证中文可见(非苹方字形,软著材料通常可接受)。", + file=sys.stderr, + ) + else: + print( + "已载入 PDF 简体中文:STSong-Light(Adobe Unicode CID)。", + file=sys.stderr, + ) + return "STSong-Light" + + +# macOS:TrueType 轮廓 .ttc(Menlo、部分宋体/黑体);苹方多为 CFF,见上方说明。 +_MACOS_TTC_CANDIDATES: tuple[tuple[Path, int], ...] = ( + (Path("/System/Library/Fonts/Menlo.ttc"), 0), + (Path("/System/Library/Fonts/Supplemental/Songti.ttc"), 0), + (Path("/System/Library/Fonts/STHeiti Light.ttc"), 0), + (Path("/System/Library/Fonts/STHeiti Medium.ttc"), 0), +) + + +def _try_register_ttf_or_ttc(path: Path, font_name: str = "CodeCJK") -> bool: + """将字体注册为 font_name;.ttc 递增 subfontIndex 直至成功或无更多子字体。""" + if not path.is_file(): + return False + if path.suffix.lower() == ".ttc": + idx = 0 + while idx < 64: + try: + pdfmetrics.registerFont(TTFont(font_name, str(path), subfontIndex=idx)) + return True + except Exception as e: + msg = str(e).lower() + if "bad subfontindex" in msg: + break + if "subfontindex" in msg and "not in" in msg: + break + idx += 1 + return False + try: + pdfmetrics.registerFont(TTFont(font_name, str(path))) + return True + except Exception: + return False + + +def register_font(user_path: Path | None, *, no_cid: bool = False) -> RenderFonts: + """选择正文字体;STSong CID 时同时返回 Courier 供拉丁混排。""" + font_tt = "CodeCJK" + if user_path is not None and user_path.is_file(): + if _try_register_ttf_or_ttc(user_path, font_tt): + print(f"已载入 PDF 字体:{user_path}", file=sys.stderr) + return RenderFonts(primary=font_tt) + + pingfang_failed = False + if sys.platform == "darwin": + for p in _PINGFANG_CANDIDATES: + if _try_register_ttf_or_ttc(p, font_tt): + print(f"已载入 PDF 字体(苹方/平方相关):{p}", file=sys.stderr) + return RenderFonts(primary=font_tt) + pingfang_failed = any(p.is_file() for p in _PINGFANG_CANDIDATES) + + if not no_cid: + cid_name = _register_stsong_cid_with_notice(pingfang_failed=pingfang_failed) + if cid_name: + return RenderFonts(primary=cid_name, latin_font=ASCII_LATIN_FONT) + + if sys.platform == "darwin": + for ttc_path, sub in _MACOS_TTC_CANDIDATES: + if not ttc_path.is_file(): + continue + try: + pdfmetrics.registerFont( + TTFont(font_tt, str(ttc_path), subfontIndex=sub) + ) + label = "系统等宽" if "Menlo" in ttc_path.name else "系统字体" + print( + f"已载入 PDF 字体(macOS TrueType):{ttc_path}({label})", + file=sys.stderr, + ) + return RenderFonts(primary=font_tt) + except Exception: + continue + + for p in ( + Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + Path("/usr/share/fonts/truetype/noto/NotoSansMonoCJK-Regular.ttf"), + ): + if _try_register_ttf_or_ttc(p, font_tt): + print(f"已载入 PDF 字体(Linux 常见路径):{p}", file=sys.stderr) + return RenderFonts(primary=font_tt) + + if no_cid: + print( + "警告:已指定 --no-cid 且未找到可用 TrueType 轮廓字体,将使用 Courier。", + file=sys.stderr, + ) + else: + print( + "警告:未找到可用的中文矢量字体,将使用 Courier(中文可能无法显示)。", + file=sys.stderr, + ) + return RenderFonts(primary="Courier") + + +@dataclass(frozen=True) +class ExtractResult: + lines: list[str] + pages: int + capped: bool + original_non_empty_lines: int + original_pages_ceil: int + + +def should_skip_dir(name: str) -> bool: + return name in SKIP_DIR_NAMES + + +def iter_source_files(root: Path) -> Iterable[Path]: + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + dirnames[:] = [d for d in dirnames if not should_skip_dir(d)] + for fn in filenames: + p = Path(dirpath) / fn + if p.suffix.lower() in INCLUDE_SUFFIXES: + yield p + + +def path_for_pdf_listing(fp: Path) -> str: + """将绝对路径转为 PDF 展示路径:/Users/.../hgtk/foo -> heguangtongkun/foo。""" + try: + resolved = fp.resolve() + base = FILE_PATH_ABSOLUTE_PREFIX.expanduser().resolve() + rel = resolved.relative_to(base) + return f"{FILE_PATH_DISPLAY_ROOT}/{rel.as_posix()}" + except ValueError: + return fp.as_posix() + + +def read_nonempty_lines_from_files(files: list[Path]) -> list[str]: + out: list[str] = [] + for fp in files: + if ADD_FILE_MARKERS: + out.append(f"{FILE_MARKER_PREFIX}{path_for_pdf_listing(fp)} =====") + try: + text = fp.read_text(encoding="utf-8", errors="replace") + except OSError: + text = fp.read_text(encoding="latin-1", errors="replace") + + for raw in text.splitlines(): + line = raw.rstrip("\n") + if line.strip() == "": + continue + out.append(line) + return out + + +def extract_for_pdf( + lines: list[str], + *, + lines_per_page: int = LINES_PER_PAGE, + max_pages: int = MAX_PAGES, +) -> ExtractResult: + original_non_empty_lines = len(lines) + original_pages_ceil = max(1, math.ceil(original_non_empty_lines / lines_per_page)) + + capped = original_pages_ceil > max_pages + if capped: + first_n = lines_per_page * (max_pages // 2) + last_n = lines_per_page * (max_pages // 2) + selected = lines[:first_n] + lines[-last_n:] + else: + selected = list(lines) + + pages = max(1, math.ceil(len(selected) / lines_per_page)) + return ExtractResult( + lines=selected, + pages=pages, + capped=capped, + original_non_empty_lines=original_non_empty_lines, + original_pages_ceil=original_pages_ceil, + ) + + +def pad_to_multiple(lines: list[str], lines_per_page: int) -> list[str]: + if not lines: + return [""] * lines_per_page + remainder = len(lines) % lines_per_page + if remainder == 0: + return lines + return lines + [""] * (lines_per_page - remainder) + + +def fit_line_to_width( + line: str, fonts: RenderFonts, font_size: float, max_width: float +) -> str: + if _mixed_line_width(line, fonts, font_size) <= max_width: + return line + lo, hi = 0, len(line) + while lo < hi: + mid = (lo + hi + 1) // 2 + if _mixed_line_width(line[:mid], fonts, font_size) <= max_width: + lo = mid + else: + hi = mid - 1 + ell = "…" + ell_w = _mixed_line_width(ell, fonts, font_size) + while lo > 0 and _mixed_line_width(line[:lo], fonts, font_size) + ell_w > max_width: + lo -= 1 + if lo <= 0: + return ell + return line[:lo] + ell + + +def draw_pdf( + out_path: Path, + all_lines: list[str], + *, + fonts: RenderFonts, + lines_per_page: int, + software_name: str, + version: str, + font_size: float = 8.5, +) -> None: + page_w, page_h = A4 + left = 2 * cm + right = 2 * cm + top = 2 * cm + bottom = 2 * cm + + sep_gap = 0.25 * cm + + c = canvas.Canvas(str(out_path), pagesize=A4) + c.setTitle(f"{software_name} {version} — 源代码节选") + c.setAuthor(software_name) + + header_baseline_y = page_h - top - 0.35 * cm + sep_y = header_baseline_y - 0.35 * cm + + content_top = sep_y - sep_gap + content_bottom = bottom + 0.5 * cm + if lines_per_page <= 1: + line_step = max(content_top - content_bottom, 1.0) + else: + line_step = (content_top - content_bottom) / (lines_per_page - 1) + + max_text_width = page_w - left - right + truncated_count = 0 + + n = len(all_lines) + if n % lines_per_page != 0: + raise ValueError("all_lines 长度必须是 lines_per_page 的整数倍(请先 pad)") + + total_pages = n // lines_per_page + + for page_no in range(1, total_pages + 1): + left_text = f"{software_name} {version}" + _draw_line(c, left, header_baseline_y, left_text, fonts, font_size) + + page_label = str(page_no) + page_font = fonts.latin_font or fonts.primary + pw = pdfmetrics.stringWidth(page_label, page_font, font_size) + c.setFont(page_font, font_size) + c.drawString(page_w - right - pw, header_baseline_y, page_label) + + c.setLineWidth(0.3) + c.line(left, sep_y, page_w - right, sep_y) + + start = (page_no - 1) * lines_per_page + page_slice = all_lines[start : start + lines_per_page] + + for i, raw in enumerate(page_slice): + y = content_top - i * line_step + fitted = fit_line_to_width(raw, fonts, font_size, max_text_width) + if fitted != raw and raw: + truncated_count += 1 + _draw_line(c, left, y, fitted, fonts, font_size) + + c.showPage() + + if truncated_count: + print( + f"提示:共有 {truncated_count} 行因超宽被截断(可减小字号或换更窄字体)。", + file=sys.stderr, + ) + + c.save() + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser( + description="将源码目录整理为软著申报用 PDF(reportlab)", + ) + p.add_argument( + "--root", + type=Path, + default=None, + help="源码根目录(默认使用脚本内 SOURCE_ROOT)", + ) + p.add_argument( + "--out", + type=Path, + default=None, + help="输出 PDF 路径(默认使用脚本内 OUTPUT_PDF)", + ) + p.add_argument( + "--font", + type=Path, + default=None, + help="CJK 等宽字体 TTF/OTF(默认使用脚本内 CJK_MONO_FONT_PATH)", + ) + p.add_argument( + "--name", + default=None, + help="软件全称(默认脚本内 SOFTWARE_FULL_NAME)", + ) + p.add_argument( + "--version", + dest="version_str", + default=None, + help="版本号(默认脚本内 VERSION)", + ) + p.add_argument( + "--font-size", + type=float, + default=8.3, + help="正文字号(略小可减少超长行截断,默认 8.3)", + ) + p.add_argument( + "--no-cid", + action="store_true", + help="不使用 Adobe STSong-Light(CID),仅尝试 TrueType 轮廓(Menlo 等),中文更易缺失", + ) + return p.parse_args() + + +def main() -> int: + args = parse_args() + root = args.root or SOURCE_ROOT + out_pdf = args.out or OUTPUT_PDF + font_path = args.font + if font_path is None: + font_path = CJK_MONO_FONT_PATH + name = args.name or SOFTWARE_FULL_NAME + ver = args.version_str or VERSION + + if not root.is_dir(): + print(f"错误:源码根目录不存在或不是目录:{root}", file=sys.stderr) + return 1 + + files = sorted(iter_source_files(root), key=lambda p: p.as_posix()) + lines = read_nonempty_lines_from_files(files) + result = extract_for_pdf(lines) + padded = pad_to_multiple(result.lines, LINES_PER_PAGE) + + fonts = register_font(font_path, no_cid=args.no_cid) + + out_pdf.parent.mkdir(parents=True, exist_ok=True) + draw_pdf( + out_pdf, + padded, + fonts=fonts, + lines_per_page=LINES_PER_PAGE, + software_name=name, + version=ver, + font_size=args.font_size, + ) + + out_pages = len(padded) // LINES_PER_PAGE + print( + "完成:", + f"源非空行 {result.original_non_empty_lines}(按 50 行/页约 {result.original_pages_ceil} 页)", + f"节选后 {len(result.lines)} 行", + f"输出 {out_pdf} 共 {out_pages} 页", + f" capped={result.capped}", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c45a2c040b68346a40bbb9085d90b2d0bc3b5f00 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 13 May 2026 15:01:50 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix(expo):=20=E6=9A=82=E5=81=9C=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=9C=97=E8=AF=BB=E5=90=8E=E7=BB=A7=E7=BB=AD=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E6=9C=80=E6=96=B0=20TTS=20=E7=89=87=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePlayer:paused 且 tts_auto 时清空队列并重置,再播当前片段 - 用 statusRef 与暂停同步,避免 WS 紧连 enqueue 时状态滞后 - 补充 use-player 单测 - api: 调整 copyright_source_pdf 脚本 - docs: 新增软著《岁月时书》软件设计说明书 Co-authored-by: Cursor --- api/scripts/copyright_source_pdf.py | 6 +- .../src/features/voice/hooks/use-player.ts | 36 +- .../tests/features/voice/use-player.test.tsx | 40 ++ docs/软著-岁月时书-软件设计说明书.md | 520 ++++++++++++++++++ 4 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 docs/软著-岁月时书-软件设计说明书.md diff --git a/api/scripts/copyright_source_pdf.py b/api/scripts/copyright_source_pdf.py index 68d6829..7ff17f1 100644 --- a/api/scripts/copyright_source_pdf.py +++ b/api/scripts/copyright_source_pdf.py @@ -52,9 +52,9 @@ VERSION = "V1.0.0" SOURCE_ROOT = REPO_ROOT OUTPUT_PDF = REPO_ROOT / "copyright_source_listing.pdf" -# PDF 页眉/材料中出现的文件路径:将本机目录前缀替换为申报用虚拟根(如 heguangtongkun/life-echo/...) +# PDF 页眉/材料中出现的文件路径:将本机目录前缀替换为申报用单位根(如 上海华嘎科技有限公司/life-echo/...) FILE_PATH_ABSOLUTE_PREFIX = Path("/Users/kevin/Codes/hgtk") -FILE_PATH_DISPLAY_ROOT = "heguangtongkun" +FILE_PATH_DISPLAY_ROOT = "上海华嘎科技有限公司" # 设为 None 时自动探测(macOS:Menlo.ttc / Songti.ttc 等);也可填本机 TTF/OTF 或 TTC 路径 CJK_MONO_FONT_PATH: Path | None = None @@ -330,7 +330,7 @@ def iter_source_files(root: Path) -> Iterable[Path]: def path_for_pdf_listing(fp: Path) -> str: - """将绝对路径转为 PDF 展示路径:/Users/.../hgtk/foo -> heguangtongkun/foo。""" + """将绝对路径转为 PDF 展示路径:.../hgtk/foo -> 上海华嘎科技有限公司/foo。""" try: resolved = fp.resolve() base = FILE_PATH_ABSOLUTE_PREFIX.expanduser().resolve() diff --git a/app-expo/src/features/voice/hooks/use-player.ts b/app-expo/src/features/voice/hooks/use-player.ts index 1c2c7c7..57380b5 100644 --- a/app-expo/src/features/voice/hooks/use-player.ts +++ b/app-expo/src/features/voice/hooks/use-player.ts @@ -40,6 +40,8 @@ export function usePlayer(): UsePlayerResult { const isPlayingRef = useRef(false); const wasBlockedByRecorderRef = useRef(false); const isPlayNextInProgressRef = useRef(false); + /** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */ + const statusRef = useRef('idle'); /** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */ const playbackActiveUriRef = useRef(null); /** 当前 source 是否已进入过 playing=true,避免换源瞬间 playerStatus 仍带上一首的 duration 而误判「已播完」。 */ @@ -63,6 +65,10 @@ export function usePlayer(): UsePlayerResult { const player = useAudioPlayer(currentSource, playerOptions); const playerStatus = useAudioPlayerStatus(player); + useEffect(() => { + statusRef.current = status; + }, [status]); + /** * 必须在 `isLoaded` 之后再 `play()`。 * expo-audio 在 `downloadFirst: true` 时先用 null 建 player,再在内部 effect 里异步 @@ -86,6 +92,7 @@ export function usePlayer(): UsePlayerResult { playbackActiveUriRef.current = null; setCurrentPlaybackItem(null); setCurrentSource(null); + statusRef.current = 'idle'; setStatus('idle'); setQueueLength(0); await audioFocus.releaseIfOwnedBy('player'); @@ -100,6 +107,7 @@ export function usePlayer(): UsePlayerResult { * `wasBlockedByRecorderRef` 不会被置位,录音结束后也不会重试 playNext。 */ wasBlockedByRecorderRef.current = true; + statusRef.current = 'idle'; setStatus('idle'); return; } @@ -108,6 +116,7 @@ export function usePlayer(): UsePlayerResult { const next = queueRef.current.shift()!; setQueueLength(queueRef.current.length); + statusRef.current = 'playing'; setStatus('playing'); trackHasPlayedRef.current = false; playbackActiveUriRef.current = next.uri; @@ -150,6 +159,7 @@ export function usePlayer(): UsePlayerResult { player.pause(); } isPlayingRef.current = false; + statusRef.current = 'paused'; return 'paused'; }); }, [player]); @@ -158,12 +168,14 @@ export function usePlayer(): UsePlayerResult { if (status !== 'paused') return; const acquired = await audioFocus.acquireForPlayback(); if (!acquired) { + statusRef.current = 'idle'; setStatus('idle'); return; } if (!player) return; if (!playerStatus.isLoaded) return; player.play(); + statusRef.current = 'playing'; setStatus('playing'); isPlayingRef.current = true; }, [status, player, playerStatus.isLoaded]); @@ -192,6 +204,26 @@ export function usePlayer(): UsePlayerResult { const enqueue = useCallback( async (item: PlaybackItem) => { + /** + * 用户在助手自动朗读中途点暂停时,`playbackActiveUriRef` 仍指向当前条, + * 后续 `tts_auto` 只会堆在队列里且不会 `playNext`。 + * 新片段到达时表示「最新已生成」:清掉暂停态与积压队列,只播本条。 + */ + if (item.kind === 'tts_auto' && statusRef.current === 'paused') { + queueRef.current = []; + setQueueLength(0); + isPlayingRef.current = false; + if (player) { + player.pause(); + } + playbackActiveUriRef.current = null; + setCurrentPlaybackItem(null); + setCurrentSource(null); + statusRef.current = 'idle'; + setStatus('idle'); + await audioFocus.releaseIfOwnedBy('player'); + } + queueRef.current.push(item); setQueueLength(queueRef.current.length); @@ -202,7 +234,7 @@ export function usePlayer(): UsePlayerResult { await playNext(); } }, - [playNext], + [playNext, player], ); const enqueueExclusive = useCallback( @@ -216,6 +248,7 @@ export function usePlayer(): UsePlayerResult { playbackActiveUriRef.current = null; setCurrentPlaybackItem(null); setCurrentSource(null); + statusRef.current = 'idle'; setStatus('idle'); await audioFocus.releaseIfOwnedBy('player'); await playNext(); @@ -235,6 +268,7 @@ export function usePlayer(): UsePlayerResult { playbackActiveUriRef.current = null; setCurrentPlaybackItem(null); setCurrentSource(null); + statusRef.current = 'idle'; setStatus('idle'); await audioFocus.releaseIfOwnedBy('player'); }, [player]); diff --git a/app-expo/tests/features/voice/use-player.test.tsx b/app-expo/tests/features/voice/use-player.test.tsx index fe5e47a..0bdd40d 100644 --- a/app-expo/tests/features/voice/use-player.test.tsx +++ b/app-expo/tests/features/voice/use-player.test.tsx @@ -167,4 +167,44 @@ describe('usePlayer', () => { expect(acquire).toHaveBeenCalledTimes(2); expect(play).toHaveBeenCalled(); }); + + test('after pause, new tts_auto clears backlog and kicks playNext', async () => { + mockUseAudioPlayerStatus.mockReturnValue({ + isLoaded: true, + playing: false, + currentTime: 0.1, + duration: 10, + }); + const pause = jest.fn(); + const play = jest.fn(); + mockUseAudioPlayer.mockReturnValue({ pause, play }); + + const { result } = renderHook(() => usePlayer()); + + await act(async () => { + await result.current.enqueue({ + uri: 'file:///first.mp3', + kind: 'tts_auto', + }); + }); + + expect(result.current.status).toBe('playing'); + const playCountAfterFirst = play.mock.calls.length; + + act(() => { + result.current.pausePlayback(); + }); + expect(result.current.status).toBe('paused'); + + await act(async () => { + await result.current.enqueue({ + uri: 'file:///latest.mp3', + kind: 'tts_auto', + }); + }); + + expect(result.current.status).toBe('playing'); + expect(play.mock.calls.length).toBeGreaterThan(playCountAfterFirst); + expect(result.current.currentSource).toBe('file:///latest.mp3'); + }); }); diff --git a/docs/软著-岁月时书-软件设计说明书.md b/docs/软著-岁月时书-软件设计说明书.md new file mode 100644 index 0000000..2c1d17a --- /dev/null +++ b/docs/软著-岁月时书-软件设计说明书.md @@ -0,0 +1,520 @@ +# 软件设计说明书(兼操作手册) + +--- + +## 文档封面信息 + + +| 项目 | 内容 | +| ------ | ------------- | +| 软件全称 | 岁月留书 | +| 英文名称 | Life Echo | +| 文档名称 | 《岁月留书》软件设计说明书 | +| 文档版本 | 1.0 | +| 对应软件版本 | 1.0 | +| 著作权人 | 上海华嘎科技有限公司 | +| 编写单位 | 上海华嘎科技有限公司 | +| 编写日期 | 2026年5月12日 | + + +--- + +## 第 1 章 引言 + +### 1.1 编写目的 + +本说明书描述「岁月留书(Life Echo)」一体化软件系统在代码仓库层面的组成结构、总体架构、主要功能规格、核心业务流程、开发与测试状况以及典型环境下的安装与运维使用方法。 + +### 1.2 适用范围 + +本说明书适用于以下组成部分的整体说明: + +1. **后端 API 服务**:路径 `api/`,基于 FastAPI,对外提供 REST API、WebSocket 实时对话能力及静态资源挂载。 +2. **客户端应用**:路径 `app-expo/`,基于 Expo Router 与 React Native,面向终端用户提供登录、对话、回忆录浏览与导出相关交互界面。 +3. **内部评测前端**:路径 `app-eval-web/`,基于 Vite,用于开发与回归评测场景(一般不纳入终端用户交付镜像,与内部评测 API 配合使用)。 +4. **工程与运维文档**:路径 `docs/`,存放与本项目相关的运维、设计备忘等资料;本说明书为其中面向著作权登记的汇总文档。 + +### 1.3 术语与缩写 + + +| 术语 | 含义 | +| ------------------ | ----------------------------------------------------- | +| JWT | JSON Web Token,访问令牌与刷新令牌组合的认证机制 | +| WebSocket | 全双工通信协议,本项目中用于实时语音与文本对话管线 | +| ASR | Automatic Speech Recognition,语音转文字 | +| TTS | Text-To-Speech,文字转语音 | +| LLM | Large Language Model,大语言模型;后端通过适配层调用兼容 OpenAI API 的服务 | +| Agent | 基于 LangChain 组织的对话与回忆录编排逻辑单元 | +| ChatOrchestrator | 实时对话编排入口,负责将用户输入路由至画像采集或访谈对话等分支 | +| MemoirOrchestrator | 回忆录正文流水线编排入口,负责分段调度抽取、分类与各 Specialist Agent | +| Celery | Python 异步分布式任务队列,用于回忆录 Phase 处理、记忆压实、嵌入调度等后台作业 | +| Phase1 / Phase2 | 回忆录批量处理阶段的工程称谓:Phase1 侧重批次准备与派发,Phase2 按类别执行故事管线 | +| Redis | 内存数据结构存储,用作会话缓存与队列协调等 | +| PostgreSQL | 关系型数据库;会话消息等持久化真源存放于此 | +| pgvector | PostgreSQL 扩展,用于向量检索相关的记忆块存储与检索 | +| Alembic | 数据库 Schema 迁移工具;本项目禁止手写 DDL 绕过迁移脚本 | + + +--- + +## 第 2 章 系统概述 + +### 2.1 建设目标与业务边界 + +本软件面向「口述回忆录」类产品形态,建设目标包括: + +1. **实时智能访谈**:通过 WebSocket 维持长连接,在移动端采集用户语音或文本输入,经 ASR(若语音)形成可处理文本,再由 LLM 驱动的 Agent 结合传记结构与历史记忆检索结果生成引导性回复。 +2. **结构化回忆录生成**:将多轮对话中的有效叙事内容抽取、归类,按章节与阅读片段组织为可阅读的「回忆录」文稿,并支持章节级素材与图像任务(工程上区分为正文配图与章节封面等管线)。 +3. **可导出成品**:服务端将回忆录内容排版并生成 PDF 等可供用户留存与分享的文档形式(具体能力以当前 `features/memoir` 与相关服务实现为准)。 +4. **账户、套餐与商业化支撑**:提供用户注册登录、套餐(Plan)、配额(Quota)、订单与支付(含微信、支付宝等适配)等能力,保证多租户下的资源可控与可审计。 + +### 2.2 非功能需求概要 + + +| 类别 | 要求说明 | +| ----- | ----------------------------------------------------------------- | +| 实时性 | WebSocket 连接内消息顺序稳定;首轮「边播 TTS 边出字」模式下,服务端按协议先推送音频再推送对应文本分段 | +| 安全 | REST 接口使用 JWT;WebSocket 连接通过 Query 携带 `access_token` 校验身份 | +| 可运维 | 健康检查路由 `/health`;请求追踪依赖中间件注入 request id;日志采用统一结构化封装 | +| 可扩展 | 外部厂商能力集中在 `adapters`,业务仅依赖 `ports` 协议,便于替换 ASR/TTS/LLM/短信/对象存储等实现 | +| 数据一致性 | 会话消息以数据库表 `conversation_messages` 为权威存储,Redis 承担缓存与加速读取职责 | + + +### 2.3 系统边界图(逻辑) + +客户端(Expo 应用或评测前端)仅与本后端 HTTP/WebSocket 交互;后端再访问 PostgreSQL、Redis,并向 Celery Worker 投递异步任务;外部云服务包括 LLM、ASR、TTS、短信、对象存储及支付网关等,经由适配层调用。 + +--- + +## 第 3 章 总体设计与运行环境 + +### 3.1 Monorepo 目录组成 + +仓库根目录主要包含: + +``` +life-echo/ +├── api/ # 后端(FastAPI,Python,uv + Alembic + Celery) +├── app-expo/ # 移动端(Expo Router + React Native) +├── app-eval-web/ # 内部评测 Web(Vite) +├── docs/ # 设计与运维文档(含本说明书) +├── package.json # 根级脚本聚合(如 husky、子项目脚本转发) +└── README.md # 项目总览 +``` + +### 3.2 后端进程形态 + +1. **主 API 进程**:模块入口 `api/app/main.py` 创建 FastAPI 应用 `app`,挂载各业务 Router,注册 WebSocket 路径 `/ws/conversation/{conversation_id}`,并在启动阶段执行 Alembic 迁移、Redis 连接、ASR 就绪检查、微信支付客户端预初始化等。 +2. **内部评测 API 进程**(可选隔离部署):模块入口 `api/app/internal_main.py` 创建 `internal_app`,挂载 `evaluation` 相关路由,用于回归评测与会话对比,默认与主 API 进程分离以降低评测对线上入口的影响。 +3. **Celery Worker**:消费 memoir 处理、记忆压实、嵌入调度、记忆富化等任务队列;具体队列名与路由以 `tasks` 包及环境变量为准。 + +### 3.3 客户端组成 + +`app-expo` 采用文件式路由,源码主目录为 `src/app/`,典型路由分组包括: + + +| 路由分组 | 相对路径示例 | 用途说明 | +| ------- | --------------------------------------------------------------------------------------------------- | ------------------------- | +| 认证 | `(auth)/login.tsx`、`register.tsx`、`reset-password.tsx` | 登录、注册、重置密码 | +| 主导航 Tab | `(tabs)/index.tsx`、`memoir.tsx`、`profile.tsx` | 首页、回忆录、个人中心 | +| 对话与内容 | `(main)/conversation/[id].tsx`、`chapter/[id].tsx` | 单会话对话页、章节阅读 | +| 设置与合规 | `(main)/personal-info.tsx`、`export-data.tsx`、`delete-data.tsx`、`faq.tsx`、`feedback.tsx`、`about.tsx` | 个人信息、导入导出与删除数据、常见问题、反馈、关于 | +| 法律文档 | `legal/[type].tsx` | 法律类展示页 | + + +### 3.4 开发与运行依赖(摘要) + +后端建议环境:Python 3.10 及以上;依赖与锁文件由 `uv` 管理(`api/pyproject.toml` 与 `api/uv.lock`)。数据库使用 PostgreSQL;缓存与消息代理使用 Redis。容器编排可使用 `docker-compose` 系列文件(以 `api/` 目录下实际文件名为准)。 + +移动端使用 Node.js 生态执行 `npm install` 与 `npx expo start` 等命令。内部评测前端在 `app-eval-web/` 目录使用 Vite 开发服务器。 + +### 3.5 内部评测前端(app-eval-web) + +`app-eval-web` 为研发与质量保障使用的单页应用工程,默认仅监听本机开发端口(常见为 `5174`,以 Vite 控制台输出为准)。其通过 HTTP 调用 `internal_app` 暴露的评测 REST API,前缀固定为 `/internal/api/evaluation`(见 `api/app/internal_main.py` 中的 `include_router` 配置)。该前端**不作为**终端用户应用商店交付物的一部分描述,仅在说明「Monorepo 完整组成」时列出。 + +--- + +## 第 4 章 软件结构说明 + +### 4.1 后端分层架构 + +后端严格按功能域拆分,并遵守「路由不写业务 SQL、服务不直连三方 SDK」的边界约定: + + +| 层次 | 目录 | 职责 | +| ---- | --------------------------- | -------------------------------------------------------------- | +| 接口层 | `app/features/*/router.py` | 定义 REST 或协议入口,注入服务依赖,返回 Pydantic 模型 | +| 业务层 | `app/features/*/service.py` | 业务规则、事务边界、编排对多个 repo 与端口的调用 | +| 持久层 | `app/features/*/repo.py` | 数据库增删查改,不在此层随意 `commit`(由上层统一事务策略) | +| 领域模型 | `app/features/*/models.py` | SQLAlchemy ORM 模型 | +| 端口 | `app/ports/` | 协议(Protocol),描述 ASR、TTS、LLM、短信等能力 | +| 适配器 | `app/adapters/` | 具体厂商实现,如 DeepSeek、腾讯、OpenAI Whisper 等 | +| 智能体 | `app/agents/` | LangChain Agent 及 `ChatOrchestrator`、`MemoirOrchestrator` 等编排类 | +| 核心设施 | `app/core/` | 配置、数据库引擎、Redis、安全、日志、中间件、异常处理等 | +| 任务 | `api/tasks/` | Celery 应用与任务定义入口 | + + +### 4.2 功能域与源码对应关系 + +以下功能域均位于 `api/app/features/` 下,各自通常包含 `router`、`service`、`repo`(按需)、`schemas`、`deps` 等文件: + + +| 域 | 目录 | 主要职责 | +| --- | --------------- | ---------------------------------------------------- | +| 认证 | `auth/` | 注册、登录、短信验证码、JWT 签发与校验、头像预设等 | +| 用户 | `user/` | 用户资料、反馈等对外接口 | +| 对话 | `conversation/` | REST 会话管理、WebSocket 管线、`ChatOrchestrator` 调用、历史与额度守护 | +| 记忆 | `memory/` | 记忆块写入、向量检索、富化管道、压实服务、嵌入调度与对外查询接口 | +| 回忆录 | `memoir/` | 章节与阅读素材、PDF 服务、叙事安全、口述规范化、章节封面与正文图像任务、状态服务等 | +| 故事 | `story/` | 与篇章素材、同步写入、配图意图等相关的服务与仓储(与回忆录流水线协同) | +| 套餐 | `plan/` | 套餐目录与订购相关能力 | +| 配额 | `quota/` | 配额检查与扣减策略,供对话、回忆录、支付等场景注入使用 | +| 支付 | `payment/` | 订单、微信与支付宝等支付通道封装 | +| 任务 | `tasks/` | 任务状态查询等对前端的任务进度暴露 | +| 内容 | `content/` | TTS 等内容相关接口 | +| 资源 | `asset/` | 资源存取相关仓储与模型 | +| 评测 | `evaluation/` | 内部评测数据模型、打分、会话目录、回放、对比摘要等(由 `internal_main` 挂载) | + + +### 4.3 WebSocket 协议文件 + +工程在 `api/app/features/conversation/ws/protocol.md` 中维护 WebSocket 文本协议说明,包括连接 URL 形态、客户端到服务端与服务端到客户端的消息类型枚举、心跳与重连语义等,与实现代码共同构成可审查的契约说明。 + +--- + +## 第 5 章 功能规格说明 + +本章按业务域给出可测试的规格化描述:目标、典型输入、处理要点、输出与约束。 + +### 5.1 认证与用户(auth / user) + +**目标**:为全站提供可信身份,支撑受保护 REST 与 WebSocket。 + +**典型输入**:手机号与密码注册、登录;短信验证码发送与校验;刷新令牌请求。 + +**处理要点**:密码经安全哈希存储;签发短期访问令牌与长期刷新令牌;对外 schema 校验在 Pydantic 层完成。 + +**输出**:令牌对、用户基础资料、错误码与错误信息遵循 `app/core/errors.py` 约定的统一错误响应结构(含 `error_code`、`message`、`request_id`)。 + +**约束**:成功业务响应可为直接模型或字典,不必统一包装信封;敏感环境变量不得写入代码库。 + +### 5.2 对话与实时管线(conversation) + +**目标**:维持长连接会话,完成「语音或文本 →(可选 ASR)→ Agent 推理 →(可选 TTS)→ 客户端展示」闭环。 + +**典型输入**:`TEXT`、`AUDIO_SEGMENT`、`AUDIO_MESSAGE`、`TRANSCRIBE_ONLY`、`TTS_REQUEST`、`TTS_CANCEL`、`END_CONVERSATION`、心跳等,详见 `protocol.md`。 + +**处理要点**:连接 URL 为 `/ws/conversation/{conversation_id}?token={jwt_access_token}`;编排由 `ChatOrchestrator` 将用户消息路由到画像分支或访谈分支;会话真源写入 `conversation_messages`,Redis 用于加速与缓存;额度由 `quota` 相关守护在管线中发挥作用。 + +**输出**:`TRANSCRIPT`、`AGENT_RESPONSE` 分段、`TTS_AUDIO`、`MEMOIR_UPDATE`、`ERROR` 等下行消息。 + +**约束**:同一连接内消息顺序稳定;若本轮开启服务端 TTS,则每一助手分段先 `TTS_AUDIO` 再对应 `AGENT_RESPONSE`。 + +### 5.3 记忆与检索(memory) + +**目标**:将对话中可复用的事实与摘要以向量块形式持久化,支持后续会话与回忆录生成时的语义检索。 + +**典型输入**:会话轮次文本、用户标识、检索 query、富化开关相关配置。 + +**处理要点**:写入与切块、嵌入、可选 LLM 富化、定时压实(compaction)等由服务与 Celery 任务协同;异步与同步路径行为可能不同,工程文档中建议以 `docs/memory-retrieval.md`(若存在)等行为矩阵为准。 + +**输出**:检索到的记忆证据、供提示词注入的结构化片段、必要的追踪信息(若开启)。 + +**约束**:配置项如 `CHAT_MEMORY_RETRIEVAL_ENABLED`、`MEMORY_COMPACTION_ENABLED` 等由 `app/core/config.py` 集中管理,调参需结合运行环境与负载。 + +### 5.4 回忆录与 PDF(memoir) + +**目标**:将多轮叙事整理为结构化章节与阅读体验,并生成可导出的文档与配图。 + +**典型输入**:会话 id、回忆录 id、章节 id、导出请求、图像生成相关任务参数。 + +**处理要点**:`MemoirOrchestrator` 负责分段编排;Phase1 批次准备后 `process_memoir_phase1` 等 Celery 任务派发 Phase2,按类别进入 `run_story_pipeline_for_category_batch`;图像方面区分正文图 `generate_story_image` 与章节封面 `generate_chapter_cover` enqueue 逻辑;PDF 由 `pdf_service` 等模块调用排版引擎完成。 + +**输出**:章节文本、阅读片段、PDF 文件流或 URL、任务状态更新、经由 WebSocket 的 `MEMOIR_UPDATE` 通知。 + +**约束**:涉及对象存储 URI 时需经预签名或受控网关访问;长篇生成依赖队列与超时配置。 + +### 5.5 故事与素材(story) + +**目标**:承接回忆录流水线中篇章级写入与配图意图抽取等职责,与 `memoir` 域协同。 + +**典型输入**:批量单元规划结果、叙事路由 Agent 输出、图像意图字段。 + +**处理要点**:仓储与服务分层保持与全局架构一致;同步写入路径需考虑与数据库事务的一致性。 + +**输出**:持久化的故事单元记录、下游图像任务触发条件。 + +### 5.6 套餐与配额(plan / quota) + +**目标**:定义可售卖套餐与用户使用限额,避免超卖与滥用。 + +**典型输入**:套餐 id、用户当前订阅状态、对话轮次或回忆录导出等资源计量事件。 + +**处理要点**:`QuotaService` 供会话与回忆录等模块注入调用;不得在功能模块内硬编码配额 SQL。 + +**输出**:允许或拒绝决策、剩余额度字段(若接口暴露)。 + +### 5.7 支付与订单(payment) + +**目标**:完成下单、渠道拉起、回调处理与订单状态推进。 + +**典型输入**:支付方式枚举、订单金额、回调报文。 + +**处理要点**:微信与支付宝客户端由适配层封装;支付失败与幂等处理在服务层体现。 + +**输出**:支付参数包、订单状态、错误码。 + +### 5.8 任务进度(tasks) + +**目标**:向前端暴露长时间运行任务的状态,改善用户对回忆录生成等操作的感知。 + +**典型输入**:任务 id 或业务关联键。 + +**处理要点**:与 `task_tracker` 等核心组件协作(若启用)。 + +**输出**:状态枚举、进度百分比或步骤文案。 + +### 5.9 内容与朗读(content) + +**目标**:提供与朗读相关的服务端能力配合对话气泡交互。 + +**典型输入**:文本分段、音色参数(若支持)。 + +**处理要点**:与 TTS 适配层衔接;必要时写入缓存或以 URL 形式下发。 + +### 5.10 内部评测(evaluation) + +**目标**:支持离线或半离线环境下的会话回放、评分与对比报告生成,服务于研发质量保障。 + +**典型输入**:评测会话导入数据、评测脚本、评分 Rubric 版本。 + +**处理要点**:评测 HTTP API 运行在 `internal_app`;浏览器界面由 `app-eval-web` 调用上述 API。 + +**输出**:评测轨迹、对比摘要、门禁报告(gate report)等结构化结果。 + +--- + +## 第 6 章 核心业务流程与设计说明 + +### 6.1 逻辑分层与依赖关系 + +```mermaid +flowchart TB + subgraph clients [Clients] + ExpoApp[app-expo] + EvalWeb[app-eval-web] + end + subgraph backend [api FastAPI] + Router[features routers] + Service[services] + Repo[repos] + Agents[agents] + Ports[ports] + Adapters[adapters] + end + subgraph infra [Infrastructure] + PG[(PostgreSQL)] + Redis[(Redis)] + Celery[Celery workers] + end + ExpoApp --> Router + EvalWeb --> Router + Router --> Service + Service --> Repo + Service --> Agents + Service --> Ports + Ports --> Adapters + Repo --> PG + Service --> Redis + Service --> Celery +``` + + + +### 6.2 访问令牌认证与刷新(REST 概要) + +```mermaid +sequenceDiagram + participant C as Client + participant A as auth_router + participant S as AuthService + participant DB as PostgreSQL + C->>A: POST login credentials + A->>S: validate_and_issue_tokens + S->>DB: verify user password hash + DB-->>S: user row + S-->>A: access_token refresh_token + A-->>C: 200 JSON tokens + C->>A: GET protected with Authorization Bearer + A->>S: verify access_token + S-->>A: user context + A-->>C: 200 protected resource + C->>A: POST refresh with refresh_token + A->>S: rotate if valid + S-->>A: new token pair + A-->>C: 200 new tokens +``` + + + +### 6.3 WebSocket 对话主路径(概要) + +```mermaid +stateDiagram-v2 + [*] --> Connected: JWT valid connect + Connected --> Transcribing: AUDIO or AUDIO_SEGMENT + Transcribing --> AgentThink: TRANSCRIPT ready + Connected --> AgentThink: TEXT input + AgentThink --> StreamingReply: ChatOrchestrator yields + StreamingReply --> Connected: segments sent optional TTS + Connected --> Ending: END_CONVERSATION + Ending --> [*] + Connected --> Connected: PING PONG heartbeat +``` + + + +### 6.4 回忆录异步流水线(概要) + +```mermaid +flowchart LR + subgraph realtime [Realtime path] + WS[WebSocket pipeline] + CO[ChatOrchestrator] + DB1[(conversation_messages)] + end + subgraph batch [Batch path] + MO[MemoirOrchestrator] + P1[Phase1 Celery] + P2[Phase2 by category] + PDF[PDF service] + IMG[Image tasks] + end + WS --> CO + CO --> DB1 + DB1 --> MO + MO --> P1 + P1 --> P2 + P2 --> PDF + P2 --> IMG +``` + + + +### 6.5 记忆写入、富化与压实(概念) + +用户经对话产生的新素材经 `memory` 域服务切块与嵌入后进入 PostgreSQL/pgvector;可选富化任务进入独立 Celery 队列(如 `memory_idle`);压实任务按配置周期合并或清理旧块,以降低检索噪声与存储膨胀。详细行为以环境变量开关与代码内服务实现为准。 + +--- + +## 第 7 章 接口与数据概要 + +### 7.1 REST 与 WebSocket 入口形态 + +主应用 `app` 注册的路由前缀以各 `router` 定义为准;OpenAPI 文档在 `settings.enable_docs` 为真时可通过 `/docs` 与 `/redoc` 访问。WebSocket 固定路径模板为 `/ws/conversation/{conversation_id}`,鉴权参数为 Query 中的 `token`。 + +内部评测应用 `internal_app` 的评测相关接口前缀参见 `internal_main` 中的说明(例如 `/internal/api/evaluation/` 一类路径,以实际路由定义为准)。 + +### 7.2 统一错误响应 + +业务错误返回 JSON 结构包含字段:`error_code`、`message`、`request_id`,由全局异常处理器注册,保证客户端可程序化解析。 + +### 7.3 数据库与迁移 + +业务表模型分散于各 `features/*/models.py`,并在 `app/main.py` 中间接 import 以聚合到 `Base.metadata`。一切结构变更必须通过 `api/alembic` 版本脚本完成。应用启动时可在 `run_alembic_upgrade_at_startup` 中自动执行 `upgrade head`,生产可配置失败即退出。 + +### 7.4 核心实体(语义级) + + +| 实体 | 语义 | +| ---------------------- | ---------------------------- | +| User | 注册用户、凭据哈希、画像字段 | +| Conversation / Session | 对话容器,关联多条消息 | +| ConversationMessage | 单条用户或助手消息,WebSocket 与历史查询的真源 | +| Memoir / Chapter | 回忆录与章节树;阅读片段与导出依赖其内容 | +| MemoryChunk | 向量检索用的记忆块 | +| Order / Payment | 订单与支付流水 | +| Plan / Quota | 套餐定义与用户使用额度 | + + +--- + +## 第 8 章 开发状况与测试结果说明 + +### 8.1 当前实现状态(与仓库说明对齐) + +下列能力在根目录 `README.md` 中标记为已实现或持续演进,著作权文档在此做技术化转述: + +1. 用户注册与登录(手机号与密码)。 +2. 基于 JWT 的访问令牌与刷新令牌机制。 +3. 基于 WebSocket 的实时语音与文本对话。 +4. AI 引导访谈与画像采集分支(由 `ChatOrchestrator` 路由)。 +5. 语音识别与语音合成适配(具体厂商由配置与适配器决定)。 +6. 对话内容向章节与回忆录结构的整理与异步处理。 +7. 回忆录章节管理与 PDF 导出能力。 +8. Android 端本地数据能力(Room 等,详见客户端工程)。 +9. 离线数据同步相关能力(详见客户端工程)。 +10. 用户套餐与订单支付相关能力。 +11. 常见问题与反馈入口(用户域接口与客户端页面)。 + +标记为规划或进行中的能力(如更多传记模板、章节编辑深化、多语言等)以仓库 `README.md` 最新描述为准,本说明书不将其表述为已完成交付。 + +### 8.2 测试策略与自动化范围 + +后端测试以 `pytest` 为主,辅以 `httpx.AsyncClient` 与 `ASGITransport` 进行异步 HTTP 场景测试。项目规则强调: + +1. 测试应服务真实业务场景,避免仅为覆盖率堆砌无断言价值的用例。 +2. 优先覆盖核心用户路径、鉴权与配额、幂等性与关键持久化状态。 +3. 不优先测试第三方库自身行为或脆弱实现细节。 +4. 测试应用 `dependency_overrides` 替换数据库与外部依赖,避免直接 import 生产入口带来的环境副作用。 + +典型可自动化部分包括:注册登录与刷新链路、受保护资源访问、主要 CRUD 与失败分支。依赖真实 ASR、TTS、LLM、短信、支付沙箱的全链路测试多在手工或独立 E2E 环境执行。 + +### 8.3 测试结果表述方式建议 + +在向登记机关提交「测试结果」材料时,可将以下内容整理为附录表格或截图说明: + +1. 本地或 CI 执行 `uv run pytest` 的命令、通过用例数量与跳过用例原因。 +2. 关键模块的覆盖率报告(若政策允许附带 `pytest-cov` 摘要)。 +3. 手工测试用例清单:WebSocket 多轮对话、回忆录生成端到端、支付回调联调(若适用)。 + +本说明书不嵌入具体 CI 截图,以免与仓库演进不同步;导出 PDF 前由实施者替换为当期报告。 + +--- + +## 第 9 章 安装、配置与使用方法 + +### 9.1 获取源代码 + +通过版本控制系统克隆 `life-echo` 仓库,检查 out 的分支与标签与本次登记版本一致。 + +### 9.2 后端本地开发(推荐路径) + +在 `api/` 目录: + +1. 安装 `uv` 并执行 `uv sync --dev` 安装运行与开发依赖。 +2. 复制环境变量模板为 `.env`,按 `api/README.md` 填入数据库 URL、Redis URL、JWT 密钥、LLM 与 ASR 相关 Key 等。 +3. 使用 `docker compose -f docker-compose.dev.yml up -d` 或项目提供的一键脚本启动 PostgreSQL 与 Redis。 +4. 执行 `uv run alembic upgrade head`(若未依赖启动时自动迁移)。 +5. 执行 `uv run uvicorn main:app --reload --host 0.0.0.0 --port 8000` 或通过 `./dev-up.sh` 拉起 API 与 Celery。 + +环境变量优先级与 LLM 提供商选择逻辑以 `api/README.md` 与 `app/core/config.py` 为准。 + +### 9.3 内部评测入口 + +在 `api/` 目录执行 `uv run uvicorn app.internal_main:internal_app --host 0.0.0.0 --port 7999`(端口以实际环境变量为准)。在另一终端于 `app-eval-web/` 执行 `npm install` 与 `npm run dev`,通过浏览器访问 Vite 提示的本地地址操作评测台。 + +### 9.4 移动端使用 + +在 `app-expo/` 目录执行 `npm install` 与 `npx expo start`,按 Expo CLI 提示在模拟器或真机打开应用。将 API 基础地址指向已启动的后端(含合法 TLS 或开发环境豁免配置),完成登录后即可使用对话与回忆录功能。具体网络权限与麦克风权限以各平台 `app.json` / 插件配置为准。 + +### 9.5 生产部署注意 + +1. `SECRET_KEY` 与所有第三方密钥必须来自安全存储。 +2. CORS `allow_origins` 在生产应收敛为前端真实域名,而非通配。 +3. 建议将 `ALEMBIC_STARTUP_FAIL_FAST` 设为严格模式以避免实例在错误 Schema 下带病运行。 +4. Celery Worker 与 Beat 应与 API 分离进程或容器,并为 `memory_idle` 等专用队列配置合理并发。 + +### 9.6 日志与排错 + +业务代码使用 `app.core.logging.get_logger(__name__)` 取得 logger,便于按 request id 关联。出现 4xx/5xx 时优先检查错误体中的 `request_id` 与服务端日志同一时间窗口。 \ No newline at end of file From 6f6ac0d550e9864aca07487d43aeaa05f7fa84e0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 13 May 2026 16:15:21 +0800 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20=E5=A4=B4=E5=83=8F=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=89=88=E6=9C=AC=E5=8F=B7=EF=BC=9B=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=9B=9E=E5=BF=86=E5=BD=95=E6=92=B0=E5=86=99=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC=E8=81=8A=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API:上传/预设头像 URL 追加 ?v=time.time_ns(),避免同路径缓存导致「只能换一次头像」 - Expo:回忆录 Tab 去掉撰写中「继续写作」及新建会话跳转;清理 memoir 文案键并更新 i18n 类型 未纳入提交:本地 api/uploads/(开发环境头像文件) Co-authored-by: Cursor --- api/app/features/auth/router.py | 6 +- app-expo/src/app/(tabs)/memoir.tsx | 22 - app-expo/src/i18n/generated/resources.ts | 490 ++++++++++++----------- app-expo/src/i18n/locales/en/memoir.json | 2 - app-expo/src/i18n/locales/zh/memoir.json | 2 - 5 files changed, 250 insertions(+), 272 deletions(-) diff --git a/api/app/features/auth/router.py b/api/app/features/auth/router.py index 92c2a97..799cea9 100644 --- a/api/app/features/auth/router.py +++ b/api/app/features/auth/router.py @@ -1,4 +1,5 @@ import io +import time from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status @@ -331,7 +332,8 @@ async def upload_avatar( image.save(file_path, "JPEG", quality=85, optimize=True) - avatar_url = f"/api/auth/avatars/{filename}" + # 路径固定为 {user_id}.jpg,客户端会缓存;每次写入新文件后 bump URL 以绕过缓存。 + avatar_url = f"/api/auth/avatars/{filename}?v={time.time_ns()}" user = await service.update_avatar_url(current_user.id, avatar_url) return _user_response(user) except HTTPException: @@ -379,7 +381,7 @@ async def set_avatar_preset( status_code=status.HTTP_400_BAD_REQUEST, detail="预设头像不可用", ) - avatar_url = avatar_url_for_preset_filename(filename) + avatar_url = f"{avatar_url_for_preset_filename(filename)}?v={time.time_ns()}" try: user = await service.update_avatar_url(current_user.id, avatar_url) except AuthError as e: diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx index 5b539c7..816bdc9 100644 --- a/app-expo/src/app/(tabs)/memoir.tsx +++ b/app-expo/src/app/(tabs)/memoir.tsx @@ -24,7 +24,6 @@ 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, mergeFrameworkChaptersWithFetched, @@ -70,13 +69,11 @@ function ChapterCard({ variant, t, onReadPress, - onContinuePress, }: { item: ChapterViewModel; variant: ChapterVariant; t: (key: string) => string; onReadPress: () => void; - onContinuePress: () => void; }) { const typography = useTypography(); const { width } = useWindowDimensions(); @@ -238,15 +235,6 @@ function ChapterCard({ - - - {t('continueWriting')} - - ); } @@ -279,7 +267,6 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) { export default function MemoirScreen() { const { t } = useTranslation('memoir'); const { viewModels: chapters, isLoading, isError, refetch } = useChapters(); - const createConversation = useCreateConversation(); const checkCover = useCheckCoverGeneration(); const [refreshing, setRefreshing] = useState(false); const didRunInitialCoverCheckRef = useRef(false); @@ -310,14 +297,6 @@ export default function MemoirScreen() { } }, [checkCover, refetch]); - const handleStartChapter = useCallback(() => { - createConversation.mutate(undefined, { - onSuccess: (result) => { - router.push(`/(main)/conversation/${result.id}`); - }, - }); - }, [createConversation]); - const handleReadChapter = useCallback((chapterId: string) => { router.push(`/(main)/chapter/${chapterId}`); }, []); @@ -357,7 +336,6 @@ export default function MemoirScreen() { variant={getChapterVariant(item)} t={t as (key: string) => string} onReadPress={() => handleReadChapter(item.id)} - onContinuePress={handleStartChapter} /> )) )} diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index 337753a..de4082a 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -1,249 +1,251 @@ // This file is automatically generated by i18next-cli. Do not edit manually. interface Resources { - app: { - languages: { - en: 'English'; - system: 'System'; - zh: 'Chinese'; - }; - name: 'Life Echo'; - tabs: { - conversations: 'Chats'; - explore: 'Explore'; - home: 'Home'; - memoir: 'Memoir'; - profile: 'Profile'; - }; - theme: { - default: 'Default'; - }; - }; - auth: { - login: { - codeLabel: 'Verification Code'; - getCode: 'Get Code'; - getCodeCountdown: 'Retry in {{seconds}}s'; - networkError: 'Network error. Please try again later.'; - phoneLabel: 'Phone Number'; - phonePlaceholder: 'Enter your phone number'; - privacyPolicy: 'Privacy Policy'; - submit: 'Login'; - termsAnd: 'and'; - termsIntro: 'I agree to the'; - termsRequired: 'Please agree to the User Agreement and Privacy Policy first'; - termsRequiredConfirm: 'OK'; - termsRequiredTitle: 'Agreement Required'; - userAgreement: 'User Agreement'; - welcomeSubtitle: 'Some lives grow richer the more you savor them.'; - welcomeTitle: 'Welcome back'; - }; - }; - common: { - chapterLabel: ''; - chapterReading: { - backgroundColor: ''; - bgPureWhite: ''; - bgSepia: ''; - close: ''; - fontSize: ''; - readingSettings: ''; - typography: ''; - }; - continueWriting: ''; - docs: 'Docs'; - emptySubtitle: ''; - emptyTitle: ''; - readMemory: ''; - startChapter: ''; - statusDrafting: ''; - statusLocked: ''; - statusPending: ''; - wordsCount: ''; - }; - conversation: { - addMore: 'More'; - agentName: 'Life Echo'; - assistantReplying: 'Replying…'; - cancel: 'Cancel'; - cancelRecording: 'Cancel recording'; - cannotReadAloud: 'Read unavailable'; - chatQueueSendTimeout: 'Connection timed out. Check your network and try again.'; - chatTitle: 'Conversation'; - chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.'; - chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.'; - chatUnavailableTitle: 'Chat unavailable'; - confirm: 'OK'; - confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.'; - connectionConnected: 'Connected'; - connectionConnecting: 'Connecting...'; - connectionDisconnected: 'Disconnected'; - createError: 'Unable to create conversation. Please check your network and try again.'; - delete: 'Delete'; - deleteConversation: 'Delete Conversation'; - emptyGreetingSubtitle: 'Chat with your companion and record your stories.'; - greetingTitle: 'Say Hello'; - inputPlaceholder: 'Type a message...'; - inputPlaceholderVoice: 'Type here or hold the mic to speak...'; - me: 'Me'; - readAloudAgain: 'Play again'; - readAloudPause: 'Pause reading'; - readAloudResume: 'Resume reading'; - readAloudRequest: 'Read aloud'; - readAloudRequestFailed: 'Could not start playback. Check your connection.'; - readAloudNoMessageId: 'This message is not ready for on-demand reading yet. Pull to refresh or try again.'; - readingAloud: 'Reading aloud…'; - recentChats: 'Recent Chats'; - recordingPermissionDenied: 'Microphone permission is required to record'; - recordingStartFailed: 'Unable to start recording. Please try again.'; - resumeChatSubtitle: 'Open your latest conversation to keep talking.'; - resumeChatTitle: 'Continue chatting'; - send: 'Send'; - startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.'; - stopReadingAloud: 'Stop reading aloud'; - switchToText: 'Switch to text input'; - switchToVoice: 'Switch to voice input'; - tapToEndRecording: 'Tap to end'; - tapToStartRecording: 'Tap to start recording'; - ttsThisTurn: 'Speak'; - ttsThisTurnAccessibility: 'When on, assistant replies synthesize speech before text appears.'; - topicSuggestionsDismiss: 'Hide'; - timeDaysAgo_one: '{{count}} day ago'; - timeDaysAgo_other: '{{count}} days ago'; - timeHoursAgo_one: '{{count}} hour ago'; - timeHoursAgo_other: '{{count}} hours ago'; - timeJustNow: 'Just now'; - timeMinutesAgo_one: '{{count}} minute ago'; - timeMinutesAgo_other: '{{count}} minutes ago'; - viewAll: 'View All'; - voiceMessagePreview: 'Voice message'; - }; - explore: {}; - home: {}; - legal: { - titlePrivacy: 'Privacy Policy'; - titleTerms: 'User Agreement'; - }; - memoir: { - chapterLabel: 'Chapter {{index}}'; - chapterReading: { - back: 'Back'; - backgroundColor: 'Background'; - bgPureWhite: 'White'; - bgSepia: 'Sepia'; - cancel: 'Cancel'; - chapterNotFound: 'Chapter not found'; - close: 'Close'; - confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.'; - deleteChapter: 'Delete Chapter'; - deleteChapterAction: 'Delete'; - fontSans: 'Sans'; - fontSerif: 'Serif'; - fontSize: 'Font Size'; - fontSizeDefault: 'Medium'; - fontSizeLarge: 'Large'; - fontSizeSmall: 'Small'; - readingSettings: 'Reading Settings'; - settings: 'Settings'; - typography: 'Typography'; - }; - continueWriting: 'Continue Writing'; - emptySubtitle: 'Chat with your companion to record your stories'; - emptyTitle: 'No memoir yet'; - frameworkChapters: { - chapter1: 'Childhood and upbringing'; - chapter2: 'Education and young adulthood'; - chapter3: 'Early career'; - chapter4: 'Major achievements and peak moments'; - chapter5: 'Setbacks, challenges, and turning points'; - chapter6: 'Family and relationships'; - chapter7: 'Beliefs and values'; - chapter8: 'Life summary'; - }; - loadErrorMessage: 'Could not load chapters'; - loadErrorRetry: 'Retry'; - pageTitle: 'Memoir'; - readMemory: 'Read Memory'; - startChapter: 'Start Writing'; - statusDrafting: 'Drafting'; - statusLocked: 'Locked'; - statusPending: 'Pending'; - wordsCount: '{{count}} words'; - }; - profile: { - about: { - aboutUs: 'About Us'; - title: 'About'; - }; - appExperience: { - language: 'Language'; - languageDesc: 'Display language'; - largeText: 'Large Text'; - largeTextDesc: 'Make reading easier'; - nightMode: 'Night Mode'; - nightModeDesc: 'Use dark theme'; - theme: 'Theme'; - themeDesc: 'Color theme'; - title: 'App Experience'; - }; - dataPrivacy: { - deleteAll: 'Delete All Data'; - deleteUnderDevelopment: 'Delete data feature is under development.'; - exportAll: 'Export All Data'; - exportUnderDevelopment: 'Export feature is under development.'; - purgeDialogCancel: 'Cancel'; - purgeDialogConfirm: 'Delete permanently'; - purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.'; - purgeDialogTitle: 'Final confirmation'; - purgeInputLabel: 'Confirmation phrase'; - purgeInputPlaceholder: 'Type the phrase shown above'; - purgeOpenConfirm: 'I understand, continue'; - purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:'; - purgeSubmitting: 'Deleting…'; - purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.'; - purgeWarningTitle: 'Before you continue'; - title: 'Data & Privacy'; - }; - editAvatar: 'Edit Profile Picture'; - helpSupport: { - faq: 'FAQ'; - feedback: 'Feedback & Support'; - feedbackPageTitle: 'Share your thoughts'; - title: 'Help & Support'; - }; - personalInfo: { - avatarPresetFailed: 'Could not set preset avatar'; - avatarUploadFailed: 'Could not upload avatar'; - birthPlacePlaceholder: 'Birthplace'; - birthYearPlaceholder: 'Birth year'; - cancel: 'Cancel'; - changeAvatar: 'Change photo'; - chooseFromLibrary: 'Choose from library'; - choosePreset: 'Preset avatars'; - grewUpPlaceholder: 'Where you grew up'; - libraryPermissionDenied: 'Photo library access is required to pick an image'; - nickname: 'Nickname'; - nicknamePlaceholder: 'Enter nickname'; - nicknameRequired: 'Please enter a nickname'; - occupationPlaceholder: 'Occupation'; - presetPickTitle: 'Choose a preset'; - save: 'Save'; - saveFailed: 'Could not save'; - savePartialBody: 'Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.'; - savePartialTitle: 'Partially saved'; - saving: 'Saving…'; - title: 'Personal info'; - }; - signOut: 'Sign Out'; - signingOut: 'Signing out...'; - tier: { - free: 'Free'; - pro: 'Pro'; - pro_plus: 'Pro+'; - test: 'Test'; - }; - userNamePlaceholder: 'User'; - userTier: '{{tier}}'; - }; + "app": { + "languages": { + "en": "English", + "system": "System", + "zh": "Chinese" + }, + "name": "Life Echo", + "tabs": { + "conversations": "Chats", + "explore": "Explore", + "home": "Home", + "memoir": "Memoir", + "profile": "Profile" + }, + "theme": { + "default": "Default" + } + }, + "auth": { + "login": { + "codeLabel": "Verification Code", + "getCode": "Get Code", + "getCodeCountdown": "Retry in {{seconds}}s", + "networkError": "Network error. Please try again later.", + "phoneLabel": "Phone Number", + "phonePlaceholder": "Enter your phone number", + "privacyPolicy": "Privacy Policy", + "submit": "Login", + "termsAnd": "and", + "termsIntro": "I agree to the", + "termsRequired": "Please agree to the User Agreement and Privacy Policy first", + "termsRequiredConfirm": "OK", + "termsRequiredTitle": "Agreement Required", + "userAgreement": "User Agreement", + "welcomeSubtitle": "Some lives grow richer the more you savor them.", + "welcomeTitle": "Welcome back" + } + }, + "common": { + "chapterLabel": "", + "chapterReading": { + "backgroundColor": "", + "bgPureWhite": "", + "bgSepia": "", + "close": "", + "fontSize": "", + "readingSettings": "", + "typography": "" + }, + "continueWriting": "", + "docs": "Docs", + "emptySubtitle": "", + "emptyTitle": "", + "readMemory": "", + "startChapter": "", + "statusDrafting": "", + "statusLocked": "", + "statusPending": "", + "wordsCount": "" + }, + "conversation": { + "addMore": "More", + "agentName": "Life Echo", + "assistantReplying": "Replying…", + "cancel": "Cancel", + "cancelRecording": "Cancel recording", + "cannotReadAloud": "Read unavailable", + "chatQueueSendTimeout": "Connection timed out. Check your network and try again.", + "chatTitle": "Conversation", + "chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.", + "chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.", + "chatUnavailableTitle": "Chat unavailable", + "confirm": "OK", + "confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.", + "connectionConnected": "Connected", + "connectionConnecting": "Connecting...", + "connectionDisconnected": "Disconnected", + "createError": "Unable to create conversation. Please check your network and try again.", + "delete": "Delete", + "deleteConversation": "Delete Conversation", + "emptyGreetingSubtitle": "Chat with your companion and record your stories.", + "greetingTitle": "Say Hello", + "inputPlaceholder": "Type a message...", + "inputPlaceholderVoice": "Type here or hold the mic to speak...", + "me": "Me", + "readAloudAgain": "Play again", + "readAloudNoMessageId": "This message is not ready for on-demand reading yet. Pull to refresh or try again.", + "readAloudPause": "Pause reading", + "readAloudRequest": "Read aloud", + "readAloudRequestFailed": "Could not start playback. Check your connection.", + "readAloudResume": "Resume reading", + "readingAloud": "Reading aloud…", + "recentChats": "Recent Chats", + "recordingPermissionDenied": "Microphone permission is required to record", + "recordingStartFailed": "Unable to start recording. Please try again.", + "resumeChatSubtitle": "Open your latest conversation to keep talking.", + "resumeChatTitle": "Continue chatting", + "send": "Send", + "startNewSubtitle": "Capture a new memory or share your thoughts with your companion.", + "stopReadingAloud": "Stop reading aloud", + "switchToText": "Switch to text input", + "switchToVoice": "Switch to voice input", + "tapToEndRecording": "Tap to end", + "tapToStartRecording": "Tap to start recording", + "timeDaysAgo_one": "{{count}} day ago", + "timeDaysAgo_other": "{{count}} days ago", + "timeHoursAgo_one": "{{count}} hour ago", + "timeHoursAgo_other": "{{count}} hours ago", + "timeJustNow": "Just now", + "timeMinutesAgo_one": "{{count}} minute ago", + "timeMinutesAgo_other": "{{count}} minutes ago", + "topicSuggestionsDismiss": "Hide", + "ttsThisTurn": "Speak", + "ttsThisTurnAccessibility": "When on, assistant replies synthesize speech before text appears.", + "viewAll": "View All", + "voiceMessagePreview": "Voice message" + }, + "explore": { + + }, + "home": { + + }, + "legal": { + "titlePrivacy": "Privacy Policy", + "titleTerms": "User Agreement" + }, + "memoir": { + "chapterLabel": "Chapter {{index}}", + "chapterReading": { + "back": "Back", + "backgroundColor": "Background", + "bgPureWhite": "White", + "bgSepia": "Sepia", + "cancel": "Cancel", + "chapterNotFound": "Chapter not found", + "close": "Close", + "confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.", + "deleteChapter": "Delete Chapter", + "deleteChapterAction": "Delete", + "fontSans": "Sans", + "fontSerif": "Serif", + "fontSize": "Font Size", + "fontSizeDefault": "Medium", + "fontSizeLarge": "Large", + "fontSizeSmall": "Small", + "readingSettings": "Reading Settings", + "settings": "Settings", + "typography": "Typography" + }, + "emptySubtitle": "Chat with your companion to record your stories", + "emptyTitle": "No memoir yet", + "frameworkChapters": { + "chapter1": "Childhood and upbringing", + "chapter2": "Education and young adulthood", + "chapter3": "Early career", + "chapter4": "Major achievements and peak moments", + "chapter5": "Setbacks, challenges, and turning points", + "chapter6": "Family and relationships", + "chapter7": "Beliefs and values", + "chapter8": "Life summary" + }, + "loadErrorMessage": "Could not load chapters", + "loadErrorRetry": "Retry", + "pageTitle": "Memoir", + "readMemory": "Read Memory", + "statusDrafting": "Drafting", + "statusLocked": "Locked", + "statusPending": "Pending", + "wordsCount": "{{count}} words" + }, + "profile": { + "about": { + "aboutUs": "About Us", + "title": "About" + }, + "appExperience": { + "language": "Language", + "languageDesc": "Display language", + "largeText": "Large Text", + "largeTextDesc": "Make reading easier", + "nightMode": "Night Mode", + "nightModeDesc": "Use dark theme", + "theme": "Theme", + "themeDesc": "Color theme", + "title": "App Experience" + }, + "dataPrivacy": { + "deleteAll": "Delete All Data", + "deleteUnderDevelopment": "Delete data feature is under development.", + "exportAll": "Export All Data", + "exportUnderDevelopment": "Export feature is under development.", + "purgeDialogCancel": "Cancel", + "purgeDialogConfirm": "Delete permanently", + "purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.", + "purgeDialogTitle": "Final confirmation", + "purgeInputLabel": "Confirmation phrase", + "purgeInputPlaceholder": "Type the phrase shown above", + "purgeOpenConfirm": "I understand, continue", + "purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:", + "purgeSubmitting": "Deleting…", + "purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.", + "purgeWarningTitle": "Before you continue", + "title": "Data & Privacy" + }, + "editAvatar": "Edit Profile Picture", + "helpSupport": { + "faq": "FAQ", + "feedback": "Feedback & Support", + "feedbackPageTitle": "Share your thoughts", + "title": "Help & Support" + }, + "personalInfo": { + "avatarPresetFailed": "Could not set preset avatar", + "avatarUploadFailed": "Could not upload avatar", + "birthPlacePlaceholder": "Birthplace", + "birthYearPlaceholder": "Birth year", + "cancel": "Cancel", + "changeAvatar": "Change photo", + "chooseFromLibrary": "Choose from library", + "choosePreset": "Preset avatars", + "grewUpPlaceholder": "Where you grew up", + "libraryPermissionDenied": "Photo library access is required to pick an image", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "nicknameRequired": "Please enter a nickname", + "occupationPlaceholder": "Occupation", + "presetPickTitle": "Choose a preset", + "save": "Save", + "saveFailed": "Could not save", + "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", + "savePartialTitle": "Partially saved", + "saving": "Saving…", + "title": "Personal info" + }, + "signOut": "Sign Out", + "signingOut": "Signing out...", + "tier": { + "free": "Free", + "pro": "Pro", + "pro_plus": "Pro+", + "test": "Test" + }, + "userNamePlaceholder": "User", + "userTier": "{{tier}}" + } } export default Resources; diff --git a/app-expo/src/i18n/locales/en/memoir.json b/app-expo/src/i18n/locales/en/memoir.json index 713e6db..7c072ec 100644 --- a/app-expo/src/i18n/locales/en/memoir.json +++ b/app-expo/src/i18n/locales/en/memoir.json @@ -33,12 +33,10 @@ "settings": "Settings", "typography": "Typography" }, - "continueWriting": "Continue Writing", "emptySubtitle": "Chat with your companion to record your stories", "emptyTitle": "No memoir yet", "pageTitle": "Memoir", "readMemory": "Read Memory", - "startChapter": "Start Writing", "statusDrafting": "Drafting", "statusLocked": "Locked", "statusPending": "Pending", diff --git a/app-expo/src/i18n/locales/zh/memoir.json b/app-expo/src/i18n/locales/zh/memoir.json index 02ac934..b69c8f6 100644 --- a/app-expo/src/i18n/locales/zh/memoir.json +++ b/app-expo/src/i18n/locales/zh/memoir.json @@ -33,12 +33,10 @@ "settings": "设置", "typography": "字体" }, - "continueWriting": "继续写作", "emptySubtitle": "与岁月知己聊天,记录你的故事", "emptyTitle": "还没有回忆录", "pageTitle": "回忆录", "readMemory": "阅读回忆", - "startChapter": "开始写作", "statusDrafting": "撰写中", "statusLocked": "已锁定", "statusPending": "待解锁", From c4d2a38b093c4adec6a31c2af87123fffab91563 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 13 May 2026 17:12:08 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(expo):=20=E5=90=8E=E5=8F=B0=E8=B6=85?= =?UTF-8?q?=E8=BF=87=205=20=E5=88=86=E9=92=9F=E6=89=8D=E6=96=AD=E5=BC=80?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 进入 background 后延迟释放长连,回到前台则取消计时;短切应用保持连接 - 池支持 subscribeConversationPoolSlotDisposed;聊天页在槽位释放时同步状态 - 前台 active 时按需 connect 或重绑会话 - backgroundDisconnectAfterMs 默认 300_000(5 分钟) 未纳入:api/uploads/ 本地文件 Co-authored-by: Cursor --- app-expo/src/core/config.ts | 5 ++ .../conversation-ws-background-pool.ts | 54 +++++++++++++++++- app-expo/src/features/conversation/hooks.ts | 56 +++++++++++++------ 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/app-expo/src/core/config.ts b/app-expo/src/core/config.ts index 8985427..36d3525 100644 --- a/app-expo/src/core/config.ts +++ b/app-expo/src/core/config.ts @@ -21,5 +21,10 @@ export const config = { reconnectBaseDelayMs: 1_000, reconnectMaxDelayMs: 30_000, heartbeatIntervalMs: 30_000, + /** + * 仅当 App 处于 `background` 连续超过该毫秒数才释放当前会话 WebSocket。 + * 短暂切到其它应用再返回时保持连接,避免反复重连。 + */ + backgroundDisconnectAfterMs: 300_000, }, } as const; diff --git a/app-expo/src/features/conversation/conversation-ws-background-pool.ts b/app-expo/src/features/conversation/conversation-ws-background-pool.ts index 9d56927..541bd3b 100644 --- a/app-expo/src/features/conversation/conversation-ws-background-pool.ts +++ b/app-expo/src/features/conversation/conversation-ws-background-pool.ts @@ -1,6 +1,8 @@ import type { QueryClient } from '@tanstack/react-query'; import { AppState, type AppStateStatus } from 'react-native'; +import { config } from '@/core/config'; + import { RealtimeSession, type RealtimeSessionUiOwner, @@ -10,23 +12,67 @@ type Slot = { conversationId: string; session: RealtimeSession }; let slot: Slot | null = null; -/** 与常见聊天 App 一致:仅当应用进入 background 时断开长连(避免后台挂 socket);inactive 不处理以减少控制中心等短暂打断 */ +/** 回到前台时取消;超时触发才真正 dispose,避免短切换也断连 */ +let backgroundDisconnectTimer: ReturnType | null = null; + +const slotDisposedListeners = new Set<() => void>(); + +/** 槽位被释放(超时 / 登出 / 换会话等)时通知;用于聊天页同步 sessionRef 与连接状态 */ +export function subscribeConversationPoolSlotDisposed( + cb: () => void, +): () => void { + slotDisposedListeners.add(cb); + return () => { + slotDisposedListeners.delete(cb); + }; +} + +/** 与常见聊天 App 一致:后台停留超过 `backgroundDisconnectAfterMs` 再断开长连;短切应用保持 socket */ let backgroundUnsubscribe: (() => void) | null = null; +function cancelBackgroundDisconnectTimer(): void { + if (backgroundDisconnectTimer != null) { + clearTimeout(backgroundDisconnectTimer); + backgroundDisconnectTimer = null; + } +} + +function scheduleBackgroundDisconnect(): void { + cancelBackgroundDisconnectTimer(); + backgroundDisconnectTimer = setTimeout(() => { + backgroundDisconnectTimer = null; + disposeSlot(); + }, config.ws.backgroundDisconnectAfterMs); +} + function installBackgroundLifecycleOnce(): void { if (backgroundUnsubscribe) return; const sub = AppState.addEventListener('change', (next: AppStateStatus) => { if (next === 'background') { - disposeAllBackgroundConversationWs(); + if (slot) { + scheduleBackgroundDisconnect(); + } + } else if (next === 'active') { + cancelBackgroundDisconnectTimer(); } }); - backgroundUnsubscribe = () => sub.remove(); + backgroundUnsubscribe = () => { + cancelBackgroundDisconnectTimer(); + sub.remove(); + }; } function disposeSlot(): void { if (!slot) return; slot.session.dispose(); slot = null; + for (const cb of slotDisposedListeners) { + try { + cb(); + } catch { + /* listener 自行兜底 */ + } + } } const offScreenUi = { @@ -54,12 +100,14 @@ export function releaseConversationWsUi( /** 删除会话等场景:关闭对应长连 */ export function disposeBackgroundConversationWs(conversationId: string): void { if (slot?.conversationId === conversationId) { + cancelBackgroundDisconnectTimer(); disposeSlot(); } } /** 登出 / 清账号:关闭池中连接 */ export function disposeAllBackgroundConversationWs(): void { + cancelBackgroundDisconnectTimer(); disposeSlot(); } diff --git a/app-expo/src/features/conversation/hooks.ts b/app-expo/src/features/conversation/hooks.ts index 353f774..5aaf270 100644 --- a/app-expo/src/features/conversation/hooks.ts +++ b/app-expo/src/features/conversation/hooks.ts @@ -1,7 +1,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { File, Paths } from 'expo-file-system'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { AppState, type AppStateStatus } from 'react-native'; +import { + AppState, + InteractionManager, + type AppStateStatus, +} from 'react-native'; import i18n from '@/i18n'; import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types'; @@ -9,9 +13,9 @@ import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types'; import { conversationApi } from './api'; import { acquireBackgroundConversationWs, - disposeAllBackgroundConversationWs, disposeBackgroundConversationWs, releaseConversationWsUi, + subscribeConversationPoolSlotDisposed, } from './conversation-ws-background-pool'; import { conversationMessagesRepository } from './conversation-messages-repository'; import { conversationKeys } from './query-keys'; @@ -246,7 +250,10 @@ export function useRealtimeSession({ const [foregroundResumeGeneration, setForegroundResumeGeneration] = useState(0); - const needsResumeAfterBackgroundRef = useRef(false); + const enabledRef = useRef(enabled); + const conversationIdRef = useRef(conversationId); + enabledRef.current = enabled; + conversationIdRef.current = conversationId; const handleStreamingText: StreamingTextCallback = useCallback( (text, isComplete) => { @@ -295,21 +302,38 @@ export function useRealtimeSession({ useEffect(() => { if (!enabled || !conversationId) return; - const sub = AppState.addEventListener('change', (next: AppStateStatus) => { - if (next === 'background') { - needsResumeAfterBackgroundRef.current = true; - disposeAllBackgroundConversationWs(); - sessionRef.current = null; - setConnectionState('disconnected'); - setStreamingMessage(null); - setAwaitingAssistantReply(false); - } else if (next === 'active' && needsResumeAfterBackgroundRef.current) { - needsResumeAfterBackgroundRef.current = false; - setForegroundResumeGeneration((g) => g + 1); - } + const unsubSlotDisposed = subscribeConversationPoolSlotDisposed(() => { + sessionRef.current = null; + setConnectionState('disconnected'); + setStreamingMessage(null); + setAwaitingAssistantReply(false); }); - return () => sub.remove(); + const sub = AppState.addEventListener('change', (next: AppStateStatus) => { + if (next !== 'active') return; + /** + * 短切后台不断开时:若 TCP 已断则 connect()。 + * 长后台已由池超时 dispose:此处 sessionRef 为空则重绑整段会话。 + */ + void InteractionManager.runAfterInteractions(() => { + if (!enabledRef.current || !conversationIdRef.current) { + return; + } + const s = sessionRef.current; + if (!s) { + setForegroundResumeGeneration((g) => g + 1); + return; + } + if (s.getConnectionState() === 'disconnected') { + void s.connect(); + } + }); + }); + + return () => { + unsubSlotDisposed(); + sub.remove(); + }; }, [enabled, conversationId]); /** From 6f41574bdad6572dbd49b18f58a0d7407751fa9e Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 15 May 2026 17:23:02 +0800 Subject: [PATCH 05/14] feat(memoir): persist chapter reading prefs globally Share font size, font family, and background across all memoir chapters via MemoirReadingSettingsProvider and SecureStore (same app-settings pattern). Add parse/merge helpers and unit tests. Co-authored-by: Cursor --- app-expo/src/app/(main)/chapter/[id].tsx | 56 +++++++---- .../core/memoir-reading-settings-context.tsx | 99 +++++++++++++++++++ app-expo/src/core/providers.tsx | 5 +- app-expo/src/core/settings/app-settings.ts | 22 +++++ .../core/settings/memoir-reading-settings.ts | 70 +++++++++++++ .../src/features/memoir/markdown-renderer.tsx | 8 +- .../settings/memoir-reading-settings.test.ts | 86 ++++++++++++++++ 7 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 app-expo/src/core/memoir-reading-settings-context.tsx create mode 100644 app-expo/src/core/settings/memoir-reading-settings.ts create mode 100644 app-expo/tests/core/settings/memoir-reading-settings.test.ts diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index 43608dd..c364fa6 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -23,7 +23,13 @@ import { ScreenHeader, } from '@/components/screen-header'; import { ScreenGutter } from '@/constants/layout'; +import { useMemoirReadingSettings } from '@/core/memoir-reading-settings-context'; import { useTypography } from '@/core/typography-context'; +import type { + MemoirReadingBackground, + MemoirReadingFontFamily, + MemoirReadingFontSize, +} from '@/core/settings/memoir-reading-settings'; import { useAppSettings } from '@/hooks/use-app-settings'; import { MarkdownRenderer, @@ -137,11 +143,7 @@ function StorySegmentCover({ ); } -type FontSize = 'small' | 'default' | 'large'; -type FontFamily = 'serif' | 'sans'; -type BackgroundTheme = 'white' | 'sepia'; - -const FONT_FAMILIES: Record = { +const FONT_FAMILIES: Record = { serif: Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ?? 'serif', @@ -153,7 +155,7 @@ const FONT_FAMILIES: Record = { }) ?? 'sans-serif', }; -const BACKGROUND_COLORS: Record = { +const BACKGROUND_COLORS: Record = { white: READING_COLORS.background, sepia: READING_COLORS.backgroundSepia, }; @@ -192,12 +194,12 @@ function ReadingSettingsModal({ }: { visible: boolean; onClose: () => void; - fontSize: FontSize; - fontFamily: FontFamily; - backgroundColor: BackgroundTheme; - onFontSizeChange: (v: FontSize) => void; - onFontFamilyChange: (v: FontFamily) => void; - onBackgroundChange: (v: BackgroundTheme) => void; + fontSize: MemoirReadingFontSize; + fontFamily: MemoirReadingFontFamily; + backgroundColor: MemoirReadingBackground; + onFontSizeChange: (v: MemoirReadingFontSize) => void; + onFontFamilyChange: (v: MemoirReadingFontFamily) => void; + onBackgroundChange: (v: MemoirReadingBackground) => void; t: (key: string) => string; }) { const insets = useSafeAreaInsets(); @@ -359,6 +361,16 @@ export default function ChapterScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); const { largeText } = useAppSettings(); + const { + preferences: { + fontSize, + fontFamily, + background: backgroundTheme, + }, + setFontSize, + setFontFamily, + setBackground: setBackgroundTheme, + } = useMemoirReadingSettings(); const typography = useTypography(); const { width } = useWindowDimensions(); const { t } = useTranslation('memoir'); @@ -367,12 +379,8 @@ export default function ChapterScreen() { const deleteChapter = useDeleteChapter(); const [settingsVisible, setSettingsVisible] = useState(false); - const [fontSize, setFontSize] = useState('default'); - const [fontFamily, setFontFamily] = useState('serif'); - const [backgroundColor, setBackgroundColor] = - useState('white'); - const bgColor = BACKGROUND_COLORS[backgroundColor]; + const bgColor = BACKGROUND_COLORS[backgroundTheme]; if (isLoading) { return ( @@ -610,10 +618,16 @@ export default function ChapterScreen() { onClose={() => setSettingsVisible(false)} fontSize={fontSize} fontFamily={fontFamily} - backgroundColor={backgroundColor} - onFontSizeChange={setFontSize} - onFontFamilyChange={setFontFamily} - onBackgroundChange={setBackgroundColor} + backgroundColor={backgroundTheme} + onFontSizeChange={(v) => { + void setFontSize(v); + }} + onFontFamilyChange={(v) => { + void setFontFamily(v); + }} + onBackgroundChange={(v) => { + void setBackgroundTheme(v); + }} t={t as (key: string) => string} /> diff --git a/app-expo/src/core/memoir-reading-settings-context.tsx b/app-expo/src/core/memoir-reading-settings-context.tsx new file mode 100644 index 0000000..2a35065 --- /dev/null +++ b/app-expo/src/core/memoir-reading-settings-context.tsx @@ -0,0 +1,99 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type PropsWithChildren, +} from 'react'; + +import { + DEFAULT_MEMOIR_READING_PREFERENCES, + type MemoirReadingBackground, + type MemoirReadingFontFamily, + type MemoirReadingFontSize, + type MemoirReadingPreferences, +} from '@/core/settings/memoir-reading-settings'; +import { + getMemoirReadingPreferences, + setMemoirReadingPreferences, +} from '@/core/settings/app-settings'; + +type MemoirReadingSettingsContextValue = { + ready: boolean; + preferences: MemoirReadingPreferences; + setFontSize: (v: MemoirReadingFontSize) => Promise; + setFontFamily: (v: MemoirReadingFontFamily) => Promise; + setBackground: (v: MemoirReadingBackground) => Promise; +}; + +const MemoirReadingSettingsContext = + createContext(null); + +export function MemoirReadingSettingsProvider({ children }: PropsWithChildren) { + const [preferences, setPreferences] = useState( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + const loaded = await getMemoirReadingPreferences(); + if (cancelled) return; + setPreferences(loaded); + setReady(true); + })(); + return () => { + cancelled = true; + }; + }, []); + + const persistAndSet = useCallback(async (patch: Partial) => { + await setMemoirReadingPreferences(patch); + setPreferences((prev) => ({ ...prev, ...patch })); + }, []); + + const setFontSize = useCallback( + (v: MemoirReadingFontSize) => persistAndSet({ fontSize: v }), + [persistAndSet], + ); + + const setFontFamily = useCallback( + (v: MemoirReadingFontFamily) => persistAndSet({ fontFamily: v }), + [persistAndSet], + ); + + const setBackground = useCallback( + (v: MemoirReadingBackground) => persistAndSet({ background: v }), + [persistAndSet], + ); + + const value = useMemo( + () => ({ + ready, + preferences, + setFontSize, + setFontFamily, + setBackground, + }), + [ready, preferences, setFontSize, setFontFamily, setBackground], + ); + + return ( + + {children} + + ); +} + +export function useMemoirReadingSettings(): MemoirReadingSettingsContextValue { + const ctx = useContext(MemoirReadingSettingsContext); + if (!ctx) { + throw new Error( + 'useMemoirReadingSettings must be used within MemoirReadingSettingsProvider', + ); + } + return ctx; +} diff --git a/app-expo/src/core/providers.tsx b/app-expo/src/core/providers.tsx index e4d7780..8357e04 100644 --- a/app-expo/src/core/providers.tsx +++ b/app-expo/src/core/providers.tsx @@ -2,6 +2,7 @@ import React, { type PropsWithChildren } from 'react'; import { initApiClient } from '@/core/api/client'; import { AppSettingsProvider } from '@/core/app-settings-context'; +import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-context'; import { NetworkError } from '@/core/api/types'; import { tokenManager } from '@/core/auth/token-manager'; import { config } from '@/core/config'; @@ -62,7 +63,9 @@ initApiClient({ export function AppProviders({ children }: PropsWithChildren) { return ( - {children} + + {children} + ); } diff --git a/app-expo/src/core/settings/app-settings.ts b/app-expo/src/core/settings/app-settings.ts index 12854bd..d0204e1 100644 --- a/app-expo/src/core/settings/app-settings.ts +++ b/app-expo/src/core/settings/app-settings.ts @@ -10,11 +10,18 @@ import { supportedLanguages, type AppLanguage } from '@/i18n/resources'; import { THEME_NAMES, type ThemeName } from '@/constants/theme-bridge'; +import { + mergeMemoirReadingPreferences, + parseMemoirReadingPreferences, + type MemoirReadingPreferences, +} from './memoir-reading-settings'; + const KEY_LANGUAGE = 'app_settings_language'; const KEY_LARGE_TEXT = 'app_settings_large_text'; const KEY_DARK_MODE = 'app_settings_dark_mode'; const KEY_THEME_NAME = 'app_settings_theme_name'; const KEY_TTS_SPEAK_DEFAULT = 'app_settings_tts_speak_default'; +const KEY_MEMOIR_READING = 'app_settings_memoir_reading'; const webFallback: Record = {}; @@ -95,5 +102,20 @@ export async function setTtsSpeakDefault(value: boolean): Promise { await setStored(KEY_TTS_SPEAK_DEFAULT, value ? 'true' : 'false'); } +export async function getMemoirReadingPreferences(): Promise { + const raw = await getStored(KEY_MEMOIR_READING); + return parseMemoirReadingPreferences(raw); +} + +export async function setMemoirReadingPreferences( + patch: Partial, +): Promise { + const raw = await getStored(KEY_MEMOIR_READING); + const current = parseMemoirReadingPreferences(raw); + const next = mergeMemoirReadingPreferences(current, patch); + await setStored(KEY_MEMOIR_READING, JSON.stringify(next)); +} + export { supportedLanguages, THEME_NAMES }; export type { AppLanguage, ThemeName }; +export type { MemoirReadingPreferences } from './memoir-reading-settings'; diff --git a/app-expo/src/core/settings/memoir-reading-settings.ts b/app-expo/src/core/settings/memoir-reading-settings.ts new file mode 100644 index 0000000..0245d7c --- /dev/null +++ b/app-expo/src/core/settings/memoir-reading-settings.ts @@ -0,0 +1,70 @@ +export type MemoirReadingFontSize = 'small' | 'default' | 'large'; + +export type MemoirReadingFontFamily = 'serif' | 'sans'; + +export type MemoirReadingBackground = 'white' | 'sepia'; + +export interface MemoirReadingPreferences { + fontSize: MemoirReadingFontSize; + fontFamily: MemoirReadingFontFamily; + background: MemoirReadingBackground; +} + +export const DEFAULT_MEMOIR_READING_PREFERENCES: MemoirReadingPreferences = { + fontSize: 'default', + fontFamily: 'serif', + background: 'white', +}; + +const FONT_SIZES: MemoirReadingFontSize[] = ['small', 'default', 'large']; +const FONT_FAMS: MemoirReadingFontFamily[] = ['serif', 'sans']; +const BACKGROUNDS: MemoirReadingBackground[] = ['white', 'sepia']; + +function isFontSize(v: unknown): v is MemoirReadingFontSize { + return typeof v === 'string' && FONT_SIZES.includes(v as MemoirReadingFontSize); +} + +function isFontFamily(v: unknown): v is MemoirReadingFontFamily { + return typeof v === 'string' && FONT_FAMS.includes(v as MemoirReadingFontFamily); +} + +function isBackground(v: unknown): v is MemoirReadingBackground { + return typeof v === 'string' && BACKGROUNDS.includes(v as MemoirReadingBackground); +} + +export function parseMemoirReadingPreferences( + raw: string | null, +): MemoirReadingPreferences { + if (raw == null || raw.trim() === '') { + return { ...DEFAULT_MEMOIR_READING_PREFERENCES }; + } + try { + const parsed: unknown = JSON.parse(raw); + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ...DEFAULT_MEMOIR_READING_PREFERENCES }; + } + const o = parsed as Record; + const d = DEFAULT_MEMOIR_READING_PREFERENCES; + return { + fontSize: isFontSize(o.fontSize) ? o.fontSize : d.fontSize, + fontFamily: isFontFamily(o.fontFamily) ? o.fontFamily : d.fontFamily, + background: isBackground(o.background) ? o.background : d.background, + }; + } catch { + return { ...DEFAULT_MEMOIR_READING_PREFERENCES }; + } +} + +export function mergeMemoirReadingPreferences( + base: MemoirReadingPreferences, + patch: Partial, +): MemoirReadingPreferences { + return { + fontSize: + patch.fontSize !== undefined ? patch.fontSize : base.fontSize, + fontFamily: + patch.fontFamily !== undefined ? patch.fontFamily : base.fontFamily, + background: + patch.background !== undefined ? patch.background : base.background, + }; +} diff --git a/app-expo/src/features/memoir/markdown-renderer.tsx b/app-expo/src/features/memoir/markdown-renderer.tsx index 0bc2913..0a65357 100644 --- a/app-expo/src/features/memoir/markdown-renderer.tsx +++ b/app-expo/src/features/memoir/markdown-renderer.tsx @@ -9,6 +9,10 @@ import React from 'react'; import { Platform, StyleSheet, Text, View } from 'react-native'; import Markdown from 'react-native-markdown-display'; +import type { + MemoirReadingFontFamily, + MemoirReadingFontSize, +} from '@/core/settings/memoir-reading-settings'; import type { ImageAsset } from './types'; const PLACEHOLDER_RE = /\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}/g; @@ -197,8 +201,8 @@ export interface MarkdownRendererProps { markdown: string; renderedAssets: ImageAsset[]; coverImageUrl: string | null; - fontSize: 'small' | 'default' | 'large'; - fontFamily: 'serif' | 'sans'; + fontSize: MemoirReadingFontSize; + fontFamily: MemoirReadingFontFamily; backgroundColor: string; contentWidth: number; /** 多故事分段时仅首段下沉首字 */ diff --git a/app-expo/tests/core/settings/memoir-reading-settings.test.ts b/app-expo/tests/core/settings/memoir-reading-settings.test.ts new file mode 100644 index 0000000..2b629f2 --- /dev/null +++ b/app-expo/tests/core/settings/memoir-reading-settings.test.ts @@ -0,0 +1,86 @@ +import { + DEFAULT_MEMOIR_READING_PREFERENCES, + mergeMemoirReadingPreferences, + parseMemoirReadingPreferences, +} from '@/core/settings/memoir-reading-settings'; + +describe('parseMemoirReadingPreferences', () => { + it('returns defaults for null and empty', () => { + expect(parseMemoirReadingPreferences(null)).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect(parseMemoirReadingPreferences('')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect(parseMemoirReadingPreferences(' ')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + }); + + it('returns defaults for invalid JSON', () => { + expect(parseMemoirReadingPreferences('not json')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + }); + + it('returns defaults for non-object JSON', () => { + expect(parseMemoirReadingPreferences('[]')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect(parseMemoirReadingPreferences('"x"')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + }); + + it('fills missing or invalid fields from defaults', () => { + expect(parseMemoirReadingPreferences('{}')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect( + parseMemoirReadingPreferences( + JSON.stringify({ + fontSize: 'huge', + fontFamily: 'comic', + background: 'black', + }), + ), + ).toEqual(DEFAULT_MEMOIR_READING_PREFERENCES); + }); + + it('accepts valid partial object', () => { + expect( + parseMemoirReadingPreferences( + JSON.stringify({ fontSize: 'large' }), + ), + ).toEqual( + mergeMemoirReadingPreferences(DEFAULT_MEMOIR_READING_PREFERENCES, { + fontSize: 'large', + }), + ); + expect( + parseMemoirReadingPreferences( + JSON.stringify({ background: 'sepia', fontFamily: 'sans' }), + ), + ).toEqual({ + ...DEFAULT_MEMOIR_READING_PREFERENCES, + background: 'sepia', + fontFamily: 'sans', + }); + }); +}); + +describe('mergeMemoirReadingPreferences', () => { + it('applies only defined patch keys', () => { + const base = { + fontSize: 'small' as const, + fontFamily: 'sans' as const, + background: 'sepia' as const, + }; + expect(mergeMemoirReadingPreferences(base, { fontSize: 'large' })).toEqual({ + fontSize: 'large', + fontFamily: 'sans', + background: 'sepia', + }); + expect(mergeMemoirReadingPreferences(base, {})).toEqual(base); + }); +}); From 6452019a1e25e9d7ee0dff0b3a13db0cb2add591 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 15 May 2026 17:24:57 +0800 Subject: [PATCH 06/14] copy env.staging --- api/.env.staging | 161 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 118 insertions(+), 43 deletions(-) diff --git a/api/.env.staging b/api/.env.staging index d170d40..c059d7b 100644 --- a/api/.env.staging +++ b/api/.env.staging @@ -1,20 +1,23 @@ -LIFE_ECHO_API_HOST_BIND=0.0.0.0 -LIFE_ECHO_API_HOST_PORT=8000 -POSTGRES_HOST_PORT=15432 +# ============================================================================= +# Life Echo API — production(生产) +# +# 仓库维护本文件;production 发布时 workflow 会上传并复制为运行时 .env。 +# 若仓库可被非授权人员访问,请不要在此文件中保留真实密钥。 +# ============================================================================= # ============================================================================= -# Life Echo API — staging(预发) -# -# 仓库维护本文件;staging 发布时 workflow 会上传并复制为运行时 .env。 -# 不要把生产密钥误填进本文件。 +# Docker Compose(宿主机独立 Caddy 反代到本 API) # ============================================================================= +# 映射到宿主机的端口,默认 8000;与同机其它项目冲突时改为未占用端口,并在独立 Caddy 的 Caddyfile 中 reverse_proxy 到 127.0.0.1:该端口。 +# LIFE_ECHO_API_HOST_PORT=8000 +# 若 Caddy 跑在独立容器且非 host 网络,不要用 127.0.0.1,应把 Caddy 加入与本 compose 相同的 Docker 网络,并对 http://life-echo-api-prod:8000 做 reverse_proxy。 # ============================================================================= # Logging(loguru sink 最低级别:TRACE / DEBUG / INFO / WARNING / ERROR / CRITICAL) # ============================================================================= -# 预发默认 INFO;勿长期 DEBUG。查慢路径可短时 LOG_AGENT_VERBOSE=1。 +# 生产默认 INFO;勿长期 DEBUG。排障 Agent 耗时可短时 LOG_AGENT_VERBOSE=1。 LOG_LEVEL=INFO -# Agent 单行 INFO 摘要;与 LOG_LEVEL 独立 +# Agent 单行 INFO 摘要;与 LOG_LEVEL 独立,便于生产短暂排查 # LOG_AGENT_VERBOSE=0 # DEBUG 下预览上限(默认 4096);0=全文 # AGENT_LOG_MAX_CHARS=4096 @@ -26,17 +29,63 @@ LOG_LEVEL=INFO # DEBUG 下超长单段 *.prompt:先跳过前 N 字符再预览 # AGENT_LOG_JSON_PROMPT_PREFIX_CHARS=0 # AGENT_LOG_JSON_PROMPT_PREFIX_ONLY_IF_LEN_GT=4000 -# 第三方 stdlib logging(空=自动) +# 第三方 stdlib logging(空=自动:LOG_LEVEL 为 DEBUG/TRACE 时 Celery→INFO、httpx/httpcore→WARNING,减少刷屏) # CELERY_LOG_LEVEL= # HTTPX_LOG_LEVEL= # ============================================================================= # LLM / DeepSeek # ============================================================================= -DEEPSEEK_API_KEY=your_deepseek_api_key +DEEPSEEK_API_KEY=sk-09f17fb61c5a4299a3afc2a01de7af75 DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_MODEL=deepseek-v4-flash +# ============================================================================= +# Memory 向量(智谱 BigModel 国内 embedding-3;与 DeepSeek/OpenAI 用途分离) +# 文档:https://docs.bigmodel.cn/cn/guide/models/embedding/embedding-3 +# 本期固定 1024 维;库表经迁移与 MEMORY_EMBEDDING_DIMENSION 一致。 +# ============================================================================= +ZHIPU_API_KEY=524eda18eb3848e881eefe4c7ef17ec2.xBmGUabYDEa44m3M +# 默认国内通用端点(与 ZhipuAiClient 一致) +# EMBEDDING_BASE_URL=https://open.bigmodel.cn/api/paas/v4 +EMBEDDING_MODEL=embedding-3 + +# Chat 访谈:每轮根据用户内容判定主人生阶段(关则仅用关键词,省一次 LLM) +# CHAT_STAGE_DETECTION_ENABLED=true +# CHAT_STAGE_DETECTION_MAX_TOKENS=128 +# 访谈者体验(覆盖 config 默认值;与 api/.env.development 对齐时可减少文风漂移与记忆噪声) +CHAT_ERA_CONTEXT_ENABLED=true +CHAT_INTERVIEW_PERSONA=warm_listener +CHAT_INTERVIEW_TEMPERATURE=0.65 +# 访谈:是否按本轮用户话检索记忆并注入提示词(关则不调 retrieve) +# CHAT_MEMORY_RETRIEVAL_ENABLED=true +CHAT_MEMORY_TOP_K=4 +CHAT_MEMORY_EVIDENCE_MAX_CHARS=1400 +CHAT_REPLY_PLANNER_LLM_ENABLED=true +# 访谈回复长度档位(brief/standard/expanded)联动:极短输入 / 默认 / 长段+新细节 +# CHAT_INTERVIEW_BRIEF_MAX_TOKENS=240 +# CHAT_INTERVIEW_BRIEF_MAX_CHARS_PER_SEGMENT=180 +# CHAT_INTERVIEW_EXPANDED_MAX_TOKENS=400 +# CHAT_INTERVIEW_EXPANDED_MAX_CHARS_PER_SEGMENT=300 + +# Memoir:批处理/抽取更新 slot 时是否允许改写 MemoirState.current_stage(默认 false,访谈 switch_stage 仍可推进) +# True 时仅当 proposed 与 existing 在同一 chat_bucket 才对齐 current_stage +# MEMOIR_EXTRACTION_UPDATES_CURRENT_STAGE=false + +# Memoir:叙事前口述归一(segment 原文仍落库;仅 story 流水线派生输入) +MEMOIR_ORAL_NORMALIZE_ENABLED=true +# off | rules | llm(llm 为先规则再 LLM 纠错,失败回退规则结果) +MEMOIR_ORAL_NORMALIZE_MODE=llm +MEMOIR_ORAL_NORMALIZE_LLM_MAX_TOKENS=512 +MEMOIR_ORAL_NORMALIZE_LLM_MAX_INPUT_CHARS=8000 + +# Chat:模型消费净稿(segment 原文仍落库;访谈编排层归一后注入 Agent / 记忆检索) +# CHAT_INPUT_NORMALIZE_ENABLED=true +# off | rules | llm(llm 为先规则再 LLM;失败回退规则;编排层已带 LLM 时不重复在 Agent 调) +# CHAT_INPUT_NORMALIZE_MODE=rules +# CHAT_INPUT_NORMALIZE_LLM_MAX_TOKENS=512 +# CHAT_INPUT_NORMALIZE_LLM_MAX_INPUT_CHARS=8000 + # ============================================================================= # Database # ============================================================================= @@ -45,6 +94,11 @@ DEEPSEEK_MODEL=deepseek-v4-flash # Docker / 服务端(主机名一般为 compose 服务名 postgres): # DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo +# 启动时 Alembic(main.py);生产可设 ALEMBIC_STARTUP_FAIL_FAST=true,迁移失败则拒绝启动 +# ALEMBIC_RUN_ON_STARTUP=true +# ALEMBIC_STARTUP_FAIL_FAST=false +# ALEMBIC_STARTUP_MAX_RETRIES=3 +# ALEMBIC_STARTUP_RETRY_BASE_SECONDS=1.0 # ============================================================================= # Redis @@ -61,7 +115,7 @@ REDIS_SESSION_TTL=86400 # ============================================================================= # Memory compaction(近重复 memory chunk 软排除;Celery + Redis 防抖) -# 与 example / development / production 一致默认开启;预发须跑 worker + celery-beat。 +# 与 .env.example / .env.development 一致默认开启;需 running:celery worker + celery-beat(见 docker-compose.yml)。 # ============================================================================= MEMORY_COMPACTION_ENABLED=true # MEMORY_COMPACTION_DEBOUNCE_SECONDS=105 @@ -75,11 +129,23 @@ MEMORY_COMPACTION_ENABLED=true # MEMORY_COMPACTION_METADATA_EVENT_YEAR_WINDOW=1 # MEMORY_COMPACTION_SWEEP_RECENT_HOURS=24 +# ============================================================================= +# Story 流水线(post-commit、章节物化、append 上限、evidence 检索) +# ============================================================================= +# STORY_IMAGE_ENQUEUE_DEDUP_TTL=300 +# RECOMPOSE_CHAPTER_DELAY_SECONDS=8 +# CHAPTER_PIPELINE_LOCK_TTL_SECONDS=120 +# STORY_APPEND_MAX_CANONICAL_CHARS=12000 +# STORY_APPEND_MAX_VERSIONS=20 +# EVIDENCE_TOP_K_DEFAULT=10 +# EVIDENCE_TOP_K_LARGE_BATCH=5 +# EVIDENCE_LARGE_BATCH_THRESHOLD=3 + # ============================================================================= # Auth # ============================================================================= # 建议使用: openssl rand -hex 32 -SECRET_KEY=replace_with_a_strong_random_secret +SECRET_KEY=cf47555c7ecbe5ddb7fd2113c59e08a8bcb110810c42f7c644e06a5acc898608 ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=120 @@ -87,14 +153,14 @@ ACCESS_TOKEN_EXPIRE_MINUTES=120 # Tencent Cloud — 短信 # ============================================================================= # 短信、一句话 ASR/TTS、COS 为不同产品;同一主账号可共用同一对 SecretId/SecretKey(分别填三处)。 -TENCENT_SMS_SECRET_ID=your_tencent_sms_secret_id -TENCENT_SMS_SECRET_KEY=your_tencent_sms_secret_key +TENCENT_SMS_SECRET_ID=AKIDa2ILCwUr56uVt31oU0JOHxPfGhvvkLiq +TENCENT_SMS_SECRET_KEY=xiFbjlZ9XheS2NWYLvHRPAh2A5nGYcR2 # 短信应用 SDK AppID -TENCENT_SMS_SDK_APP_ID=your_sms_sdk_app_id +TENCENT_SMS_SDK_APP_ID=1401010099 # 短信签名内容(不包含【】符号) -TENCENT_SMS_SIGN_NAME=your_sms_sign_name +TENCENT_SMS_SIGN_NAME=上海华嘎科技有限公司 # 短信模板 ID -TENCENT_SMS_TEMPLATE_ID=your_sms_template_id +TENCENT_SMS_TEMPLATE_ID=2592163 # 短信模板参数数量(1=仅验证码,2=验证码+过期时间) # 若遇 TemplateParamSetNotMatchApprovedTemplate,请对照控制台模板配置 TENCENT_SMS_TEMPLATE_PARAM_COUNT=1 @@ -102,7 +168,7 @@ TENCENT_SMS_TEMPLATE_PARAM_COUNT=1 # ============================================================================= # ASR Provider(whisper | tencent) # ============================================================================= -ASR_PROVIDER=whisper +ASR_PROVIDER=tencent # ============================================================================= # Whisper ASR(ASR_PROVIDER=whisper 时使用) @@ -119,8 +185,8 @@ ASR_COMPUTE_TYPE=int8 # ============================================================================= # Tencent Cloud — 一句话 ASR + TTS(ASR_PROVIDER=tencent 或 TTS_PROVIDER=tencent) # ============================================================================= -TENCENT_SECRET_ID=your_tencent_asr_secret_id -TENCENT_SECRET_KEY=your_tencent_asr_secret_key +TENCENT_SECRET_ID=AKIDa2ILCwUr56uVt31oU0JOHxPfGhvvkLiq +TENCENT_SECRET_KEY=xiFbjlZ9XheS2NWYLvHRPAh2A5nGYcR2 # ============================================================================= # TTS(文字转语音,Agent 回复朗读)— 与 ASR 独立 @@ -130,8 +196,8 @@ TENCENT_SECRET_KEY=your_tencent_asr_secret_key # 若 ENABLE_TTS=true 且该轮 `tts_this_turn=true`:每一段助手文案先下发 `tts_audio`,再下发对应段的 `agent_response`。 ENABLE_TTS=true TTS_PROVIDER=tencent -# 仅 TTS_PROVIDER=openai 时需要 -# OPENAI_API_KEY=your_openai_api_key +# 仅 TTS_PROVIDER=openai 时需要(填控制台密钥;勿在注释行写 =your_* 以免旧版 CI 误匹配) +# OPENAI_API_KEY= # 音色 ID 见 https://cloud.tencent.com/document/product/1073/92668 TTS_VOICE_TYPE=501004 TTS_CODEC=mp3 @@ -139,36 +205,36 @@ TTS_CODEC=mp3 # ============================================================================= # WeChat Pay # ============================================================================= -WECHAT_PAY_APP_ID=your_wechat_pay_app_id -WECHAT_PAY_MCH_ID=your_wechat_mch_id -WECHAT_PAY_API_V3_KEY=your_wechat_api_v3_key +WECHAT_PAY_APP_ID=wx1df508452e06cfb8 +WECHAT_PAY_MCH_ID=1662979099 +WECHAT_PAY_API_V3_KEY=xjvGSJLGJAJfjgskfjslafjsajsdjals # 商户私钥:推荐使用文件路径,避免 .env 中长 PEM 转义问题 WECHAT_PAY_PRIVATE_KEY_PATH=certs/apiclient_key.pem # 若不用文件,可配置 WECHAT_PAY_PRIVATE_KEY(PEM,换行用 \n) # WECHAT_PAY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" -WECHAT_PAY_CERT_SERIAL_NO=your_wechat_cert_serial_no -WECHAT_PAY_NOTIFY_URL=https://your-domain.com/api/payment/notify/wechat +WECHAT_PAY_CERT_SERIAL_NO=1AA82328AC1456C6F115B014606F22CD621D2032 +WECHAT_PAY_NOTIFY_URL=https://lifecho.worldsplats.com/api/payment/notify/wechat # 平台公钥模式(仅当无法走平台证书自动拉取时使用);勿填商户私钥路径 # WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH=certs/wechat_platform_public_key.pem -# WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID=your_wechat_platform_public_key_id +# WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID=PUB_KEY_ID_0116629790992026020700181671002400 # ============================================================================= -# Alipay +# Alipay(未接入时保持空字符串,与 Settings 默认一致) # ============================================================================= -ALIPAY_APP_ID=your_alipay_app_id -ALIPAY_PRIVATE_KEY=your_alipay_private_key -ALIPAY_PUBLIC_KEY=your_alipay_public_key -ALIPAY_NOTIFY_URL=https://your-domain.com/api/payment/notify/alipay +ALIPAY_APP_ID= +ALIPAY_PRIVATE_KEY= +ALIPAY_PUBLIC_KEY= +ALIPAY_NOTIFY_URL=https://lifecho.worldsplats.com/api/payment/notify/alipay # ============================================================================= # Misc # ============================================================================= -ENABLE_TEST_SUBSCRIPTION=0 +ENABLE_TEST_SUBSCRIPTION=1 # ============================================================================= # Memoir image generation(Story 主图等;轮询 Liblib 任务) # ============================================================================= -MEMOIR_IMAGE_ENABLED=false +MEMOIR_IMAGE_ENABLED=true MEMOIR_IMAGE_POLL_INTERVAL=3 MEMOIR_IMAGE_MAX_ATTEMPTS=20 MEMOIR_IMAGE_PROVIDER=liblib @@ -176,24 +242,33 @@ 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 +# 叙事模型输出相对口述过短则回退为口述原文 +MEMOIR_NARRATIVE_FALLBACK_BODY_RATIO=0.5 +MEMOIR_NARRATIVE_FALLBACK_MIN_CHARS=20 +# 回忆录 segment 入队:累计 strip 后字数未达此值则暂缓提交 Celery(0=关闭字数门闸,仅静默防抖后提交) +# MEMOIR_SEGMENT_BATCH_MIN_CHARS=50 +# 本批首条入队起最长等待(秒),超时仍提交;测试可调低,生产可调高 +# MEMOIR_SEGMENT_BATCH_MAX_WAIT_SECONDS=60 # 可选,Liblib 返回图片域名不在默认白名单时(逗号分隔) # MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud # ============================================================================= # Liblib image provider # ============================================================================= -LIBLIB_ACCESS_KEY=your_liblib_access_key -LIBLIB_SECRET_KEY=your_liblib_secret_key +LIBLIB_ACCESS_KEY=zrDp6quCOKlLwcewOEfrog +LIBLIB_SECRET_KEY=iTVHo5Nf3KA-xpC1Mja80bC93u6chJem LIBLIB_BASE_URL=https://openapi.liblibai.cloud -LIBLIB_TEMPLATE_UUID=your_liblib_template_uuid +LIBLIB_TEMPLATE_UUID=5d7e67009b344550bc1aa6ccbfa1d7f4 # ============================================================================= # Tencent Cloud — COS(回忆录图片存储) # ============================================================================= -TENCENT_COS_SECRET_ID=your_tencent_cos_secret_id -TENCENT_COS_SECRET_KEY=your_tencent_cos_secret_key +TENCENT_COS_SECRET_ID=AKIDa2ILCwUr56uVt31oU0JOHxPfGhvvkLiq +TENCENT_COS_SECRET_KEY=xiFbjlZ9XheS2NWYLvHRPAh2A5nGYcR2 TENCENT_COS_REGION=ap-shanghai -TENCENT_COS_BUCKET=your_bucket_name -TENCENT_COS_BASE_URL=https://your_bucket_name.cos.ap-shanghai.myqcloud.com +TENCENT_COS_BUCKET=life-echo-prod-1319381411 +TENCENT_COS_BASE_URL=https://life-echo-prod-1319381411.cos.ap-shanghai.myqcloud.com # 可选临时凭证 # TENCENT_COS_TOKEN= From ddc701f22def5905bf662941f507b35b804c567a Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 15 May 2026 17:25:44 +0800 Subject: [PATCH 07/14] fix(voice): queue split TTS segments after pause without replacing track Detect consecutive tts_auto items on the same assistant bubble via listKey (uuid_seg_n / uuid_part_n). When paused, skip the 'clear queue and play latest only' path so later segments enqueue instead of wiping playback. Add regression test. Co-authored-by: Cursor --- .../src/features/voice/hooks/use-player.ts | 58 ++++++++++++++++++- .../tests/features/voice/use-player.test.tsx | 43 ++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/app-expo/src/features/voice/hooks/use-player.ts b/app-expo/src/features/voice/hooks/use-player.ts index 57380b5..edee04b 100644 --- a/app-expo/src/features/voice/hooks/use-player.ts +++ b/app-expo/src/features/voice/hooks/use-player.ts @@ -5,6 +5,39 @@ import { audioFocus } from '@/core/audio/audio-focus'; import type { PlaybackItem, PlayerStatus } from '../types'; +/** + * `handleTtsSegment` 使用 `assistantSegmentMessageId` → `{uuid}_seg_{n}`; + * 展平气泡使用 `{uuid}_part_{n}`。同一条落库助手消息上的连续分段应用入队续播, + * 而不是「暂停后又到一条 tts_auto 就整轨切换成最新」——否则多段朗读只会听到最后一段。 + */ +function parseAssistantSplitListKey(listKey: string | undefined): { + messageId: string; + segmentIndex: number; +} | null { + if (!listKey) return null; + const m = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_(?:seg|part)_(\d+)$/i.exec( + listKey, + ); + if (!m) return null; + return { messageId: m[1]!, segmentIndex: Number(m[2]) }; +} + +function isLaterSegmentOfSameAssistantBubble( + current: PlaybackItem | null | undefined, + incoming: PlaybackItem, +): boolean { + if (incoming.kind !== 'tts_auto' || current?.kind !== 'tts_auto') { + return false; + } + const a = parseAssistantSplitListKey(current.messageRef?.listKey); + const b = parseAssistantSplitListKey(incoming.messageRef?.listKey); + if (!a || !b) return false; + return ( + a.messageId === b.messageId && b.segmentIndex > a.segmentIndex + ); +} + interface UsePlayerResult { status: PlayerStatus; queueLength: number; @@ -40,6 +73,8 @@ export function usePlayer(): UsePlayerResult { const isPlayingRef = useRef(false); const wasBlockedByRecorderRef = useRef(false); const isPlayNextInProgressRef = useRef(false); + /** 供 `enqueue` 判断「同一条助手消息的下一段 TTS」;不依赖 React state 闭包陈旧。 */ + const currentPlaybackItemRef = useRef(null); /** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */ const statusRef = useRef('idle'); /** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */ @@ -69,6 +104,10 @@ export function usePlayer(): UsePlayerResult { statusRef.current = status; }, [status]); + useEffect(() => { + currentPlaybackItemRef.current = currentPlaybackItem; + }, [currentPlaybackItem]); + /** * 必须在 `isLoaded` 之后再 `play()`。 * expo-audio 在 `downloadFirst: true` 时先用 null 建 player,再在内部 effect 里异步 @@ -206,10 +245,23 @@ export function usePlayer(): UsePlayerResult { async (item: PlaybackItem) => { /** * 用户在助手自动朗读中途点暂停时,`playbackActiveUriRef` 仍指向当前条, - * 后续 `tts_auto` 只会堆在队列里且不会 `playNext`。 - * 新片段到达时表示「最新已生成」:清掉暂停态与积压队列,只播本条。 + * 后续 `tts_auto` 默认堆在队列里且不会 `playNext`。 + * 无分段 listKey 时:新片段到达表示「另一条 / 最新一条」应只播它 → 清暂停态与队列。 + * 有 `{uuid}_seg_{n}` 且 n 递增:同一落库助手消息的多段 TTS → 只入队,不抢轨。 */ - if (item.kind === 'tts_auto' && statusRef.current === 'paused') { + const skipPausedClearForSplitContinue = + item.kind === 'tts_auto' && + statusRef.current === 'paused' && + isLaterSegmentOfSameAssistantBubble( + currentPlaybackItemRef.current, + item, + ); + + if ( + item.kind === 'tts_auto' && + statusRef.current === 'paused' && + !skipPausedClearForSplitContinue + ) { queueRef.current = []; setQueueLength(0); isPlayingRef.current = false; diff --git a/app-expo/tests/features/voice/use-player.test.tsx b/app-expo/tests/features/voice/use-player.test.tsx index 0bdd40d..45e5a08 100644 --- a/app-expo/tests/features/voice/use-player.test.tsx +++ b/app-expo/tests/features/voice/use-player.test.tsx @@ -207,4 +207,47 @@ describe('usePlayer', () => { expect(play.mock.calls.length).toBeGreaterThan(playCountAfterFirst); expect(result.current.currentSource).toBe('file:///latest.mp3'); }); + + test('after pause, next uuid_seg tts_auto queues without replacing current (multi-segment TTS)', async () => { + const aid = '78b32c06-d2f9-453b-9cc4-354e68fbcb2d'; + mockUseAudioPlayerStatus.mockReturnValue({ + isLoaded: true, + playing: false, + currentTime: 0.1, + duration: 10, + }); + const pause = jest.fn(); + const play = jest.fn(); + mockUseAudioPlayer.mockReturnValue({ pause, play }); + + const { result } = renderHook(() => usePlayer()); + + await act(async () => { + await result.current.enqueue({ + uri: 'file:///seg0.mp3', + kind: 'tts_auto', + messageRef: { listKey: `${aid}_seg_0` }, + }); + }); + + expect(result.current.status).toBe('playing'); + expect(result.current.currentSource).toBe('file:///seg0.mp3'); + + act(() => { + result.current.pausePlayback(); + }); + expect(result.current.status).toBe('paused'); + + await act(async () => { + await result.current.enqueue({ + uri: 'file:///seg1.mp3', + kind: 'tts_auto', + messageRef: { listKey: `${aid}_seg_1` }, + }); + }); + + expect(result.current.status).toBe('paused'); + expect(result.current.currentSource).toBe('file:///seg0.mp3'); + expect(result.current.queueLength).toBe(1); + }); }); From eabda2c6a959b8be7d63e9d1c5c70f87afbe14de Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 18 May 2026 15:34:50 +0800 Subject: [PATCH 08/14] chore: resolve WIP after merging internal/development - .gitignore: keep api/uploads ignore and copyright_source_listing.pdf path - auth: keep COS avatar upload URL; delete prior COS object when applying preset - i18n: regenerate resources.ts (includes profile tapAwayToClose) - Avatar/COS tests and personal-info remain from prior local work Co-authored-by: Cursor --- .gitignore | 2 + api/app/core/cos_url_keys.py | 50 +++++ api/app/features/auth/router.py | 49 ++++- api/app/features/user/repo.py | 11 ++ api/app/features/user/router.py | 3 +- api/app/features/user/service.py | 4 +- api/tests/test_avatar_preset_http.py | 82 +++++++- api/tests/test_http_contract_errors.py | 22 +++ app-expo/src/app/(main)/personal-info.tsx | 221 ++++++++++++++-------- app-expo/src/i18n/generated/resources.ts | 1 + app-expo/src/i18n/locales/en/profile.json | 1 + app-expo/src/i18n/locales/zh/profile.json | 1 + 12 files changed, 350 insertions(+), 97 deletions(-) diff --git a/.gitignore b/.gitignore index 0890d63..512d49c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ api/models/whisper/ # 脚本输出(预览 JSON/Markdown) api/scripts/output/ +# 本地上传(不提交) +api/uploads/ # 软著:源码摘录 PDF(默认生成在仓库根目录) /copyright_source_listing.pdf diff --git a/api/app/core/cos_url_keys.py b/api/app/core/cos_url_keys.py index efc9886..3d61681 100644 --- a/api/app/core/cos_url_keys.py +++ b/api/app/core/cos_url_keys.py @@ -14,6 +14,9 @@ logger = get_logger(__name__) # 客户端再读 TTS / 拉取音频:预签名有效期(秒),与移动端会话长度匹配 TTS_PRESIGNED_EXPIRES_SEC = 86_400 +# 用户头像 API 下发(与 TTS 一致) +AVATAR_PRESIGNED_EXPIRES_SEC = TTS_PRESIGNED_EXPIRES_SEC + def extract_cos_object_key_if_owned(url: str | None) -> str | None: """ @@ -119,3 +122,50 @@ def presign_tts_urls_for_playback( else: out.append(s) return out + + +def avatar_url_for_api_response(stored_url: str | None) -> str | None: + """DB 中的头像 URL:本环境 COS 直链改为预签名下载 URL。""" + if stored_url is None: + return None + s = str(stored_url).strip() + if not s: + return None + key = extract_cos_object_key_if_owned(s) + if not key: + return s + if not ( + (settings.tencent_cos_secret_id or "").strip() + and (settings.tencent_cos_bucket or "").strip() + ): + return s + from app.core.dependencies import get_object_storage + + storage = get_object_storage() + try: + return storage.get_url(key, expires=AVATAR_PRESIGNED_EXPIRES_SEC) + except Exception as exc: + logger.warning( + "presign avatar url failed, keeping original: key={} err={}", + key, + exc, + ) + return s + + +def best_effort_delete_cos_object_for_url(url: str | None) -> None: + """本环境 COS 对象则尽力 delete(换头像 / 预设时清理)。""" + key = extract_cos_object_key_if_owned(url) + if not key: + return + if not ( + (settings.tencent_cos_secret_id or "").strip() + and (settings.tencent_cos_bucket or "").strip() + ): + return + from app.core.dependencies import get_object_storage + + try: + get_object_storage().delete(key) + except Exception as exc: + logger.warning("delete cos avatar object failed: key={} err={}", key, exc) diff --git a/api/app/features/auth/router.py b/api/app/features/auth/router.py index 799cea9..d3108ae 100644 --- a/api/app/features/auth/router.py +++ b/api/app/features/auth/router.py @@ -7,6 +7,11 @@ from fastapi.responses import FileResponse from PIL import Image from app.core.config import settings +from app.core.cos_url_keys import ( + avatar_url_for_api_response, + best_effort_delete_cos_object_for_url, + extract_cos_object_key_if_owned, +) from app.core.dependencies import get_current_user from app.core.logging import get_logger from app.features.auth.deps import get_auth_service @@ -46,7 +51,6 @@ router = APIRouter( ) AVATAR_DIR = Path("uploads/avatars") -AVATAR_DIR.mkdir(parents=True, exist_ok=True) # ── helpers ────────────────────────────────────────────────── @@ -75,7 +79,7 @@ def _user_response(user: User) -> UserResponse: phone=user.phone, email=user.email, nickname=user.nickname, - avatar_url=user.avatar_url, + avatar_url=avatar_url_for_api_response(user.avatar_url), subscription_type=user.subscription_type, created_at=user.created_at.isoformat(), language_preference=lang, @@ -278,9 +282,17 @@ async def upload_avatar( len(file_content), ) - try: - AVATAR_DIR.mkdir(parents=True, exist_ok=True) + if not ( + (settings.tencent_cos_secret_id or "").strip() + and (settings.tencent_cos_secret_key or "").strip() + and (settings.tencent_cos_bucket or "").strip() + ): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="头像存储服务未配置,请稍后再试", + ) + try: image_bytes = io.BytesIO(file_content) image_bytes.seek(0) @@ -326,14 +338,28 @@ async def upload_avatar( if size > 512: image = image.resize((512, 512), Image.Resampling.LANCZOS) - file_extension = "jpg" - filename = f"{current_user.id}.{file_extension}" - file_path = AVATAR_DIR / filename + jpeg_buffer = io.BytesIO() + image.save(jpeg_buffer, format="JPEG", quality=85, optimize=True) + jpeg_bytes = jpeg_buffer.getvalue() - image.save(file_path, "JPEG", quality=85, optimize=True) + cos_key = f"avatars/{current_user.id}.jpg" + old_url = current_user.avatar_url + old_key = extract_cos_object_key_if_owned(old_url) if old_url else None + if old_key and old_key != cos_key: + best_effort_delete_cos_object_for_url(old_url) + + from app.core.dependencies import get_object_storage + + storage = get_object_storage() + try: + avatar_url = storage.upload(cos_key, jpeg_bytes, "image/jpeg") + except Exception as exc: + logger.exception("COS 头像上传失败: {}", exc) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="头像存储暂时不可用,请稍后再试", + ) from exc - # 路径固定为 {user_id}.jpg,客户端会缓存;每次写入新文件后 bump URL 以绕过缓存。 - avatar_url = f"/api/auth/avatars/{filename}?v={time.time_ns()}" user = await service.update_avatar_url(current_user.id, avatar_url) return _user_response(user) except HTTPException: @@ -381,6 +407,8 @@ async def set_avatar_preset( status_code=status.HTTP_400_BAD_REQUEST, detail="预设头像不可用", ) + best_effort_delete_cos_object_for_url(current_user.avatar_url) + avatar_url = f"{avatar_url_for_preset_filename(filename)}?v={time.time_ns()}" try: user = await service.update_avatar_url(current_user.id, avatar_url) @@ -410,6 +438,7 @@ async def get_avatar_preset(filename: str): responses={404: {"description": "头像不存在"}}, ) async def get_avatar(filename: str): + AVATAR_DIR.mkdir(parents=True, exist_ok=True) file_path = safe_avatar_upload_path(filename, AVATAR_DIR) if file_path is None or not file_path.exists(): raise HTTPException( diff --git a/api/app/features/user/repo.py b/api/app/features/user/repo.py index 362b174..ffc1bf8 100644 --- a/api/app/features/user/repo.py +++ b/api/app/features/user/repo.py @@ -51,6 +51,10 @@ async def clear_user_demographics(db: AsyncSession, user_id: str) -> None: ) +async def clear_user_avatar_url(db: AsyncSession, user_id: str) -> None: + await db.execute(update(User).where(User.id == user_id).values(avatar_url=None)) + + async def collect_purge_context( db: AsyncSession, user_id: str ) -> tuple[list[str], list[str], list[str]]: @@ -130,6 +134,13 @@ async def collect_object_storage_keys_before_purge( tts_urls if isinstance(tts_urls, list) else None ) + r_av = await db.execute(select(User.avatar_url).where(User.id == user_id)) + avatar_url_val = r_av.scalar_one_or_none() + if avatar_url_val: + ak = extract_cos_object_key_if_owned(avatar_url_val) + if ak: + keys.add(ak) + return sorted(keys) diff --git a/api/app/features/user/router.py b/api/app/features/user/router.py index 0b3093b..b212d0e 100644 --- a/api/app/features/user/router.py +++ b/api/app/features/user/router.py @@ -3,6 +3,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from app.core.config import settings +from app.core.cos_url_keys import avatar_url_for_api_response from app.core.dependencies import get_current_user, get_object_storage from app.core.logging import get_logger from app.features.user.deps import get_user_service @@ -50,7 +51,7 @@ async def get_user_profile( phone=current_user.phone, email=current_user.email, nickname=current_user.nickname, - avatar_url=current_user.avatar_url, + avatar_url=avatar_url_for_api_response(current_user.avatar_url), subscription_type=current_user.subscription_type, created_at=current_user.created_at.isoformat(), birth_year=current_user.birth_year, diff --git a/api/app/features/user/service.py b/api/app/features/user/service.py index dcaf72c..cee51e7 100644 --- a/api/app/features/user/service.py +++ b/api/app/features/user/service.py @@ -2,6 +2,7 @@ from datetime import timedelta from sqlalchemy.ext.asyncio import AsyncSession +from app.core.cos_url_keys import avatar_url_for_api_response from app.core.db import utc_now from app.core.logging import get_logger from app.core.redis import redis_service @@ -32,7 +33,7 @@ def _user_to_profile(user: User) -> UserProfileResponse: phone=user.phone, email=user.email, nickname=user.nickname, - avatar_url=user.avatar_url, + avatar_url=avatar_url_for_api_response(user.avatar_url), subscription_type=user.subscription_type, created_at=user.created_at.isoformat(), birth_year=user.birth_year, @@ -121,6 +122,7 @@ class UserService: await repo.purge_user_related_rows(self._db, user_id) await repo.clear_user_demographics(self._db, user_id) + await repo.clear_user_avatar_url(self._db, user_id) await self._db.commit() logger.info("用户数据 DB 行已删除、档案字段已清空并提交 user_id={}", user_id) diff --git a/api/tests/test_avatar_preset_http.py b/api/tests/test_avatar_preset_http.py index e9d2f67..61a1957 100644 --- a/api/tests/test_avatar_preset_http.py +++ b/api/tests/test_avatar_preset_http.py @@ -4,12 +4,14 @@ from __future__ import annotations import uuid from datetime import datetime, timezone +from io import BytesIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from PIL import Image from app.core.dependencies import get_current_user from app.features.auth.deps import get_auth_service @@ -27,6 +29,7 @@ def _mock_current_user() -> User: u.avatar_url = None u.subscription_type = "free" u.created_at = datetime.now(timezone.utc) + u.language_preference = "zh" return u @@ -91,7 +94,6 @@ async def test_get_avatar_preset_path_traversal(preset_auth_app: FastAPI) -> Non @pytest.mark.asyncio async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None: - uid = preset_auth_app.state._fixed_user.id transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.put( @@ -101,11 +103,13 @@ async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None: ) assert r.status_code == 200 body = r.json() - assert body["avatar_url"] == "/api/auth/avatar-presets/02.png" + url = body["avatar_url"] + assert url.startswith("/api/auth/avatar-presets/02.png") + assert "?v=" in url svc: MagicMock = preset_auth_app.state._mock_auth_service - svc.update_avatar_url.assert_awaited_once_with( - uid, "/api/auth/avatar-presets/02.png" - ) + stored = svc.update_avatar_url.await_args[0][1] + assert stored.startswith("/api/auth/avatar-presets/02.png") + assert "?v=" in stored @pytest.mark.asyncio @@ -168,3 +172,71 @@ async def test_get_uploaded_avatar_ok_when_stem_has_underscore( async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatars/user_abc_01.jpg") assert r.status_code == 200 + + +def _minimal_jpeg_bytes() -> bytes: + img = Image.new("RGB", (2, 2), color=(120, 80, 200)) + buf = BytesIO() + img.save(buf, format="JPEG", quality=85) + return buf.getvalue() + + +@pytest.mark.asyncio +async def test_upload_avatar_cos_calls_storage_and_presigns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from fastapi import FastAPI + from httpx import ASGITransport, AsyncClient + + import app.core.dependencies as deps + from app.core.config import settings + from app.core.dependencies import get_current_user + from app.features.auth.router import router as auth_router + + bucket, region = "life-test-bucket", "ap-shanghai" + uid = str(uuid.uuid4()) + public = f"https://{bucket}.cos.{region}.myqcloud.com/avatars/{uid}.jpg" + for attr, val in ( + ("tencent_cos_secret_id", "sid"), + ("tencent_cos_secret_key", "sk"), + ("tencent_cos_bucket", bucket), + ("tencent_cos_region", region), + ("tencent_cos_base_url", f"https://{bucket}.cos.{region}.myqcloud.com"), + ): + monkeypatch.setattr(settings, attr, val, raising=False) + + mock_storage = MagicMock() + mock_storage.upload = MagicMock(return_value=public) + mock_storage.get_url = MagicMock(return_value="https://example.com/signed-avatar") + monkeypatch.setattr(deps, "get_object_storage", lambda: mock_storage) + + fixed_user = _mock_current_user() + fixed_user.id = uid + fixed_user.avatar_url = None + + async def _fake_update_avatar(u: str, url: str): + fixed_user.avatar_url = url + return fixed_user + + mock_service = MagicMock(spec=AuthService) + mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar) + + app = FastAPI() + app.include_router(auth_router) + app.dependency_overrides[get_auth_service] = lambda: mock_service + app.dependency_overrides[get_current_user] = lambda: fixed_user + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.post( + "/api/auth/me/avatar", + files={"file": ("a.jpg", BytesIO(_minimal_jpeg_bytes()), "image/jpeg")}, + headers={"Authorization": "Bearer x"}, + ) + + assert r.status_code == 200 + assert r.json()["avatar_url"] == "https://example.com/signed-avatar" + mock_storage.upload.assert_called_once() + assert mock_storage.upload.call_args[0][0] == f"avatars/{uid}.jpg" + mock_storage.get_url.assert_called_once() + assert mock_storage.get_url.call_args[0][0] == f"avatars/{uid}.jpg" diff --git a/api/tests/test_http_contract_errors.py b/api/tests/test_http_contract_errors.py index 7f145a2..bddf8ae 100644 --- a/api/tests/test_http_contract_errors.py +++ b/api/tests/test_http_contract_errors.py @@ -50,8 +50,30 @@ async def test_avatar_upload_500_detail_sanitized( ) -> None: from fastapi import FastAPI + from app.core.config import settings + import app.core.dependencies as deps + fake_user = MagicMock() fake_user.id = "user-contract-test" + fake_user.avatar_url = None + + mock_storage = MagicMock() + mock_storage.upload = MagicMock( + return_value="https://test-bucket.cos.ap-shanghai.myqcloud.com/avatars/user-contract-test/abc.jpg" + ) + monkeypatch.setattr(deps, "get_object_storage", lambda: mock_storage) + monkeypatch.setattr(settings, "tencent_cos_secret_id", "sid", raising=False) + monkeypatch.setattr(settings, "tencent_cos_secret_key", "sk", raising=False) + monkeypatch.setattr(settings, "tencent_cos_bucket", "test-bucket", raising=False) + monkeypatch.setattr( + settings, "tencent_cos_region", "ap-shanghai", raising=False + ) + monkeypatch.setattr( + settings, + "tencent_cos_base_url", + "https://test-bucket.cos.ap-shanghai.myqcloud.com", + raising=False, + ) class BoomAuth: async def update_avatar_url(self, user_id: str, avatar_url: str): diff --git a/app-expo/src/app/(main)/personal-info.tsx b/app-expo/src/app/(main)/personal-info.tsx index 342ef7d..6ce2885 100644 --- a/app-expo/src/app/(main)/personal-info.tsx +++ b/app-expo/src/app/(main)/personal-info.tsx @@ -26,6 +26,7 @@ import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; import { resolveApiMediaUrl } from '@/core/api/media-url'; import { ApiError } from '@/core/api/types'; +import { cn } from '@/lib/utils'; import { buildAvatarUploadFormData } from '@/features/auth/avatar-upload-form-data'; import { useAvatarPresets, @@ -131,6 +132,7 @@ export default function PersonalInfoScreen() { const avatarBusy = uploadAvatar.isPending || setPreset.isPending; const avatarUri = resolveApiMediaUrl(profile?.avatar_url ?? null); const tileSize = computePresetTileSize(); + const avatarPresetSheetMaxH = Dimensions.get('window').height * 0.88; const handleSave = async () => { const trimmed = nickname.trim(); @@ -288,92 +290,151 @@ export default function PersonalInfoScreen() { - - - {avatarStep === 'presets' ? ( - setAvatarStep('menu')} - > - {t('personalInfo.back')} - - ) : ( - - )} - - {avatarStep === 'presets' - ? t('personalInfo.presetPickTitle') - : t('personalInfo.changeAvatar')} - - + + + - {t('personalInfo.cancel')} - - - - {avatarStep === 'menu' ? ( - - - - - ) : ( - - {presetsLoading ? ( - + + {avatarStep === 'presets' ? ( + setAvatarStep('menu')} + > + + {t('personalInfo.back')} + + + ) : null} + + + + {avatarStep === 'presets' + ? t('personalInfo.presetPickTitle') + : t('personalInfo.changeAvatar')} + + + + + + {t('personalInfo.cancel')} + + + + + + {avatarStep === 'menu' ? ( + + void pickFromLibrary()} + className={cn( + 'items-center rounded-md border border-border bg-background py-1 px-3 active:bg-accent', + avatarBusy && 'opacity-50', + )} + > + + {t('personalInfo.chooseFromLibrary')} + + + setAvatarStep('presets')} + className={cn( + 'items-center rounded-md border border-border bg-background py-1 px-3 active:bg-accent', + avatarBusy && 'opacity-50', + )} + > + + {t('personalInfo.choosePreset')} + + + ) : ( - - {(presets ?? []).map((item) => { - const uri = resolveApiMediaUrl(item.url); - return ( - void applyPreset(item.id)} - style={{ - width: tileSize, - height: tileSize, - }} - > - {uri ? ( - - ) : null} - - ); - })} - + + {presetsLoading ? ( + + ) : ( + + {(presets ?? []).map((item) => { + const uri = resolveApiMediaUrl(item.url); + return ( + void applyPreset(item.id)} + style={{ + width: tileSize, + height: tileSize, + }} + > + {uri ? ( + + ) : null} + + ); + })} + + )} + )} - - )} - + + + ); diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index de4082a..b95d62c 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -233,6 +233,7 @@ interface Resources { "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", "savePartialTitle": "Partially saved", "saving": "Saving…", + "tapAwayToClose": "Tap outside to close", "title": "Personal info" }, "signOut": "Sign Out", diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index b368656..a1abacf 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -37,6 +37,7 @@ "avatarPresetFailed": "Could not set preset avatar", "avatarUploadFailed": "Could not upload avatar", "cancel": "Cancel", + "tapAwayToClose": "Tap outside to close", "birthPlacePlaceholder": "Birthplace", "birthYearPlaceholder": "Birth year", "changeAvatar": "Change photo", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index 268434a..d27bbcf 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -37,6 +37,7 @@ "avatarPresetFailed": "设置预设头像失败", "avatarUploadFailed": "上传头像失败", "cancel": "取消", + "tapAwayToClose": "点击空白处关闭", "birthPlacePlaceholder": "出生地", "birthYearPlaceholder": "出生年份", "changeAvatar": "更换头像", From 8f6c2a6a349090960d09aa3bb5c91c5ef7f92ad6 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 18 May 2026 16:19:32 +0800 Subject: [PATCH 09/14] chore(app-expo): regenerate i18n Resources types Sync generated resources.ts with locale keys (e.g. profile.personalInfo.tapAwayToClose for avatar sheet a11y). Co-authored-by: Cursor --- app-expo/src/i18n/generated/resources.ts | 492 +++++++++++------------ 1 file changed, 245 insertions(+), 247 deletions(-) diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index b95d62c..8335323 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -1,252 +1,250 @@ // This file is automatically generated by i18next-cli. Do not edit manually. interface Resources { - "app": { - "languages": { - "en": "English", - "system": "System", - "zh": "Chinese" - }, - "name": "Life Echo", - "tabs": { - "conversations": "Chats", - "explore": "Explore", - "home": "Home", - "memoir": "Memoir", - "profile": "Profile" - }, - "theme": { - "default": "Default" - } - }, - "auth": { - "login": { - "codeLabel": "Verification Code", - "getCode": "Get Code", - "getCodeCountdown": "Retry in {{seconds}}s", - "networkError": "Network error. Please try again later.", - "phoneLabel": "Phone Number", - "phonePlaceholder": "Enter your phone number", - "privacyPolicy": "Privacy Policy", - "submit": "Login", - "termsAnd": "and", - "termsIntro": "I agree to the", - "termsRequired": "Please agree to the User Agreement and Privacy Policy first", - "termsRequiredConfirm": "OK", - "termsRequiredTitle": "Agreement Required", - "userAgreement": "User Agreement", - "welcomeSubtitle": "Some lives grow richer the more you savor them.", - "welcomeTitle": "Welcome back" - } - }, - "common": { - "chapterLabel": "", - "chapterReading": { - "backgroundColor": "", - "bgPureWhite": "", - "bgSepia": "", - "close": "", - "fontSize": "", - "readingSettings": "", - "typography": "" - }, - "continueWriting": "", - "docs": "Docs", - "emptySubtitle": "", - "emptyTitle": "", - "readMemory": "", - "startChapter": "", - "statusDrafting": "", - "statusLocked": "", - "statusPending": "", - "wordsCount": "" - }, - "conversation": { - "addMore": "More", - "agentName": "Life Echo", - "assistantReplying": "Replying…", - "cancel": "Cancel", - "cancelRecording": "Cancel recording", - "cannotReadAloud": "Read unavailable", - "chatQueueSendTimeout": "Connection timed out. Check your network and try again.", - "chatTitle": "Conversation", - "chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.", - "chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.", - "chatUnavailableTitle": "Chat unavailable", - "confirm": "OK", - "confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.", - "connectionConnected": "Connected", - "connectionConnecting": "Connecting...", - "connectionDisconnected": "Disconnected", - "createError": "Unable to create conversation. Please check your network and try again.", - "delete": "Delete", - "deleteConversation": "Delete Conversation", - "emptyGreetingSubtitle": "Chat with your companion and record your stories.", - "greetingTitle": "Say Hello", - "inputPlaceholder": "Type a message...", - "inputPlaceholderVoice": "Type here or hold the mic to speak...", - "me": "Me", - "readAloudAgain": "Play again", - "readAloudNoMessageId": "This message is not ready for on-demand reading yet. Pull to refresh or try again.", - "readAloudPause": "Pause reading", - "readAloudRequest": "Read aloud", - "readAloudRequestFailed": "Could not start playback. Check your connection.", - "readAloudResume": "Resume reading", - "readingAloud": "Reading aloud…", - "recentChats": "Recent Chats", - "recordingPermissionDenied": "Microphone permission is required to record", - "recordingStartFailed": "Unable to start recording. Please try again.", - "resumeChatSubtitle": "Open your latest conversation to keep talking.", - "resumeChatTitle": "Continue chatting", - "send": "Send", - "startNewSubtitle": "Capture a new memory or share your thoughts with your companion.", - "stopReadingAloud": "Stop reading aloud", - "switchToText": "Switch to text input", - "switchToVoice": "Switch to voice input", - "tapToEndRecording": "Tap to end", - "tapToStartRecording": "Tap to start recording", - "timeDaysAgo_one": "{{count}} day ago", - "timeDaysAgo_other": "{{count}} days ago", - "timeHoursAgo_one": "{{count}} hour ago", - "timeHoursAgo_other": "{{count}} hours ago", - "timeJustNow": "Just now", - "timeMinutesAgo_one": "{{count}} minute ago", - "timeMinutesAgo_other": "{{count}} minutes ago", - "topicSuggestionsDismiss": "Hide", - "ttsThisTurn": "Speak", - "ttsThisTurnAccessibility": "When on, assistant replies synthesize speech before text appears.", - "viewAll": "View All", - "voiceMessagePreview": "Voice message" - }, - "explore": { - - }, - "home": { - - }, - "legal": { - "titlePrivacy": "Privacy Policy", - "titleTerms": "User Agreement" - }, - "memoir": { - "chapterLabel": "Chapter {{index}}", - "chapterReading": { - "back": "Back", - "backgroundColor": "Background", - "bgPureWhite": "White", - "bgSepia": "Sepia", - "cancel": "Cancel", - "chapterNotFound": "Chapter not found", - "close": "Close", - "confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.", - "deleteChapter": "Delete Chapter", - "deleteChapterAction": "Delete", - "fontSans": "Sans", - "fontSerif": "Serif", - "fontSize": "Font Size", - "fontSizeDefault": "Medium", - "fontSizeLarge": "Large", - "fontSizeSmall": "Small", - "readingSettings": "Reading Settings", - "settings": "Settings", - "typography": "Typography" - }, - "emptySubtitle": "Chat with your companion to record your stories", - "emptyTitle": "No memoir yet", - "frameworkChapters": { - "chapter1": "Childhood and upbringing", - "chapter2": "Education and young adulthood", - "chapter3": "Early career", - "chapter4": "Major achievements and peak moments", - "chapter5": "Setbacks, challenges, and turning points", - "chapter6": "Family and relationships", - "chapter7": "Beliefs and values", - "chapter8": "Life summary" - }, - "loadErrorMessage": "Could not load chapters", - "loadErrorRetry": "Retry", - "pageTitle": "Memoir", - "readMemory": "Read Memory", - "statusDrafting": "Drafting", - "statusLocked": "Locked", - "statusPending": "Pending", - "wordsCount": "{{count}} words" - }, - "profile": { - "about": { - "aboutUs": "About Us", - "title": "About" - }, - "appExperience": { - "language": "Language", - "languageDesc": "Display language", - "largeText": "Large Text", - "largeTextDesc": "Make reading easier", - "nightMode": "Night Mode", - "nightModeDesc": "Use dark theme", - "theme": "Theme", - "themeDesc": "Color theme", - "title": "App Experience" - }, - "dataPrivacy": { - "deleteAll": "Delete All Data", - "deleteUnderDevelopment": "Delete data feature is under development.", - "exportAll": "Export All Data", - "exportUnderDevelopment": "Export feature is under development.", - "purgeDialogCancel": "Cancel", - "purgeDialogConfirm": "Delete permanently", - "purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.", - "purgeDialogTitle": "Final confirmation", - "purgeInputLabel": "Confirmation phrase", - "purgeInputPlaceholder": "Type the phrase shown above", - "purgeOpenConfirm": "I understand, continue", - "purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:", - "purgeSubmitting": "Deleting…", - "purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.", - "purgeWarningTitle": "Before you continue", - "title": "Data & Privacy" - }, - "editAvatar": "Edit Profile Picture", - "helpSupport": { - "faq": "FAQ", - "feedback": "Feedback & Support", - "feedbackPageTitle": "Share your thoughts", - "title": "Help & Support" - }, - "personalInfo": { - "avatarPresetFailed": "Could not set preset avatar", - "avatarUploadFailed": "Could not upload avatar", - "birthPlacePlaceholder": "Birthplace", - "birthYearPlaceholder": "Birth year", - "cancel": "Cancel", - "changeAvatar": "Change photo", - "chooseFromLibrary": "Choose from library", - "choosePreset": "Preset avatars", - "grewUpPlaceholder": "Where you grew up", - "libraryPermissionDenied": "Photo library access is required to pick an image", - "nickname": "Nickname", - "nicknamePlaceholder": "Enter nickname", - "nicknameRequired": "Please enter a nickname", - "occupationPlaceholder": "Occupation", - "presetPickTitle": "Choose a preset", - "save": "Save", - "saveFailed": "Could not save", - "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", - "savePartialTitle": "Partially saved", - "saving": "Saving…", - "tapAwayToClose": "Tap outside to close", - "title": "Personal info" - }, - "signOut": "Sign Out", - "signingOut": "Signing out...", - "tier": { - "free": "Free", - "pro": "Pro", - "pro_plus": "Pro+", - "test": "Test" - }, - "userNamePlaceholder": "User", - "userTier": "{{tier}}" - } + app: { + languages: { + en: 'English'; + system: 'System'; + zh: 'Chinese'; + }; + name: 'Life Echo'; + tabs: { + conversations: 'Chats'; + explore: 'Explore'; + home: 'Home'; + memoir: 'Memoir'; + profile: 'Profile'; + }; + theme: { + default: 'Default'; + }; + }; + auth: { + login: { + codeLabel: 'Verification Code'; + getCode: 'Get Code'; + getCodeCountdown: 'Retry in {{seconds}}s'; + networkError: 'Network error. Please try again later.'; + phoneLabel: 'Phone Number'; + phonePlaceholder: 'Enter your phone number'; + privacyPolicy: 'Privacy Policy'; + submit: 'Login'; + termsAnd: 'and'; + termsIntro: 'I agree to the'; + termsRequired: 'Please agree to the User Agreement and Privacy Policy first'; + termsRequiredConfirm: 'OK'; + termsRequiredTitle: 'Agreement Required'; + userAgreement: 'User Agreement'; + welcomeSubtitle: 'Some lives grow richer the more you savor them.'; + welcomeTitle: 'Welcome back'; + }; + }; + common: { + chapterLabel: ''; + chapterReading: { + backgroundColor: ''; + bgPureWhite: ''; + bgSepia: ''; + close: ''; + fontSize: ''; + readingSettings: ''; + typography: ''; + }; + continueWriting: ''; + docs: 'Docs'; + emptySubtitle: ''; + emptyTitle: ''; + readMemory: ''; + startChapter: ''; + statusDrafting: ''; + statusLocked: ''; + statusPending: ''; + wordsCount: ''; + }; + conversation: { + addMore: 'More'; + agentName: 'Life Echo'; + assistantReplying: 'Replying…'; + cancel: 'Cancel'; + cancelRecording: 'Cancel recording'; + cannotReadAloud: 'Read unavailable'; + chatQueueSendTimeout: 'Connection timed out. Check your network and try again.'; + chatTitle: 'Conversation'; + chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.'; + chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.'; + chatUnavailableTitle: 'Chat unavailable'; + confirm: 'OK'; + confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.'; + connectionConnected: 'Connected'; + connectionConnecting: 'Connecting...'; + connectionDisconnected: 'Disconnected'; + createError: 'Unable to create conversation. Please check your network and try again.'; + delete: 'Delete'; + deleteConversation: 'Delete Conversation'; + emptyGreetingSubtitle: 'Chat with your companion and record your stories.'; + greetingTitle: 'Say Hello'; + inputPlaceholder: 'Type a message...'; + inputPlaceholderVoice: 'Type here or hold the mic to speak...'; + me: 'Me'; + readAloudAgain: 'Play again'; + readAloudPause: 'Pause reading'; + readAloudResume: 'Resume reading'; + readAloudRequest: 'Read aloud'; + readAloudRequestFailed: 'Could not start playback. Check your connection.'; + readAloudNoMessageId: 'This message is not ready for on-demand reading yet. Pull to refresh or try again.'; + readingAloud: 'Reading aloud…'; + recentChats: 'Recent Chats'; + recordingPermissionDenied: 'Microphone permission is required to record'; + recordingStartFailed: 'Unable to start recording. Please try again.'; + resumeChatSubtitle: 'Open your latest conversation to keep talking.'; + resumeChatTitle: 'Continue chatting'; + send: 'Send'; + startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.'; + stopReadingAloud: 'Stop reading aloud'; + switchToText: 'Switch to text input'; + switchToVoice: 'Switch to voice input'; + tapToEndRecording: 'Tap to end'; + tapToStartRecording: 'Tap to start recording'; + ttsThisTurn: 'Speak'; + ttsThisTurnAccessibility: 'When on, assistant replies synthesize speech before text appears.'; + topicSuggestionsDismiss: 'Hide'; + timeDaysAgo_one: '{{count}} day ago'; + timeDaysAgo_other: '{{count}} days ago'; + timeHoursAgo_one: '{{count}} hour ago'; + timeHoursAgo_other: '{{count}} hours ago'; + timeJustNow: 'Just now'; + timeMinutesAgo_one: '{{count}} minute ago'; + timeMinutesAgo_other: '{{count}} minutes ago'; + viewAll: 'View All'; + voiceMessagePreview: 'Voice message'; + }; + explore: {}; + home: {}; + legal: { + titlePrivacy: 'Privacy Policy'; + titleTerms: 'User Agreement'; + }; + memoir: { + chapterLabel: 'Chapter {{index}}'; + chapterReading: { + back: 'Back'; + backgroundColor: 'Background'; + bgPureWhite: 'White'; + bgSepia: 'Sepia'; + cancel: 'Cancel'; + chapterNotFound: 'Chapter not found'; + close: 'Close'; + confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.'; + deleteChapter: 'Delete Chapter'; + deleteChapterAction: 'Delete'; + fontSans: 'Sans'; + fontSerif: 'Serif'; + fontSize: 'Font Size'; + fontSizeDefault: 'Medium'; + fontSizeLarge: 'Large'; + fontSizeSmall: 'Small'; + readingSettings: 'Reading Settings'; + settings: 'Settings'; + typography: 'Typography'; + }; + continueWriting: 'Continue Writing'; + emptySubtitle: 'Chat with your companion to record your stories'; + emptyTitle: 'No memoir yet'; + frameworkChapters: { + chapter1: 'Childhood and upbringing'; + chapter2: 'Education and young adulthood'; + chapter3: 'Early career'; + chapter4: 'Major achievements and peak moments'; + chapter5: 'Setbacks, challenges, and turning points'; + chapter6: 'Family and relationships'; + chapter7: 'Beliefs and values'; + chapter8: 'Life summary'; + }; + loadErrorMessage: 'Could not load chapters'; + loadErrorRetry: 'Retry'; + pageTitle: 'Memoir'; + readMemory: 'Read Memory'; + startChapter: 'Start Writing'; + statusDrafting: 'Drafting'; + statusLocked: 'Locked'; + statusPending: 'Pending'; + wordsCount: '{{count}} words'; + }; + profile: { + about: { + aboutUs: 'About Us'; + title: 'About'; + }; + appExperience: { + language: 'Language'; + languageDesc: 'Display language'; + largeText: 'Large Text'; + largeTextDesc: 'Make reading easier'; + nightMode: 'Night Mode'; + nightModeDesc: 'Use dark theme'; + theme: 'Theme'; + themeDesc: 'Color theme'; + title: 'App Experience'; + }; + dataPrivacy: { + deleteAll: 'Delete All Data'; + deleteUnderDevelopment: 'Delete data feature is under development.'; + exportAll: 'Export All Data'; + exportUnderDevelopment: 'Export feature is under development.'; + purgeDialogCancel: 'Cancel'; + purgeDialogConfirm: 'Delete permanently'; + purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.'; + purgeDialogTitle: 'Final confirmation'; + purgeInputLabel: 'Confirmation phrase'; + purgeInputPlaceholder: 'Type the phrase shown above'; + purgeOpenConfirm: 'I understand, continue'; + purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:'; + purgeSubmitting: 'Deleting…'; + purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.'; + purgeWarningTitle: 'Before you continue'; + title: 'Data & Privacy'; + }; + editAvatar: 'Edit Profile Picture'; + helpSupport: { + faq: 'FAQ'; + feedback: 'Feedback & Support'; + feedbackPageTitle: 'Share your thoughts'; + title: 'Help & Support'; + }; + personalInfo: { + avatarPresetFailed: 'Could not set preset avatar'; + avatarUploadFailed: 'Could not upload avatar'; + birthPlacePlaceholder: 'Birthplace'; + birthYearPlaceholder: 'Birth year'; + cancel: 'Cancel'; + changeAvatar: 'Change photo'; + chooseFromLibrary: 'Choose from library'; + choosePreset: 'Preset avatars'; + grewUpPlaceholder: 'Where you grew up'; + libraryPermissionDenied: 'Photo library access is required to pick an image'; + nickname: 'Nickname'; + nicknamePlaceholder: 'Enter nickname'; + nicknameRequired: 'Please enter a nickname'; + occupationPlaceholder: 'Occupation'; + presetPickTitle: 'Choose a preset'; + save: 'Save'; + saveFailed: 'Could not save'; + savePartialBody: 'Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.'; + savePartialTitle: 'Partially saved'; + saving: 'Saving…'; + tapAwayToClose: 'Tap outside to close'; + title: 'Personal info'; + }; + signOut: 'Sign Out'; + signingOut: 'Signing out...'; + tier: { + free: 'Free'; + pro: 'Pro'; + pro_plus: 'Pro+'; + test: 'Test'; + }; + userNamePlaceholder: 'User'; + userTier: '{{tier}}'; + }; } export default Resources; From 897f49f2abe7751a3f32d6205e0381ec51c0f913 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 18 May 2026 16:47:29 +0800 Subject: [PATCH 10/14] feat(app-expo): tiered large-text presets with English-friendly default Replace the boolean large-text flag with three global typography tiers, defaulting new installs to the smallest tier when English is in effect while preserving legacy storage and Chinese defaults. Add a profile sub-screen to pick the tier and unit tests for storage resolution. Co-authored-by: Cursor --- app-expo/design-tokens.json | 20 + app-expo/src/app/(main)/chapter/[id].tsx | 4 +- app-expo/src/app/(main)/conversation/[id].tsx | 143 +++-- app-expo/src/app/(main)/large-text.tsx | 46 ++ app-expo/src/app/(tabs)/profile.tsx | 26 +- app-expo/src/components/screen-header.tsx | 60 ++- app-expo/src/core/app-settings-context.tsx | 55 +- app-expo/src/core/settings/app-settings.ts | 79 ++- app-expo/src/core/typography-context.tsx | 21 +- app-expo/src/i18n/generated/resources.ts | 500 +++++++++--------- app-expo/src/i18n/locales/en/profile.json | 12 +- app-expo/src/i18n/locales/zh/profile.json | 10 +- .../settings/app-settings-large-text.test.ts | 31 ++ 13 files changed, 663 insertions(+), 344 deletions(-) create mode 100644 app-expo/src/app/(main)/large-text.tsx create mode 100644 app-expo/tests/core/settings/app-settings-large-text.test.ts diff --git a/app-expo/design-tokens.json b/app-expo/design-tokens.json index 506c93b..df887f4 100644 --- a/app-expo/design-tokens.json +++ b/app-expo/design-tokens.json @@ -220,6 +220,26 @@ "lineHeightTight": 28, "lineHeightLoose": 34, "lineHeightXLoose": 40 + }, + "xxlarge": { + "headingLarge": 50, + "headingMedium": 37, + "headingSmall": 32, + "titleLarge": 32, + "titleMedium": 28, + "titleSmall": 24, + "bodyLarge": 32, + "bodyMedium": 30, + "bodySmall": 20, + "captionLarge": 20, + "captionMedium": 18, + "captionSmall": 17, + "sectionTitle": 18, + "badge": 14, + "lineHeightNormal": 34, + "lineHeightTight": 32, + "lineHeightLoose": 39, + "lineHeightXLoose": 46 } } } diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index c364fa6..35701bd 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -360,7 +360,7 @@ function ReadingSettingsModal({ export default function ChapterScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const { preferences: { fontSize, @@ -448,7 +448,7 @@ export default function ChapterScreen() { const headerOccupiedHeight = getScreenHeaderLayoutMetrics(insets, { useSafeArea: true, variant: 'reading', - largeText, + largeTextLevel, typography, }).totalHeight; diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 4e264d7..e66984f 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -1151,7 +1151,7 @@ export default function ConversationScreen() { const { t } = useTranslation('conversation'); const { t: tApp } = useTranslation('app'); const typography = useTypography(); - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const { user } = useSession(); const { data: profile } = useProfile(); @@ -1165,101 +1165,166 @@ export default function ConversationScreen() { return c ? c.toUpperCase() : '?'; }, [user?.nickname, profile?.nickname]); - /** 大字模式:对话气泡与输入使用更大一档,与设置中的「大字」一致 */ + /** 大字档位:气泡与输入在全局 Typography 基础上再分层放大 */ const chatBubbleTextStyle = useMemo( () => ({ - fontSize: largeText ? typography.headingMedium : typography.bodyLarge, - lineHeight: largeText - ? typography.lineHeightXLoose - : typography.lineHeightLoose, + fontSize: + largeTextLevel >= 2 + ? typography.headingLarge + : largeTextLevel >= 1 + ? typography.headingMedium + : typography.bodyLarge, + lineHeight: + largeTextLevel >= 1 + ? typography.lineHeightXLoose + : typography.lineHeightLoose, fontWeight: '400' as const, }), - [typography, largeText], + [typography, largeTextLevel], ); - /** 大字模式:朗读图标与触控区与气泡字号同档放大 */ - const chatReadAloudIconSize = largeText ? 24 : 20; - const chatReadAloudButtonSize = largeText ? 52 : 44; + /** 朗读图标与触控区与气泡字号同档放大 */ + const chatReadAloudIconSize = + largeTextLevel >= 2 ? 28 : largeTextLevel >= 1 ? 24 : 20; + const chatReadAloudButtonSize = + largeTextLevel >= 2 ? 58 : largeTextLevel >= 1 ? 52 : 44; const chatVoiceDurationStyle = useMemo(() => { - const fs = largeText ? typography.headingSmall : typography.titleMedium; + const fs = + largeTextLevel >= 2 + ? typography.headingMedium + : largeTextLevel >= 1 + ? typography.headingSmall + : typography.titleMedium; return { fontSize: fs, lineHeight: Math.ceil(fs * 1.25), fontWeight: '500' as const, }; - }, [typography, largeText]); + }, [typography, largeTextLevel]); const chatTypingLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodySmall : typography.captionLarge, + fontSize: + largeTextLevel >= 2 + ? typography.bodyMedium + : largeTextLevel >= 1 + ? typography.bodySmall + : typography.captionLarge, lineHeight: typography.lineHeightNormal, }), - [typography, largeText], + [typography, largeTextLevel], ); const headerTitleFontStyle = useMemo( () => ({ - fontSize: largeText ? typography.headingMedium : typography.headingSmall, - lineHeight: largeText - ? Math.ceil(typography.headingMedium * 1.4) - : Math.ceil(typography.headingSmall * 1.28), + fontSize: + largeTextLevel >= 2 + ? typography.headingLarge + : largeTextLevel >= 1 + ? typography.headingMedium + : typography.headingSmall, + lineHeight: + largeTextLevel >= 2 + ? Math.ceil(typography.headingLarge * 1.4) + : largeTextLevel >= 1 + ? Math.ceil(typography.headingMedium * 1.4) + : Math.ceil(typography.headingSmall * 1.28), }), - [typography, largeText], + [typography, largeTextLevel], ); const headerTtsSwitchLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodySmall : typography.captionLarge, + fontSize: + largeTextLevel >= 2 + ? typography.bodyMedium + : largeTextLevel >= 1 + ? typography.bodySmall + : typography.captionLarge, fontWeight: '600' as const, color: CHAT_COLORS.primary, }), - [typography, largeText], + [typography, largeTextLevel], ); - const inputLineHeight = largeText - ? typography.lineHeightLoose - : typography.lineHeightNormal; + const inputLineHeight = + largeTextLevel >= 2 + ? typography.lineHeightXLoose + : largeTextLevel >= 1 + ? typography.lineHeightLoose + : typography.lineHeightNormal; const inputTextStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodyLarge : typography.bodyMedium, + fontSize: + largeTextLevel >= 2 + ? typography.bodyLarge + : largeTextLevel >= 1 + ? typography.bodyLarge + : typography.bodyMedium, lineHeight: inputLineHeight, color: CHAT_COLORS.onSurface, maxHeight: inputLineHeight * 4, }), - [typography, inputLineHeight, largeText], + [typography, inputLineHeight, largeTextLevel], ); const connectionNoticeTitleStyle = useMemo(() => { - const fs = largeText ? typography.titleSmall : typography.captionLarge; + const fs = + largeTextLevel >= 2 + ? typography.titleMedium + : largeTextLevel >= 1 + ? typography.titleSmall + : typography.captionLarge; return { fontSize: fs, lineHeight: Math.ceil(fs * 1.28), fontWeight: '700' as const, }; - }, [typography, largeText]); + }, [typography, largeTextLevel]); const connectionNoticeBodyStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodySmall : typography.captionLarge, + fontSize: + largeTextLevel >= 2 + ? typography.bodyMedium + : largeTextLevel >= 1 + ? typography.bodySmall + : typography.captionLarge, lineHeight: typography.lineHeightLoose, }), - [typography, largeText], + [typography, largeTextLevel], ); const sendButtonLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodyLarge : typography.bodyMedium, + fontSize: + largeTextLevel >= 2 + ? typography.titleSmall + : largeTextLevel >= 1 + ? typography.bodyLarge + : typography.bodyMedium, fontWeight: '500' as const, }), - [typography, largeText], + [typography, largeTextLevel], ); const voiceRecordLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodyLarge : typography.bodyMedium, - lineHeight: largeText - ? typography.lineHeightLoose - : typography.lineHeightNormal, + fontSize: + largeTextLevel >= 2 + ? typography.titleSmall + : largeTextLevel >= 1 + ? typography.bodyLarge + : typography.bodyMedium, + lineHeight: + largeTextLevel >= 1 + ? typography.lineHeightLoose + : typography.lineHeightNormal, }), - [typography, largeText], + [typography, largeTextLevel], ); const voiceRecordDurationStyle = useMemo( () => ({ - fontSize: largeText ? typography.captionLarge : typography.captionMedium, + fontSize: + largeTextLevel >= 2 + ? typography.titleSmall + : largeTextLevel >= 1 + ? typography.captionLarge + : typography.captionMedium, lineHeight: typography.lineHeightNormal, }), - [typography, largeText], + [typography, largeTextLevel], ); const statusBadgeTextStyle = useMemo( () => ({ diff --git a/app-expo/src/app/(main)/large-text.tsx b/app-expo/src/app/(main)/large-text.tsx new file mode 100644 index 0000000..a900e94 --- /dev/null +++ b/app-expo/src/app/(main)/large-text.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { Check } from 'lucide-react-native'; + +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { ScreenHeader } from '@/components/screen-header'; +import { useAppSettings } from '@/hooks/use-app-settings'; + +export default function LargeTextScreen() { + const { t } = useTranslation('profile'); + const { largeTextLevel, largeTextOptions, changeLargeTextLevel } = + useAppSettings(); + + return ( + + + + {largeTextOptions.map((opt) => { + const isSelected = opt.value === largeTextLevel; + return ( + void changeLargeTextLevel(opt.value)} + className="flex-row items-center justify-between border-b border-border py-4 active:bg-muted" + > + + {opt.label} + + {opt.description} + + + {isSelected && ( + + )} + + ); + })} + + + ); +} diff --git a/app-expo/src/app/(tabs)/profile.tsx b/app-expo/src/app/(tabs)/profile.tsx index 31f54aa..90a6a32 100644 --- a/app-expo/src/app/(tabs)/profile.tsx +++ b/app-expo/src/app/(tabs)/profile.tsx @@ -73,11 +73,13 @@ function RowButton({ } function LanguageRow({ + icon: LeadingIcon = Globe, label, description, currentLabel, onPress, }: { + icon?: LucideIcon; label: string; description: string; currentLabel: string; @@ -89,7 +91,7 @@ function LanguageRow({ onPress={onPress} > - + {label} @@ -168,8 +170,8 @@ export default function ProfileScreen() { languageOptions, themeName, themeOptions, - largeText, - changeLargeText, + largeTextLevel, + largeTextOptions, darkMode, changeDarkMode, } = useAppSettings(); @@ -188,6 +190,8 @@ export default function ProfileScreen() { const currentThemeLabel = themeOptions.find((o) => o.value === themeName)?.label ?? tApp('theme.default'); + const currentLargeTextLabel = + largeTextOptions.find((o) => o.value === largeTextLevel)?.label ?? ''; const avatarUri = resolveApiMediaUrl(user?.avatar_url ?? null); @@ -252,13 +256,15 @@ export default function ProfileScreen() { onPress={() => router.push('/(main)/theme')} /> )} - + {settingsReady && ( + router.push('/(main)/large-text')} + /> + )} = 2 ? 20 : level >= 1 ? 18 : 16; + const titleRowPaddingV = level >= 2 ? 10 : level >= 1 ? 8 : 4; + const backTouchMin = + level >= 2 + ? BACK_HIT_MIN_XLARGE + : level >= 1 + ? BACK_HIT_MIN_LARGE + : BACK_HIT_MIN; + const { variant, typography, largeTextLevel } = opts; const titleFontSize = variant === 'chat' - ? largeText - ? typography.headingMedium - : typography.headingSmall + ? largeTextLevel >= 2 + ? typography.headingLarge + : largeTextLevel >= 1 + ? typography.headingMedium + : typography.headingSmall : Math.max(typography.titleLarge, typography.headingSmall); + const lhChatMul = largeTextLevel >= 2 ? 1.52 : largeTextLevel >= 1 ? 1.45 : 1.38; + const lhChatPad = largeTextLevel >= 2 ? 16 : largeTextLevel >= 1 ? 14 : 10; + const lhDefaultPad = + largeTextLevel >= 2 ? 12 : largeTextLevel >= 1 ? 10 : 8; const titleLineMin = variant === 'chat' - ? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) + - (largeText ? 14 : 10) - : Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8); + ? Math.ceil(titleFontSize * lhChatMul) + lhChatPad + : Math.ceil(titleFontSize * 1.32) + lhDefaultPad; const titleRowMinHeight = Math.max(backTouchMin, titleLineMin); const titleRowOuterHeight = titleRowMinHeight + 2 * titleRowPaddingV; const paddingTop = opts.useSafeArea ? Math.max(insets.top, 12) : 12; @@ -120,17 +136,25 @@ export function ScreenHeader({ useSafeArea = true, }: ScreenHeaderProps) { const insets = useSafeAreaInsets(); - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const typography = useTypography(); const colors = VARIANT_COLORS[variant]; - const backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE; + const backIconSize = + largeTextLevel >= 2 + ? BACK_ICON_SIZE_XLARGE + : largeTextLevel >= 1 + ? 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; + const hitAndroidTb = largeTextLevel >= 2 ? 12 : largeTextLevel >= 1 ? 10 : 6; + const hitIosTb = largeTextLevel >= 2 ? 10 : largeTextLevel >= 1 ? 8 : 4; + /** * 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。 * 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。 @@ -145,7 +169,7 @@ export function ScreenHeader({ } = getScreenHeaderLayoutMetrics(insets, { useSafeArea, variant, - largeText, + largeTextLevel, typography, }); @@ -192,14 +216,14 @@ export function ScreenHeader({ hitSlop={ Platform.OS === 'android' ? { - top: largeText ? 10 : 6, - bottom: largeText ? 10 : 6, + top: hitAndroidTb, + bottom: hitAndroidTb, right: 8, left: BACK_EXTRA_HIT_LEFT, } : { - top: largeText ? 8 : 4, - bottom: largeText ? 8 : 4, + top: hitIosTb, + bottom: hitIosTb, left: 2, right: 4, } diff --git a/app-expo/src/core/app-settings-context.tsx b/app-expo/src/core/app-settings-context.tsx index 391789f..46b02e6 100644 --- a/app-expo/src/core/app-settings-context.tsx +++ b/app-expo/src/core/app-settings-context.tsx @@ -12,15 +12,16 @@ import { clearAppLanguage, getAppLanguage, getDarkMode, - getLargeText, + getLargeTextLevel, getThemeName, setAppLanguage, setDarkMode, - setLargeText, + setLargeTextLevel, setThemeName, supportedLanguages, THEME_NAMES, type AppLanguage, + type LargeTextLevel, type ThemeName, } from '@/core/settings/app-settings'; import i18n, { syncLanguageWithDevice } from '@/i18n'; @@ -32,8 +33,11 @@ type AppSettingsContextValue = { hasLanguageOverride: boolean; languageOptions: { code: AppLanguage | 'system'; label: string }[]; changeLanguage: (lang: AppLanguage | 'system') => Promise; - largeText: boolean; - changeLargeText: (value: boolean) => Promise; + largeTextLevel: LargeTextLevel; + /** 兼容:>=1 表示开启「大字」相关局部加量 */ + largeTextComfort: boolean; + changeLargeTextLevel: (value: LargeTextLevel) => Promise; + largeTextOptions: { value: LargeTextLevel; label: string; description: string }[]; darkMode: boolean; changeDarkMode: (value: boolean) => Promise; themeName: ThemeName; @@ -46,9 +50,10 @@ const AppSettingsContext = createContext(null); export function AppSettingsProvider({ children }: PropsWithChildren) { const { setColorScheme } = useColorScheme(); const { t } = useTranslation('app'); + const { t: tProfile } = useTranslation('profile'); const [language, setLanguageState] = useState(null); - const [largeText, setLargeTextState] = useState(true); + const [largeTextLevel, setLargeTextLevelState] = useState(0); const [darkMode, setDarkModeState] = useState(false); const [themeName, setThemeNameState] = useState('default'); const [ready, setReady] = useState(false); @@ -57,15 +62,15 @@ export function AppSettingsProvider({ children }: PropsWithChildren) { let cancelled = false; async function load() { - const [lang, large, dark, theme] = await Promise.all([ + const [lang, level, dark, theme] = await Promise.all([ getAppLanguage(), - getLargeText(), + getLargeTextLevel(), getDarkMode(), getThemeName(), ]); if (cancelled) return; setLanguageState(lang); - setLargeTextState(large); + setLargeTextLevelState(level); setDarkModeState(dark); setThemeNameState(theme); if (dark) setColorScheme('dark'); @@ -90,9 +95,9 @@ export function AppSettingsProvider({ children }: PropsWithChildren) { } }, []); - const changeLargeText = useCallback(async (value: boolean) => { - await setLargeText(value); - setLargeTextState(value); + const changeLargeTextLevel = useCallback(async (value: LargeTextLevel) => { + await setLargeTextLevel(value); + setLargeTextLevelState(value); }, []); const changeDarkMode = useCallback( @@ -124,14 +129,38 @@ export function AppSettingsProvider({ children }: PropsWithChildren) { })), ]; + const largeTextOptions: { + value: LargeTextLevel; + label: string; + description: string; + }[] = [ + { + value: 0, + label: tProfile('appExperience.largeTextLevel.standard'), + description: tProfile('appExperience.largeTextLevel.standardDesc'), + }, + { + value: 1, + label: tProfile('appExperience.largeTextLevel.large'), + description: tProfile('appExperience.largeTextLevel.largeDesc'), + }, + { + value: 2, + label: tProfile('appExperience.largeTextLevel.extraLarge'), + description: tProfile('appExperience.largeTextLevel.extraLargeDesc'), + }, + ]; + const value: AppSettingsContextValue = { ready, language: (language ?? i18n.resolvedLanguage ?? 'zh') as AppLanguage, hasLanguageOverride: language !== null, languageOptions, changeLanguage, - largeText, - changeLargeText, + largeTextLevel, + largeTextComfort: largeTextLevel >= 1, + changeLargeTextLevel, + largeTextOptions, darkMode, changeDarkMode, themeName, diff --git a/app-expo/src/core/settings/app-settings.ts b/app-expo/src/core/settings/app-settings.ts index d0204e1..56308d6 100644 --- a/app-expo/src/core/settings/app-settings.ts +++ b/app-expo/src/core/settings/app-settings.ts @@ -1,3 +1,4 @@ +import { getLocales } from 'expo-localization'; import { Platform } from 'react-native'; import { @@ -6,7 +7,11 @@ import { setSecureItem, } from '@/core/storage/secure'; -import { supportedLanguages, type AppLanguage } from '@/i18n/resources'; +import { + fallbackLanguage, + supportedLanguages, + type AppLanguage, +} from '@/i18n/resources'; import { THEME_NAMES, type ThemeName } from '@/constants/theme-bridge'; @@ -18,6 +23,7 @@ import { const KEY_LANGUAGE = 'app_settings_language'; const KEY_LARGE_TEXT = 'app_settings_large_text'; +const KEY_LARGE_TEXT_LEVEL = 'app_settings_large_text_level'; const KEY_DARK_MODE = 'app_settings_dark_mode'; const KEY_THEME_NAME = 'app_settings_theme_name'; const KEY_TTS_SPEAK_DEFAULT = 'app_settings_tts_speak_default'; @@ -62,14 +68,73 @@ export async function clearAppLanguage(): Promise { await deleteStored(KEY_LANGUAGE); } -export async function getLargeText(): Promise { - const v = await getStored(KEY_LARGE_TEXT); - if (v == null || v === '') return true; - return v === 'true'; +export type LargeTextLevel = 0 | 1 | 2; + +function parseStoredLargeTextLevel(raw: string | null): LargeTextLevel | null { + if (raw === '0' || raw === '1' || raw === '2') { + return Number(raw) as LargeTextLevel; + } + return null; } -export async function setLargeText(value: boolean): Promise { - await setStored(KEY_LARGE_TEXT, value ? 'true' : 'false'); +function deviceLanguageFromLocales(): AppLanguage { + const locale = getLocales()[0]; + const tag = locale?.languageCode ?? locale?.languageTag; + if (!tag) return fallbackLanguage; + return tag.toLowerCase().startsWith('zh') ? 'zh' : 'en'; +} + +async function effectiveAppLanguage(): Promise { + const override = await getAppLanguage(); + return override ?? deviceLanguageFromLocales(); +} + +/** 无存储时的默认档位:英文关大字、中文沿用偏大默认 */ +export function defaultLargeTextLevelForLanguage(lang: AppLanguage): LargeTextLevel { + return lang === 'en' ? 0 : 1; +} + +/** + * 纯函数:由原始存储值与当前生效语言解析档位(供单测与实现共用)。 + */ +export function computeLargeTextLevelFromStorage( + levelRaw: string | null, + legacyLargeTextRaw: string | null, + effectiveLanguage: AppLanguage, +): LargeTextLevel { + const parsed = parseStoredLargeTextLevel(levelRaw); + if (parsed !== null) return parsed; + if (legacyLargeTextRaw === 'true') return 1; + if (legacyLargeTextRaw === 'false') return 0; + return defaultLargeTextLevelForLanguage(effectiveLanguage); +} + +export async function getLargeTextLevel(): Promise { + const [levelRaw, legacyRaw, lang] = await Promise.all([ + getStored(KEY_LARGE_TEXT_LEVEL), + getStored(KEY_LARGE_TEXT), + effectiveAppLanguage(), + ]); + const level = computeLargeTextLevelFromStorage(levelRaw, legacyRaw, lang); + + const hasValidLevel = parseStoredLargeTextLevel(levelRaw) !== null; + if (hasValidLevel && legacyRaw != null && legacyRaw !== '') { + await deleteStored(KEY_LARGE_TEXT); + } else if ( + !hasValidLevel && + legacyRaw != null && + legacyRaw !== '' + ) { + await setStored(KEY_LARGE_TEXT_LEVEL, String(level)); + await deleteStored(KEY_LARGE_TEXT); + } + + return level; +} + +export async function setLargeTextLevel(level: LargeTextLevel): Promise { + await setStored(KEY_LARGE_TEXT_LEVEL, String(level)); + await deleteStored(KEY_LARGE_TEXT); } export async function getDarkMode(): Promise { diff --git a/app-expo/src/core/typography-context.tsx b/app-expo/src/core/typography-context.tsx index 61b7f55..046a5b4 100644 --- a/app-expo/src/core/typography-context.tsx +++ b/app-expo/src/core/typography-context.tsx @@ -1,10 +1,9 @@ /** * Global typography from design tokens. * - * - **关大字模式** → `typography.large`(舒适基准,不用过小的 `normal` 档) - * - **开大字模式** → `typography.xlarge`(一级页、正文等整体再放大一档) + * **大字档位**对应全局 token:`large` / `xlarge` / `xxlarge`。 * - * 对话气泡等仍可在页面内用 `largeText` 做更大一级的 token 选择。 + * 对话气泡等仍在页面内用 `largeTextLevel` 做相对当前全局 token 的再放大。 * * Uses React Context instead of NativeWind vars() because vars() returns empty * objects on native (iOS/Android). See: @@ -17,11 +16,12 @@ import React, { type PropsWithChildren, } from 'react'; +import type { LargeTextLevel } from '@/core/settings/app-settings'; import { useAppSettings } from '@/hooks/use-app-settings'; import tokens from '../../design-tokens.json'; -export type TypographyScale = 'large' | 'xlarge'; +export type TypographyScale = 'large' | 'xlarge' | 'xxlarge'; export type TypographyTokens = Record; @@ -30,13 +30,20 @@ const TypographyContext = createContext(null); const SCALE: Record = { large: tokens.typography.large as TypographyTokens, xlarge: tokens.typography.xlarge as TypographyTokens, + xxlarge: tokens.typography.xxlarge as TypographyTokens, }; +function scaleForLargeTextLevel(level: LargeTextLevel): TypographyScale { + if (level >= 2) return 'xxlarge'; + if (level >= 1) return 'xlarge'; + return 'large'; +} + export function TypographyProvider({ children }: PropsWithChildren) { - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const typography = useMemo( - () => SCALE[largeText ? 'xlarge' : 'large'], - [largeText], + () => SCALE[scaleForLargeTextLevel(largeTextLevel)], + [largeTextLevel], ); return ( diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index 8335323..06a321b 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -1,250 +1,260 @@ // This file is automatically generated by i18next-cli. Do not edit manually. interface Resources { - app: { - languages: { - en: 'English'; - system: 'System'; - zh: 'Chinese'; - }; - name: 'Life Echo'; - tabs: { - conversations: 'Chats'; - explore: 'Explore'; - home: 'Home'; - memoir: 'Memoir'; - profile: 'Profile'; - }; - theme: { - default: 'Default'; - }; - }; - auth: { - login: { - codeLabel: 'Verification Code'; - getCode: 'Get Code'; - getCodeCountdown: 'Retry in {{seconds}}s'; - networkError: 'Network error. Please try again later.'; - phoneLabel: 'Phone Number'; - phonePlaceholder: 'Enter your phone number'; - privacyPolicy: 'Privacy Policy'; - submit: 'Login'; - termsAnd: 'and'; - termsIntro: 'I agree to the'; - termsRequired: 'Please agree to the User Agreement and Privacy Policy first'; - termsRequiredConfirm: 'OK'; - termsRequiredTitle: 'Agreement Required'; - userAgreement: 'User Agreement'; - welcomeSubtitle: 'Some lives grow richer the more you savor them.'; - welcomeTitle: 'Welcome back'; - }; - }; - common: { - chapterLabel: ''; - chapterReading: { - backgroundColor: ''; - bgPureWhite: ''; - bgSepia: ''; - close: ''; - fontSize: ''; - readingSettings: ''; - typography: ''; - }; - continueWriting: ''; - docs: 'Docs'; - emptySubtitle: ''; - emptyTitle: ''; - readMemory: ''; - startChapter: ''; - statusDrafting: ''; - statusLocked: ''; - statusPending: ''; - wordsCount: ''; - }; - conversation: { - addMore: 'More'; - agentName: 'Life Echo'; - assistantReplying: 'Replying…'; - cancel: 'Cancel'; - cancelRecording: 'Cancel recording'; - cannotReadAloud: 'Read unavailable'; - chatQueueSendTimeout: 'Connection timed out. Check your network and try again.'; - chatTitle: 'Conversation'; - chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.'; - chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.'; - chatUnavailableTitle: 'Chat unavailable'; - confirm: 'OK'; - confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.'; - connectionConnected: 'Connected'; - connectionConnecting: 'Connecting...'; - connectionDisconnected: 'Disconnected'; - createError: 'Unable to create conversation. Please check your network and try again.'; - delete: 'Delete'; - deleteConversation: 'Delete Conversation'; - emptyGreetingSubtitle: 'Chat with your companion and record your stories.'; - greetingTitle: 'Say Hello'; - inputPlaceholder: 'Type a message...'; - inputPlaceholderVoice: 'Type here or hold the mic to speak...'; - me: 'Me'; - readAloudAgain: 'Play again'; - readAloudPause: 'Pause reading'; - readAloudResume: 'Resume reading'; - readAloudRequest: 'Read aloud'; - readAloudRequestFailed: 'Could not start playback. Check your connection.'; - readAloudNoMessageId: 'This message is not ready for on-demand reading yet. Pull to refresh or try again.'; - readingAloud: 'Reading aloud…'; - recentChats: 'Recent Chats'; - recordingPermissionDenied: 'Microphone permission is required to record'; - recordingStartFailed: 'Unable to start recording. Please try again.'; - resumeChatSubtitle: 'Open your latest conversation to keep talking.'; - resumeChatTitle: 'Continue chatting'; - send: 'Send'; - startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.'; - stopReadingAloud: 'Stop reading aloud'; - switchToText: 'Switch to text input'; - switchToVoice: 'Switch to voice input'; - tapToEndRecording: 'Tap to end'; - tapToStartRecording: 'Tap to start recording'; - ttsThisTurn: 'Speak'; - ttsThisTurnAccessibility: 'When on, assistant replies synthesize speech before text appears.'; - topicSuggestionsDismiss: 'Hide'; - timeDaysAgo_one: '{{count}} day ago'; - timeDaysAgo_other: '{{count}} days ago'; - timeHoursAgo_one: '{{count}} hour ago'; - timeHoursAgo_other: '{{count}} hours ago'; - timeJustNow: 'Just now'; - timeMinutesAgo_one: '{{count}} minute ago'; - timeMinutesAgo_other: '{{count}} minutes ago'; - viewAll: 'View All'; - voiceMessagePreview: 'Voice message'; - }; - explore: {}; - home: {}; - legal: { - titlePrivacy: 'Privacy Policy'; - titleTerms: 'User Agreement'; - }; - memoir: { - chapterLabel: 'Chapter {{index}}'; - chapterReading: { - back: 'Back'; - backgroundColor: 'Background'; - bgPureWhite: 'White'; - bgSepia: 'Sepia'; - cancel: 'Cancel'; - chapterNotFound: 'Chapter not found'; - close: 'Close'; - confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.'; - deleteChapter: 'Delete Chapter'; - deleteChapterAction: 'Delete'; - fontSans: 'Sans'; - fontSerif: 'Serif'; - fontSize: 'Font Size'; - fontSizeDefault: 'Medium'; - fontSizeLarge: 'Large'; - fontSizeSmall: 'Small'; - readingSettings: 'Reading Settings'; - settings: 'Settings'; - typography: 'Typography'; - }; - continueWriting: 'Continue Writing'; - emptySubtitle: 'Chat with your companion to record your stories'; - emptyTitle: 'No memoir yet'; - frameworkChapters: { - chapter1: 'Childhood and upbringing'; - chapter2: 'Education and young adulthood'; - chapter3: 'Early career'; - chapter4: 'Major achievements and peak moments'; - chapter5: 'Setbacks, challenges, and turning points'; - chapter6: 'Family and relationships'; - chapter7: 'Beliefs and values'; - chapter8: 'Life summary'; - }; - loadErrorMessage: 'Could not load chapters'; - loadErrorRetry: 'Retry'; - pageTitle: 'Memoir'; - readMemory: 'Read Memory'; - startChapter: 'Start Writing'; - statusDrafting: 'Drafting'; - statusLocked: 'Locked'; - statusPending: 'Pending'; - wordsCount: '{{count}} words'; - }; - profile: { - about: { - aboutUs: 'About Us'; - title: 'About'; - }; - appExperience: { - language: 'Language'; - languageDesc: 'Display language'; - largeText: 'Large Text'; - largeTextDesc: 'Make reading easier'; - nightMode: 'Night Mode'; - nightModeDesc: 'Use dark theme'; - theme: 'Theme'; - themeDesc: 'Color theme'; - title: 'App Experience'; - }; - dataPrivacy: { - deleteAll: 'Delete All Data'; - deleteUnderDevelopment: 'Delete data feature is under development.'; - exportAll: 'Export All Data'; - exportUnderDevelopment: 'Export feature is under development.'; - purgeDialogCancel: 'Cancel'; - purgeDialogConfirm: 'Delete permanently'; - purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.'; - purgeDialogTitle: 'Final confirmation'; - purgeInputLabel: 'Confirmation phrase'; - purgeInputPlaceholder: 'Type the phrase shown above'; - purgeOpenConfirm: 'I understand, continue'; - purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:'; - purgeSubmitting: 'Deleting…'; - purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.'; - purgeWarningTitle: 'Before you continue'; - title: 'Data & Privacy'; - }; - editAvatar: 'Edit Profile Picture'; - helpSupport: { - faq: 'FAQ'; - feedback: 'Feedback & Support'; - feedbackPageTitle: 'Share your thoughts'; - title: 'Help & Support'; - }; - personalInfo: { - avatarPresetFailed: 'Could not set preset avatar'; - avatarUploadFailed: 'Could not upload avatar'; - birthPlacePlaceholder: 'Birthplace'; - birthYearPlaceholder: 'Birth year'; - cancel: 'Cancel'; - changeAvatar: 'Change photo'; - chooseFromLibrary: 'Choose from library'; - choosePreset: 'Preset avatars'; - grewUpPlaceholder: 'Where you grew up'; - libraryPermissionDenied: 'Photo library access is required to pick an image'; - nickname: 'Nickname'; - nicknamePlaceholder: 'Enter nickname'; - nicknameRequired: 'Please enter a nickname'; - occupationPlaceholder: 'Occupation'; - presetPickTitle: 'Choose a preset'; - save: 'Save'; - saveFailed: 'Could not save'; - savePartialBody: 'Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.'; - savePartialTitle: 'Partially saved'; - saving: 'Saving…'; - tapAwayToClose: 'Tap outside to close'; - title: 'Personal info'; - }; - signOut: 'Sign Out'; - signingOut: 'Signing out...'; - tier: { - free: 'Free'; - pro: 'Pro'; - pro_plus: 'Pro+'; - test: 'Test'; - }; - userNamePlaceholder: 'User'; - userTier: '{{tier}}'; - }; + "app": { + "languages": { + "en": "English", + "system": "System", + "zh": "Chinese" + }, + "name": "Life Echo", + "tabs": { + "conversations": "Chats", + "explore": "Explore", + "home": "Home", + "memoir": "Memoir", + "profile": "Profile" + }, + "theme": { + "default": "Default" + } + }, + "auth": { + "login": { + "codeLabel": "Verification Code", + "getCode": "Get Code", + "getCodeCountdown": "Retry in {{seconds}}s", + "networkError": "Network error. Please try again later.", + "phoneLabel": "Phone Number", + "phonePlaceholder": "Enter your phone number", + "privacyPolicy": "Privacy Policy", + "submit": "Login", + "termsAnd": "and", + "termsIntro": "I agree to the", + "termsRequired": "Please agree to the User Agreement and Privacy Policy first", + "termsRequiredConfirm": "OK", + "termsRequiredTitle": "Agreement Required", + "userAgreement": "User Agreement", + "welcomeSubtitle": "Some lives grow richer the more you savor them.", + "welcomeTitle": "Welcome back" + } + }, + "common": { + "chapterLabel": "", + "chapterReading": { + "backgroundColor": "", + "bgPureWhite": "", + "bgSepia": "", + "close": "", + "fontSize": "", + "readingSettings": "", + "typography": "" + }, + "continueWriting": "", + "docs": "Docs", + "emptySubtitle": "", + "emptyTitle": "", + "readMemory": "", + "startChapter": "", + "statusDrafting": "", + "statusLocked": "", + "statusPending": "", + "wordsCount": "" + }, + "conversation": { + "addMore": "More", + "agentName": "Life Echo", + "assistantReplying": "Replying…", + "cancel": "Cancel", + "cancelRecording": "Cancel recording", + "cannotReadAloud": "Read unavailable", + "chatQueueSendTimeout": "Connection timed out. Check your network and try again.", + "chatTitle": "Conversation", + "chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.", + "chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.", + "chatUnavailableTitle": "Chat unavailable", + "confirm": "OK", + "confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.", + "connectionConnected": "Connected", + "connectionConnecting": "Connecting...", + "connectionDisconnected": "Disconnected", + "createError": "Unable to create conversation. Please check your network and try again.", + "delete": "Delete", + "deleteConversation": "Delete Conversation", + "emptyGreetingSubtitle": "Chat with your companion and record your stories.", + "greetingTitle": "Say Hello", + "inputPlaceholder": "Type a message...", + "inputPlaceholderVoice": "Type here or hold the mic to speak...", + "me": "Me", + "readAloudAgain": "Play again", + "readAloudNoMessageId": "This message is not ready for on-demand reading yet. Pull to refresh or try again.", + "readAloudPause": "Pause reading", + "readAloudRequest": "Read aloud", + "readAloudRequestFailed": "Could not start playback. Check your connection.", + "readAloudResume": "Resume reading", + "readingAloud": "Reading aloud…", + "recentChats": "Recent Chats", + "recordingPermissionDenied": "Microphone permission is required to record", + "recordingStartFailed": "Unable to start recording. Please try again.", + "resumeChatSubtitle": "Open your latest conversation to keep talking.", + "resumeChatTitle": "Continue chatting", + "send": "Send", + "startNewSubtitle": "Capture a new memory or share your thoughts with your companion.", + "stopReadingAloud": "Stop reading aloud", + "switchToText": "Switch to text input", + "switchToVoice": "Switch to voice input", + "tapToEndRecording": "Tap to end", + "tapToStartRecording": "Tap to start recording", + "timeDaysAgo_one": "{{count}} day ago", + "timeDaysAgo_other": "{{count}} days ago", + "timeHoursAgo_one": "{{count}} hour ago", + "timeHoursAgo_other": "{{count}} hours ago", + "timeJustNow": "Just now", + "timeMinutesAgo_one": "{{count}} minute ago", + "timeMinutesAgo_other": "{{count}} minutes ago", + "topicSuggestionsDismiss": "Hide", + "ttsThisTurn": "Speak", + "ttsThisTurnAccessibility": "When on, assistant replies synthesize speech before text appears.", + "viewAll": "View All", + "voiceMessagePreview": "Voice message" + }, + "explore": { + + }, + "home": { + + }, + "legal": { + "titlePrivacy": "Privacy Policy", + "titleTerms": "User Agreement" + }, + "memoir": { + "chapterLabel": "Chapter {{index}}", + "chapterReading": { + "back": "Back", + "backgroundColor": "Background", + "bgPureWhite": "White", + "bgSepia": "Sepia", + "cancel": "Cancel", + "chapterNotFound": "Chapter not found", + "close": "Close", + "confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.", + "deleteChapter": "Delete Chapter", + "deleteChapterAction": "Delete", + "fontSans": "Sans", + "fontSerif": "Serif", + "fontSize": "Font Size", + "fontSizeDefault": "Medium", + "fontSizeLarge": "Large", + "fontSizeSmall": "Small", + "readingSettings": "Reading Settings", + "settings": "Settings", + "typography": "Typography" + }, + "emptySubtitle": "Chat with your companion to record your stories", + "emptyTitle": "No memoir yet", + "frameworkChapters": { + "chapter1": "Childhood and upbringing", + "chapter2": "Education and young adulthood", + "chapter3": "Early career", + "chapter4": "Major achievements and peak moments", + "chapter5": "Setbacks, challenges, and turning points", + "chapter6": "Family and relationships", + "chapter7": "Beliefs and values", + "chapter8": "Life summary" + }, + "loadErrorMessage": "Could not load chapters", + "loadErrorRetry": "Retry", + "pageTitle": "Memoir", + "readMemory": "Read Memory", + "statusDrafting": "Drafting", + "statusLocked": "Locked", + "statusPending": "Pending", + "wordsCount": "{{count}} words" + }, + "profile": { + "about": { + "aboutUs": "About Us", + "title": "About" + }, + "appExperience": { + "language": "Language", + "languageDesc": "Display language", + "largeText": "Large text", + "largeTextDesc": "Standard, large, or extra-large", + "largeTextLevel": { + "extraLarge": "Extra large", + "extraLargeDesc": "One more step up from Large", + "large": "Large", + "largeDesc": "Larger body text and headings app-wide", + "standard": "Standard", + "standardDesc": "Comfortable default for most screens" + }, + "nightMode": "Night Mode", + "nightModeDesc": "Use dark theme", + "theme": "Theme", + "themeDesc": "Color theme", + "title": "App Experience" + }, + "dataPrivacy": { + "deleteAll": "Delete All Data", + "deleteUnderDevelopment": "Delete data feature is under development.", + "exportAll": "Export All Data", + "exportUnderDevelopment": "Export feature is under development.", + "purgeDialogCancel": "Cancel", + "purgeDialogConfirm": "Delete permanently", + "purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.", + "purgeDialogTitle": "Final confirmation", + "purgeInputLabel": "Confirmation phrase", + "purgeInputPlaceholder": "Type the phrase shown above", + "purgeOpenConfirm": "I understand, continue", + "purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:", + "purgeSubmitting": "Deleting…", + "purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.", + "purgeWarningTitle": "Before you continue", + "title": "Data & Privacy" + }, + "editAvatar": "Edit Profile Picture", + "helpSupport": { + "faq": "FAQ", + "feedback": "Feedback & Support", + "feedbackPageTitle": "Share your thoughts", + "title": "Help & Support" + }, + "personalInfo": { + "avatarPresetFailed": "Could not set preset avatar", + "avatarUploadFailed": "Could not upload avatar", + "birthPlacePlaceholder": "Birthplace", + "birthYearPlaceholder": "Birth year", + "cancel": "Cancel", + "changeAvatar": "Change photo", + "chooseFromLibrary": "Choose from library", + "choosePreset": "Preset avatars", + "grewUpPlaceholder": "Where you grew up", + "libraryPermissionDenied": "Photo library access is required to pick an image", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "nicknameRequired": "Please enter a nickname", + "occupationPlaceholder": "Occupation", + "presetPickTitle": "Choose a preset", + "save": "Save", + "saveFailed": "Could not save", + "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", + "savePartialTitle": "Partially saved", + "saving": "Saving…", + "tapAwayToClose": "Tap outside to close", + "title": "Personal info" + }, + "signOut": "Sign Out", + "signingOut": "Signing out...", + "tier": { + "free": "Free", + "pro": "Pro", + "pro_plus": "Pro+", + "test": "Test" + }, + "userNamePlaceholder": "User", + "userTier": "{{tier}}" + } } export default Resources; diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index a1abacf..99072ce 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -6,8 +6,16 @@ "appExperience": { "language": "Language", "languageDesc": "Display language", - "largeText": "Large Text", - "largeTextDesc": "Make reading easier", + "largeText": "Large text", + "largeTextDesc": "Standard, large, or extra-large", + "largeTextLevel": { + "standard": "Standard", + "standardDesc": "Comfortable default for most screens", + "large": "Large", + "largeDesc": "Larger body text and headings app-wide", + "extraLarge": "Extra large", + "extraLargeDesc": "One more step up from Large" + }, "nightMode": "Night Mode", "nightModeDesc": "Use dark theme", "theme": "Theme", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index d27bbcf..22007b7 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -7,7 +7,15 @@ "language": "语言", "languageDesc": "应用显示语言", "largeText": "大字模式", - "largeTextDesc": "让阅读更轻松", + "largeTextDesc": "标准、大字或更大字号", + "largeTextLevel": { + "standard": "标准", + "standardDesc": "舒适阅读(默认较小)", + "large": "大字", + "largeDesc": "正文与标题整体放大", + "extraLarge": "超大", + "extraLargeDesc": "在「大字」基础上再放大一档" + }, "nightMode": "夜间模式", "nightModeDesc": "使用深色主题", "theme": "主题", diff --git a/app-expo/tests/core/settings/app-settings-large-text.test.ts b/app-expo/tests/core/settings/app-settings-large-text.test.ts new file mode 100644 index 0000000..7c4fcaf --- /dev/null +++ b/app-expo/tests/core/settings/app-settings-large-text.test.ts @@ -0,0 +1,31 @@ +import { + computeLargeTextLevelFromStorage, + defaultLargeTextLevelForLanguage, +} from '@/core/settings/app-settings'; + +describe('defaultLargeTextLevelForLanguage', () => { + it('uses level 0 for English', () => { + expect(defaultLargeTextLevelForLanguage('en')).toBe(0); + }); + + it('uses level 1 for Chinese', () => { + expect(defaultLargeTextLevelForLanguage('zh')).toBe(1); + }); +}); + +describe('computeLargeTextLevelFromStorage', () => { + it('prefers valid level key when present', () => { + expect(computeLargeTextLevelFromStorage('2', 'false', 'en')).toBe(2); + expect(computeLargeTextLevelFromStorage('0', 'true', 'zh')).toBe(0); + }); + + it('ignores invalid level key and uses legacy', () => { + expect(computeLargeTextLevelFromStorage('9', 'true', 'en')).toBe(1); + expect(computeLargeTextLevelFromStorage('', 'false', 'zh')).toBe(0); + }); + + it('uses language default when no usable values', () => { + expect(computeLargeTextLevelFromStorage(null, null, 'en')).toBe(0); + expect(computeLargeTextLevelFromStorage('', '', 'zh')).toBe(1); + }); +}); From 95856ca11a4f936bb103b47538de48b7b6e6ddd7 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 19 May 2026 11:11:58 +0800 Subject: [PATCH 11/14] feat(app-expo): show memoir chapter draft progress on list cards Explain invite vs. generating state using MemoirState slots, surface remaining characters to the display gate, refresh memoir-state on pull, and sync i18n. Co-authored-by: Cursor --- app-expo/src/app/(tabs)/memoir.tsx | 91 ++++++++++++++++--- .../src/features/memoir/draft-progress.ts | 53 +++++++++++ .../features/memoir/framework-chapter-keys.ts | 12 +++ app-expo/src/i18n/generated/resources.ts | 3 + app-expo/src/i18n/locales/en/memoir.json | 3 + app-expo/src/i18n/locales/zh/memoir.json | 3 + .../features/memoir/draft-progress.test.ts | 49 ++++++++++ 7 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 app-expo/src/features/memoir/draft-progress.ts create mode 100644 app-expo/tests/features/memoir/draft-progress.test.ts diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx index 816bdc9..1b6045e 100644 --- a/app-expo/src/app/(tabs)/memoir.tsx +++ b/app-expo/src/app/(tabs)/memoir.tsx @@ -28,7 +28,16 @@ import { buildFrameworkChapterPlaceholders, mergeFrameworkChaptersWithFetched, } from '@/features/memoir/framework-chapter-keys'; -import { useChapters, useCheckCoverGeneration } from '@/features/memoir/hooks'; +import { + memoirDraftCharsRemaining, + memoirDraftHasStarted, + resolvedChapterCategory, +} from '@/features/memoir/draft-progress'; +import { + useChapters, + useCheckCoverGeneration, + useMemoirState, +} from '@/features/memoir/hooks'; import type { ChapterViewModel } from '@/features/memoir/types'; type ChapterVariant = 'completed' | 'drafting'; @@ -69,11 +78,15 @@ function ChapterCard({ variant, t, onReadPress, + draftStarted = false, + draftRemainingChars = 0, }: { item: ChapterViewModel; variant: ChapterVariant; t: (key: string) => string; onReadPress: () => void; + draftStarted?: boolean; + draftRemainingChars?: number; }) { const typography = useTypography(); const { width } = useWindowDimensions(); @@ -225,7 +238,7 @@ function ChapterCard({ {item.title} - + {t('statusDrafting')} + {!draftStarted ? ( + + {t('draftingInviteChat')} + + ) : ( + + + {t('draftingGenerating')} + + {draftRemainingChars > 0 ? ( + + {t('draftingWordsRemaining').replace( + '{{count}}', + String(draftRemainingChars), + )} + + ) : null} + + )} @@ -267,6 +312,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) { export default function MemoirScreen() { const { t } = useTranslation('memoir'); const { viewModels: chapters, isLoading, isError, refetch } = useChapters(); + const { data: memoirState, refetch: refetchMemoirState } = useMemoirState(); const checkCover = useCheckCoverGeneration(); const [refreshing, setRefreshing] = useState(false); const didRunInitialCoverCheckRef = useRef(false); @@ -291,11 +337,11 @@ export default function MemoirScreen() { setRefreshing(true); try { await checkCover.mutateAsync(undefined); - await refetch(); + await Promise.all([refetch(), refetchMemoirState()]); } finally { setRefreshing(false); } - }, [checkCover, refetch]); + }, [checkCover, refetch, refetchMemoirState]); const handleReadChapter = useCallback((chapterId: string) => { router.push(`/(main)/chapter/${chapterId}`); @@ -329,15 +375,34 @@ export default function MemoirScreen() { ) : isError ? ( void refetch()} /> ) : ( - displayChapters.map((item) => ( - string} - onReadPress={() => handleReadChapter(item.id)} - /> - )) + displayChapters.map((item) => { + const variant = getChapterVariant(item); + const cat = resolvedChapterCategory(item); + const started = + variant === 'drafting' + ? memoirDraftHasStarted( + memoirState?.slots, + cat, + item.wordCount, + ) + : false; + const remaining = + variant === 'drafting' + ? memoirDraftCharsRemaining(item.wordCount) + : 0; + + return ( + string} + onReadPress={() => handleReadChapter(item.id)} + /> + ); + }) )} diff --git a/app-expo/src/features/memoir/draft-progress.ts b/app-expo/src/features/memoir/draft-progress.ts new file mode 100644 index 0000000..34d04ff --- /dev/null +++ b/app-expo/src/features/memoir/draft-progress.ts @@ -0,0 +1,53 @@ +import type { MemoirState } from './types'; + +import { CHAPTER_CATEGORY_BY_ORDER_INDEX } from './framework-chapter-keys'; + +/** 与后端 `reading_segment_materialize.MIN_STORY_CHARS_IN_CHAPTER` 一致:章节可读成稿阈值 */ +export const MIN_CHAPTER_DISPLAY_CHARS = 300; + +/** + * Chat 口述槽的阶段键(MemoirState.slots);多章 career_* 共用 `career`,beliefs/summary 与 `belief` 槽对齐。 + */ +export function chapterCategoryToInterviewStage(category: string): string { + const c = (category ?? '').trim(); + if (!c) return 'childhood'; + if (c.startsWith('career_')) return 'career'; + if (c === 'beliefs' || c === 'summary') return 'belief'; + return c; +} + +export function resolvedChapterCategory(vm: { + category: string; + orderIndex: number; +}): string { + const raw = vm.category?.trim(); + if (raw) return raw; + return CHAPTER_CATEGORY_BY_ORDER_INDEX[vm.orderIndex] ?? 'childhood'; +} + +export function interviewStageHasSnippetMaterial( + slots: MemoirState['slots'] | undefined, + stage: string, +): boolean { + if (!slots) return false; + const block = slots[stage]; + if (!block) return false; + return Object.values(block).some( + (cell) => (cell?.snippet ?? '').trim().length > 0, + ); +} + +export function memoirDraftHasStarted( + slots: MemoirState['slots'] | undefined, + chapterCategory: string, + chapterWordCount: number, +): boolean { + const stage = chapterCategoryToInterviewStage(chapterCategory); + if (chapterWordCount > 0) return true; + return interviewStageHasSnippetMaterial(slots, stage); +} + +export function memoirDraftCharsRemaining(chapterWordCount: number): number { + const n = typeof chapterWordCount === 'number' ? chapterWordCount : 0; + return Math.max(0, MIN_CHAPTER_DISPLAY_CHARS - n); +} diff --git a/app-expo/src/features/memoir/framework-chapter-keys.ts b/app-expo/src/features/memoir/framework-chapter-keys.ts index 798dc8b..e1835a0 100644 --- a/app-expo/src/features/memoir/framework-chapter-keys.ts +++ b/app-expo/src/features/memoir/framework-chapter-keys.ts @@ -16,6 +16,18 @@ export const FRAMEWORK_CHAPTER_KEYS = [ export type FrameworkChapterKey = (typeof FRAMEWORK_CHAPTER_KEYS)[number]; +/** 与后端 `CHAPTER_ORDER` 一致(用于占位章节缺省 category) */ +export const CHAPTER_CATEGORY_BY_ORDER_INDEX = [ + 'childhood', + 'education', + 'career_early', + 'career_achievement', + 'career_challenge', + 'family', + 'beliefs', + 'summary', +] as const; + export function buildFrameworkChapterPlaceholders( tr: (key: string) => string, ): ChapterViewModel[] { diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index 06a321b..a1481f9 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -150,6 +150,9 @@ interface Resources { "settings": "Settings", "typography": "Typography" }, + "draftingGenerating": "Writing your memoir…", + "draftingInviteChat": "Chat about this chapter to get started", + "draftingWordsRemaining": "{{count}} characters to go", "emptySubtitle": "Chat with your companion to record your stories", "emptyTitle": "No memoir yet", "frameworkChapters": { diff --git a/app-expo/src/i18n/locales/en/memoir.json b/app-expo/src/i18n/locales/en/memoir.json index 7c072ec..ede9f08 100644 --- a/app-expo/src/i18n/locales/en/memoir.json +++ b/app-expo/src/i18n/locales/en/memoir.json @@ -40,5 +40,8 @@ "statusDrafting": "Drafting", "statusLocked": "Locked", "statusPending": "Pending", + "draftingInviteChat": "Chat about this chapter to get started", + "draftingGenerating": "Writing your memoir…", + "draftingWordsRemaining": "{{count}} characters to go", "wordsCount": "{{count}} words" } diff --git a/app-expo/src/i18n/locales/zh/memoir.json b/app-expo/src/i18n/locales/zh/memoir.json index b69c8f6..382d72c 100644 --- a/app-expo/src/i18n/locales/zh/memoir.json +++ b/app-expo/src/i18n/locales/zh/memoir.json @@ -40,5 +40,8 @@ "statusDrafting": "撰写中", "statusLocked": "已锁定", "statusPending": "待解锁", + "draftingInviteChat": "聊聊这部分内容吧", + "draftingGenerating": "正在生成回忆录…", + "draftingWordsRemaining": "还差 {{count}} 字", "wordsCount": "{{count}} 字" } diff --git a/app-expo/tests/features/memoir/draft-progress.test.ts b/app-expo/tests/features/memoir/draft-progress.test.ts new file mode 100644 index 0000000..7916f79 --- /dev/null +++ b/app-expo/tests/features/memoir/draft-progress.test.ts @@ -0,0 +1,49 @@ +import { + chapterCategoryToInterviewStage, + memoirDraftCharsRemaining, + memoirDraftHasStarted, + MIN_CHAPTER_DISPLAY_CHARS, + resolvedChapterCategory, +} from '@/features/memoir/draft-progress'; + +describe('draft-progress', () => { + test('chapterCategoryToInterviewStage maps career chapters to career', () => { + expect(chapterCategoryToInterviewStage('career_early')).toBe('career'); + expect(chapterCategoryToInterviewStage('career_achievement')).toBe('career'); + }); + + test('chapterCategoryToInterviewStage maps beliefs and summary to belief', () => { + expect(chapterCategoryToInterviewStage('beliefs')).toBe('belief'); + expect(chapterCategoryToInterviewStage('summary')).toBe('belief'); + }); + + test('resolvedChapterCategory falls back to order index', () => { + expect( + resolvedChapterCategory({ category: '', orderIndex: 2 }), + ).toBe('career_early'); + }); + + test('memoirDraftHasStarted when interview slots have snippet', () => { + const slots = { + childhood: { place: { snippet: '老家在小城', segment_ids: [] } }, + }; + expect( + memoirDraftHasStarted(slots, 'childhood', 0), + ).toBe(true); + }); + + test('memoirDraftHasStarted when word count positive', () => { + expect(memoirDraftHasStarted({}, 'childhood', 12)).toBe(true); + }); + + test('memoirDraftCharsRemaining caps at zero', () => { + expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS)).toBe(0); + expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS + 50)).toBe(0); + }); + + test('memoirDraftCharsRemaining subtracts from threshold', () => { + expect(memoirDraftCharsRemaining(100)).toBe( + MIN_CHAPTER_DISPLAY_CHARS - 100, + ); + }); +}); From b22f1cd4c4d33f7444c250df90229605ec44189d Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 19 May 2026 14:31:32 +0800 Subject: [PATCH 12/14] feat(app-expo): replay brand splash on logout and route to login After sign-out or data purge, clear session state reliably, remount the splash overlay above navigation, and navigate to login instead of tabs so users no longer briefly land on the chat home screen. Co-authored-by: Cursor --- app-expo/src/app/(auth)/_layout.tsx | 7 +++ app-expo/src/app/(main)/_layout.tsx | 12 ++--- app-expo/src/app/(tabs)/_layout.tsx | 13 +++-- app-expo/src/app/_layout.tsx | 48 +++++++++++++++--- app-expo/src/components/animated-icon.tsx | 29 ++++++++++- app-expo/src/components/animated-icon.web.tsx | 18 +++++++ app-expo/src/core/providers.tsx | 5 +- app-expo/src/core/splash-replay.ts | 50 +++++++++++++++++++ app-expo/src/features/auth/auth-query-keys.ts | 5 ++ .../clear-local-session-and-replay-entry.ts | 42 ++++++++++++++++ app-expo/src/features/auth/hooks.ts | 15 ++---- app-expo/src/features/profile/hooks.ts | 11 +--- .../src/hooks/use-splash-replay-active.ts | 14 ++++++ app-expo/tests/features/auth/hooks.test.tsx | 2 + 14 files changed, 225 insertions(+), 46 deletions(-) create mode 100644 app-expo/src/core/splash-replay.ts create mode 100644 app-expo/src/features/auth/auth-query-keys.ts create mode 100644 app-expo/src/features/auth/clear-local-session-and-replay-entry.ts create mode 100644 app-expo/src/hooks/use-splash-replay-active.ts diff --git a/app-expo/src/app/(auth)/_layout.tsx b/app-expo/src/app/(auth)/_layout.tsx index 9eb6260..383591d 100644 --- a/app-expo/src/app/(auth)/_layout.tsx +++ b/app-expo/src/app/(auth)/_layout.tsx @@ -1,11 +1,18 @@ import { Redirect, Stack } from 'expo-router'; import React from 'react'; +import { BrandBootstrapLoading } from '@/components/animated-icon'; +import { useSplashReplayActive } from '@/hooks/use-splash-replay-active'; import { useSession } from '@/features/auth/hooks'; export default function AuthLayout() { + const splashReplay = useSplashReplayActive(); const { status } = useSession(); + if (splashReplay || status === 'loading') { + return ; + } + if (status === 'authenticated') { return ; } diff --git a/app-expo/src/app/(main)/_layout.tsx b/app-expo/src/app/(main)/_layout.tsx index e02c618..9281aac 100644 --- a/app-expo/src/app/(main)/_layout.tsx +++ b/app-expo/src/app/(main)/_layout.tsx @@ -1,18 +1,16 @@ import { Redirect, Stack } from 'expo-router'; import React from 'react'; -import { ActivityIndicator, View } from 'react-native'; +import { BrandBootstrapLoading } from '@/components/animated-icon'; +import { useSplashReplayActive } from '@/hooks/use-splash-replay-active'; import { useSession } from '@/features/auth/hooks'; export default function MainLayout() { + const splashReplay = useSplashReplayActive(); const { status } = useSession(); - if (status === 'loading') { - return ( - - - - ); + if (splashReplay || status === 'loading') { + return ; } if (status === 'unauthenticated') { diff --git a/app-expo/src/app/(tabs)/_layout.tsx b/app-expo/src/app/(tabs)/_layout.tsx index a5c7d7e..6b9a0a0 100644 --- a/app-expo/src/app/(tabs)/_layout.tsx +++ b/app-expo/src/app/(tabs)/_layout.tsx @@ -1,6 +1,8 @@ import { Redirect, Tabs } from 'expo-router'; import React from 'react'; -import { ActivityIndicator, View } from 'react-native'; + +import { BrandBootstrapLoading } from '@/components/animated-icon'; +import { useSplashReplayActive } from '@/hooks/use-splash-replay-active'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { useTranslation } from 'react-i18next'; @@ -27,6 +29,7 @@ const TAB_BAR_HEIGHT = 72; const TAB_BAR_PADDING_HORIZONTAL = 12; export default function TabsLayout() { + const splashReplay = useSplashReplayActive(); const { status } = useSession(); const typography = useTypography(); const { colorScheme } = useColorScheme(); @@ -35,12 +38,8 @@ export default function TabsLayout() { const tabColors = TAB_BAR_COLORS[isDark ? 'dark' : 'light']; const { t } = useTranslation('app'); - if (status === 'loading') { - return ( - - - - ); + if (splashReplay || status === 'loading') { + return ; } if (status === 'unauthenticated') { diff --git a/app-expo/src/app/_layout.tsx b/app-expo/src/app/_layout.tsx index f1814d4..3436905 100644 --- a/app-expo/src/app/_layout.tsx +++ b/app-expo/src/app/_layout.tsx @@ -1,7 +1,8 @@ import { PortalHost } from '@rn-primitives/portal'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import React, { useEffect } from 'react'; +import React, { useEffect, useSyncExternalStore } from 'react'; +import { StyleSheet, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { KeyboardProvider } from 'react-native-keyboard-controller'; import { @@ -10,6 +11,10 @@ import { } from 'react-native-safe-area-context'; import '@/global.css'; import { AnimatedSplashOverlay } from '@/components/animated-icon'; +import { + getSplashGeneration, + subscribeSplashGeneration, +} from '@/core/splash-replay'; import { AppProviders } from '@/core/providers'; import { NavigationThemeProvider } from '@/core/navigation-theme-provider'; import { ThemeVariablesProvider } from '@/core/theme-variables-provider'; @@ -18,6 +23,31 @@ import { getAppLanguage, getDarkMode } from '@/core/settings/app-settings'; import { setLanguageResolver, startLocaleSync } from '@/i18n'; import { useColorScheme } from '@/hooks/use-color-scheme'; +/** Remount AnimatedSplashOverlay when {@link requestSplashReplay} runs (post-logout). */ +function ReplayableSplashOverlay() { + const generation = useSyncExternalStore( + subscribeSplashGeneration, + getSplashGeneration, + getSplashGeneration, + ); + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + splashHost: { + ...StyleSheet.absoluteFillObject, + zIndex: 9999, + elevation: 9999, + }, +}); + export default function RootLayout() { const { colorScheme, setColorScheme } = useColorScheme(); const resolved = colorScheme === 'dark' ? 'dark' : 'light'; @@ -53,13 +83,15 @@ export default function RootLayout() { - - - - - - - + + + + + + + + + diff --git a/app-expo/src/components/animated-icon.tsx b/app-expo/src/components/animated-icon.tsx index df1f04b..b757571 100644 --- a/app-expo/src/components/animated-icon.tsx +++ b/app-expo/src/components/animated-icon.tsx @@ -4,9 +4,14 @@ import { Dimensions, StyleSheet, View } from 'react-native'; import Animated, { Easing, Keyframe } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; +import { completeSplashReplay } from '@/core/splash-replay'; + const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; const DURATION = 600; +/** Brand gate background (matches native splash / overlay). */ +export const BRAND_BOOTSTRAP_BG = '#208AEF'; + export function AnimatedSplashOverlay() { const [visible, setVisible] = useState(true); @@ -37,10 +42,22 @@ export function AnimatedSplashOverlay() { 'worklet'; if (finished) { scheduleOnRN(setVisible, false); + scheduleOnRN(completeSplashReplay); } })} style={styles.backgroundSolidColor} - /> + > + + + ); +} + +/** Full-screen logo gate while session / token check is in progress (after splash). */ +export function BrandBootstrapLoading() { + return ( + + + ); } @@ -141,7 +158,15 @@ const styles = StyleSheet.create({ }, backgroundSolidColor: { ...StyleSheet.absoluteFillObject, - backgroundColor: '#208AEF', + backgroundColor: BRAND_BOOTSTRAP_BG, zIndex: 1000, + justifyContent: 'center', + alignItems: 'center', + }, + brandBootstrapRoot: { + flex: 1, + backgroundColor: BRAND_BOOTSTRAP_BG, + justifyContent: 'center', + alignItems: 'center', }, }); diff --git a/app-expo/src/components/animated-icon.web.tsx b/app-expo/src/components/animated-icon.web.tsx index adaabbb..ce443a5 100644 --- a/app-expo/src/components/animated-icon.web.tsx +++ b/app-expo/src/components/animated-icon.web.tsx @@ -5,10 +5,22 @@ import Animated, { Keyframe, Easing } from 'react-native-reanimated'; import classes from './animated-icon.module.css'; const DURATION = 300; +/** Brand gate background (matches native splash / overlay). */ +export const BRAND_BOOTSTRAP_BG = '#208AEF'; + export function AnimatedSplashOverlay() { return null; } +/** Full-screen logo gate while session / token check is in progress (after splash). */ +export function BrandBootstrapLoading() { + return ( + + + + ); +} + const keyframe = new Keyframe({ 0: { transform: [{ scale: 0 }], @@ -88,6 +100,12 @@ export function AnimatedIcon() { } const styles = StyleSheet.create({ + brandBootstrapRoot: { + flex: 1, + backgroundColor: BRAND_BOOTSTRAP_BG, + justifyContent: 'center', + alignItems: 'center', + }, container: { alignItems: 'center', width: '100%', diff --git a/app-expo/src/core/providers.tsx b/app-expo/src/core/providers.tsx index 8357e04..674c7e6 100644 --- a/app-expo/src/core/providers.tsx +++ b/app-expo/src/core/providers.tsx @@ -6,6 +6,7 @@ import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-co import { NetworkError } from '@/core/api/types'; import { tokenManager } from '@/core/auth/token-manager'; import { config } from '@/core/config'; +import { authKeys } from '@/features/auth/auth-query-keys'; import { AppQueryProvider, queryClient } from '@/core/query'; /** @@ -49,8 +50,8 @@ async function refreshTokens(): Promise { * since the cache flip is the authoritative signal. */ function onAuthFailure() { - queryClient.setQueryData(['auth', 'token-check'], false); - queryClient.setQueryData(['session'], null); + queryClient.setQueryData(authKeys.tokenCheck, false); + queryClient.setQueryData(authKeys.session, null); tokenManager.clearTokens(); } diff --git a/app-expo/src/core/splash-replay.ts b/app-expo/src/core/splash-replay.ts new file mode 100644 index 0000000..1befbc5 --- /dev/null +++ b/app-expo/src/core/splash-replay.ts @@ -0,0 +1,50 @@ +/** + * Signals the root AnimatedSplashOverlay to remount so the cold-start splash + * animation runs again — used after explicit logout-like flows (not passive /me failures). + */ + +let splashGeneration = 0; +let splashReplayActive = false; + +const generationListeners = new Set<() => void>(); +const activeListeners = new Set<() => void>(); + +function notifyGeneration() { + generationListeners.forEach((listener) => listener()); +} + +function notifyActive() { + activeListeners.forEach((listener) => listener()); +} + +export function requestSplashReplay() { + splashGeneration += 1; + splashReplayActive = true; + notifyGeneration(); + notifyActive(); +} + +/** Called when the replay overlay animation finishes (or unmounts). */ +export function completeSplashReplay() { + if (!splashReplayActive) return; + splashReplayActive = false; + notifyActive(); +} + +export function getSplashGeneration(): number { + return splashGeneration; +} + +export function getSplashReplayActive(): boolean { + return splashReplayActive; +} + +export function subscribeSplashGeneration(onStoreChange: () => void): () => void { + generationListeners.add(onStoreChange); + return () => generationListeners.delete(onStoreChange); +} + +export function subscribeSplashReplayActive(onStoreChange: () => void): () => void { + activeListeners.add(onStoreChange); + return () => activeListeners.delete(onStoreChange); +} diff --git a/app-expo/src/features/auth/auth-query-keys.ts b/app-expo/src/features/auth/auth-query-keys.ts new file mode 100644 index 0000000..628712a --- /dev/null +++ b/app-expo/src/features/auth/auth-query-keys.ts @@ -0,0 +1,5 @@ +/** Shared TanStack keys for bootstrap session — avoids circular imports with hooks. */ +export const authKeys = { + session: ['session'] as const, + tokenCheck: ['auth', 'token-check'] as const, +} as const; diff --git a/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts b/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts new file mode 100644 index 0000000..1315e23 --- /dev/null +++ b/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts @@ -0,0 +1,42 @@ +import { type QueryClient } from '@tanstack/react-query'; +import { router } from 'expo-router'; + +import { tokenManager } from '@/core/auth/token-manager'; +import { requestSplashReplay } from '@/core/splash-replay'; +import { authKeys } from '@/features/auth/auth-query-keys'; +import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool'; + +/** Matches `useSession` token bootstrap — keep options aligned for cache coherence. */ +const TOKEN_CHECK_FETCH_OPTIONS = { + queryKey: authKeys.tokenCheck, + queryFn: () => tokenManager.hasTokens(), + staleTime: Infinity, + gcTime: Infinity, +} as const; + +/** + * Used after deliberate sign-out flows (manual logout, data purge…): + * wipes local credentials + TanStack cache, replays splash on top, then routes to login. + */ +export async function clearLocalSessionAndReplayEntry( + queryClient: QueryClient, +): Promise { + requestSplashReplay(); + + disposeAllBackgroundConversationWs(); + await tokenManager.clearTokens(); + + await queryClient.cancelQueries({ queryKey: authKeys.session }); + await queryClient.cancelQueries({ queryKey: authKeys.tokenCheck }); + + queryClient.removeQueries({ queryKey: authKeys.session }); + queryClient.clear(); + + queryClient.setQueryData(authKeys.tokenCheck, false); + + await queryClient.fetchQuery({ + ...TOKEN_CHECK_FETCH_OPTIONS, + }); + + router.replace('/(auth)/login'); +} diff --git a/app-expo/src/features/auth/hooks.ts b/app-expo/src/features/auth/hooks.ts index ca30ab9..b6cfefc 100644 --- a/app-expo/src/features/auth/hooks.ts +++ b/app-expo/src/features/auth/hooks.ts @@ -1,13 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { router } from 'expo-router'; import { useCallback } from 'react'; import { AuthError } from '@/core/api/types'; import { tokenManager } from '@/core/auth/token-manager'; -import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool'; +import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry'; import { getDeviceLanguage } from '@/i18n'; import { authApi } from './api'; +import { authKeys } from './auth-query-keys'; import type { LanguagePreference, LoginRequest, @@ -34,10 +34,7 @@ function withDeviceLanguage( // ─── Query keys ─── -export const authKeys = { - session: ['session'] as const, - tokenCheck: ['auth', 'token-check'] as const, -}; +export { authKeys }; const PROFILE_QUERY_PREFIX = ['profile'] as const; @@ -251,11 +248,7 @@ export function useLogout() { } }, onSettled: async () => { - disposeAllBackgroundConversationWs(); - await tokenManager.clearTokens(); - queryClient.clear(); - queryClient.setQueryData(authKeys.tokenCheck, false); - router.replace('/(auth)/login'); + await clearLocalSessionAndReplayEntry(queryClient); }, }); } diff --git a/app-expo/src/features/profile/hooks.ts b/app-expo/src/features/profile/hooks.ts index 0954b75..b7b478d 100644 --- a/app-expo/src/features/profile/hooks.ts +++ b/app-expo/src/features/profile/hooks.ts @@ -1,9 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { router } from 'expo-router'; -import { tokenManager } from '@/core/auth/token-manager'; -import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool'; -import { authKeys } from '@/features/auth/hooks'; +import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry'; import { profileApi } from './api'; import type { @@ -98,11 +95,7 @@ export function usePurgeUserData() { return useMutation({ mutationFn: (body: PurgeUserDataRequest) => profileApi.purgeUserData(body), onSuccess: async () => { - disposeAllBackgroundConversationWs(); - await tokenManager.clearTokens(); - queryClient.clear(); - queryClient.setQueryData(authKeys.tokenCheck, false); - router.replace('/(auth)/login'); + await clearLocalSessionAndReplayEntry(queryClient); }, }); } diff --git a/app-expo/src/hooks/use-splash-replay-active.ts b/app-expo/src/hooks/use-splash-replay-active.ts new file mode 100644 index 0000000..5ae35f0 --- /dev/null +++ b/app-expo/src/hooks/use-splash-replay-active.ts @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from 'react'; + +import { + getSplashReplayActive, + subscribeSplashReplayActive, +} from '@/core/splash-replay'; + +export function useSplashReplayActive(): boolean { + return useSyncExternalStore( + subscribeSplashReplayActive, + getSplashReplayActive, + () => false, + ); +} diff --git a/app-expo/tests/features/auth/hooks.test.tsx b/app-expo/tests/features/auth/hooks.test.tsx index 013c51d..031c267 100644 --- a/app-expo/tests/features/auth/hooks.test.tsx +++ b/app-expo/tests/features/auth/hooks.test.tsx @@ -226,6 +226,7 @@ describe('useLogout', () => { test('reads refresh token first, calls API, then clears local state', async () => { mockGetRefreshToken.mockResolvedValue('my-refresh'); mockLogout.mockResolvedValue({ message: 'ok' }); + mockHasTokens.mockResolvedValue(false); const wrapper = createWrapper(); @@ -251,6 +252,7 @@ describe('useLogout', () => { test('clears local state even if server logout fails', async () => { mockGetRefreshToken.mockResolvedValue('my-refresh'); mockLogout.mockRejectedValue(new Error('Network down')); + mockHasTokens.mockResolvedValue(false); const wrapper = createWrapper(); From 3921c5ec2459eba8e873b328fb3e221e8f0abb42 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 19 May 2026 14:55:10 +0800 Subject: [PATCH 13/14] fix(app-expo): allow read-aloud on other split segments while TTS paused Match playback refs to the correct assistant segment so the interrupt overlay does not block other bubbles, and preempt local playback when switching segments. Co-authored-by: Cursor --- app-expo/src/app/(main)/conversation/[id].tsx | 63 +++++++++++-------- .../features/conversation/message-split.ts | 50 +++++++++++++++ .../src/features/voice/hooks/use-player.ts | 20 +----- .../conversation/message-split.test.ts | 24 +++++++ 4 files changed, 114 insertions(+), 43 deletions(-) diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index e66984f..d1417e3 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -62,6 +62,7 @@ import { useSession } from '@/features/auth/hooks'; import { useProfile } from '@/features/profile/hooks'; import { assistantSegmentMessageId, + playbackListKeyMatchesBubble, splitMessageParts, splitStreamingSegments, } from '@/features/conversation/message-split'; @@ -171,16 +172,6 @@ function segmentTtsUrlAt( /** 流式助手区与自动 TTS 的 `PlaybackItem.messageRef.listKey` 对齐,用于点区域停止朗读 */ const TTS_STREAMING_LIST_KEY = '__tts_streaming__'; -/** PlaybackItem.messageRef.listKey 可与 `item.id` 或 `${id}_seg_/part_` 后缀对齐 */ -function playbackMessageRefMatchesMessage( - playbackListKey: string | undefined, - messageItemId: string, -): boolean { - if (!playbackListKey?.length) return false; - if (playbackListKey === messageItemId) return true; - return playbackListKey.startsWith(`${messageItemId}_`); -} - /** 展平消息列表:assistant 消息按 [SPLIT] 边界拆成多条,每条一个 listKey */ function flattenMessagesForList( messages: MessageItem[], @@ -230,7 +221,9 @@ function MessageBubble({ onPauseAssistantTts, onResumeAssistantTts, onInterruptAssistantTts, + onPreemptAssistantPlayback, onReplayAssistantTts, + assistantPlaybackEngaged, bubbleTextStyle, voiceDurationTextStyle, readAloudIconSize, @@ -254,7 +247,11 @@ function MessageBubble({ onPauseAssistantTts: () => void; onResumeAssistantTts: () => void; onInterruptAssistantTts: () => void; + /** 切换到另一条助手分段朗读前,仅停止本地播放(不发 tts_cancel) */ + onPreemptAssistantPlayback: () => void; onReplayAssistantTts: (messageId: string, urls: string[]) => void; + /** 当前有助手 TTS 在播或已暂停(不含用户语音气泡) */ + assistantPlaybackEngaged: boolean; bubbleTextStyle?: TextStyle; voiceDurationTextStyle?: TextStyle; readAloudIconSize: number; @@ -284,8 +281,7 @@ function MessageBubble({ !isUser && !isVoice && playbackKind !== 'voice' && - (playbackRefListKey === listKey || - playbackMessageRefMatchesMessage(playbackRefListKey, item.id)); + playbackListKeyMatchesBubble(playbackRefListKey, listKey, item.id); const playbackEngaged = playbackIsPlaying || playbackIsPaused; const isThisBubbleActiveTts = matchesThisMessageForTts && playbackEngaged; @@ -335,19 +331,26 @@ function MessageBubble({ onPauseAssistantTts(); } else if (isThisBubbleTtsPaused) { onResumeAssistantTts(); - } else if (ttsUrlThisPart) { - onReplayAssistantTts(listKey, [ttsUrlThisPart]); - } else if (durableAssistantId) { - const ok = requestAssistantSegmentTts({ - assistantMessageId: durableAssistantId, - segmentIndex: assistantSegmentIndex, - segmentText: item.content, - }); - if (!ok) { - Alert.alert('', t('readAloudRequestFailed')); - } } else { - Alert.alert('', t('readAloudNoMessageId')); + const switchingSegment = + assistantPlaybackEngaged && !isThisBubbleActiveTts; + if (switchingSegment) { + onPreemptAssistantPlayback(); + } + if (ttsUrlThisPart) { + onReplayAssistantTts(listKey, [ttsUrlThisPart]); + } else if (durableAssistantId) { + const ok = requestAssistantSegmentTts({ + assistantMessageId: durableAssistantId, + segmentIndex: assistantSegmentIndex, + segmentText: item.content, + }); + if (!ok) { + Alert.alert('', t('readAloudRequestFailed')); + } + } else { + Alert.alert('', t('readAloudNoMessageId')); + } } }} style={({ pressed }) => [ @@ -432,7 +435,7 @@ function MessageBubble({ ) : ( {assistantTextBubbleBody} - {isThisBubbleActiveTts ? ( + {isThisBubbleTtsPlaying ? ( [ @@ -1470,6 +1473,14 @@ export default function ConversationScreen() { pausePlayback(); }, [pausePlayback]); + const handlePreemptAssistantPlayback = useCallback(() => { + void stop(); + }, [stop]); + + const assistantPlaybackEngaged = + (playerStatus === 'playing' || playerStatus === 'paused') && + currentPlaybackItem?.kind !== 'voice'; + const handleResumeAssistantPlayback = useCallback(() => { void resumePlayback(); }, [resumePlayback]); @@ -1918,7 +1929,9 @@ export default function ConversationScreen() { onResumeAssistantTts={handleResumeAssistantPlayback} onPlayVoiceExclusive={handlePlayVoiceExclusive} onInterruptAssistantTts={handleInterruptAssistantTts} + onPreemptAssistantPlayback={handlePreemptAssistantPlayback} onReplayAssistantTts={handleReplayAssistantTts} + assistantPlaybackEngaged={assistantPlaybackEngaged} bubbleTextStyle={chatBubbleTextStyle} voiceDurationTextStyle={chatVoiceDurationStyle} readAloudIconSize={chatReadAloudIconSize} diff --git a/app-expo/src/features/conversation/message-split.ts b/app-expo/src/features/conversation/message-split.ts index 45b1348..0a4c432 100644 --- a/app-expo/src/features/conversation/message-split.ts +++ b/app-expo/src/features/conversation/message-split.ts @@ -50,6 +50,56 @@ export function assistantSegmentMessageId( return `${assistantMessageId}_seg_${segmentIndex}`; } +const ASSISTANT_SPLIT_LIST_KEY_RE = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_(?:seg|part)_(\d+)$/i; + +/** + * 解析展平气泡 `uuid_part_n` 或播放队列 `uuid_seg_n` 的落库 id 与段下标。 + */ +export function parseAssistantSplitListKey(listKey: string | undefined): { + messageId: string; + segmentIndex: number; +} | null { + if (!listKey?.length) return null; + const m = ASSISTANT_SPLIT_LIST_KEY_RE.exec(listKey); + if (m) { + return { messageId: m[1]!, segmentIndex: Number(m[2]) }; + } + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + listKey, + ) + ) { + return { messageId: listKey, segmentIndex: 0 }; + } + return null; +} + +/** 播放中的 `messageRef.listKey` 是否与当前气泡(含 `_part_` / `_seg_`)为同一段 */ +export function playbackListKeyMatchesBubble( + playbackListKey: string | undefined, + bubbleListKey: string, + messageItemId: string, +): boolean { + if (!playbackListKey?.length) return false; + if (playbackListKey === bubbleListKey) return true; + + const playback = parseAssistantSplitListKey(playbackListKey); + const bubble = + parseAssistantSplitListKey(bubbleListKey) ?? + parseAssistantSplitListKey(messageItemId); + if (playback && bubble) { + return ( + playback.messageId === bubble.messageId && + playback.segmentIndex === bubble.segmentIndex + ); + } + if (playbackListKey === messageItemId) { + return !playback || playback.segmentIndex === 0; + } + return false; +} + /** 历史/已落库消息:拆成非空片段,各渲染为一个气泡 */ export function splitMessageParts(content: string): string[] { return splitToPartsNormalized(String(content ?? '')); diff --git a/app-expo/src/features/voice/hooks/use-player.ts b/app-expo/src/features/voice/hooks/use-player.ts index edee04b..d260304 100644 --- a/app-expo/src/features/voice/hooks/use-player.ts +++ b/app-expo/src/features/voice/hooks/use-player.ts @@ -3,25 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { audioFocus } from '@/core/audio/audio-focus'; -import type { PlaybackItem, PlayerStatus } from '../types'; +import { parseAssistantSplitListKey } from '@/features/conversation/message-split'; -/** - * `handleTtsSegment` 使用 `assistantSegmentMessageId` → `{uuid}_seg_{n}`; - * 展平气泡使用 `{uuid}_part_{n}`。同一条落库助手消息上的连续分段应用入队续播, - * 而不是「暂停后又到一条 tts_auto 就整轨切换成最新」——否则多段朗读只会听到最后一段。 - */ -function parseAssistantSplitListKey(listKey: string | undefined): { - messageId: string; - segmentIndex: number; -} | null { - if (!listKey) return null; - const m = - /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_(?:seg|part)_(\d+)$/i.exec( - listKey, - ); - if (!m) return null; - return { messageId: m[1]!, segmentIndex: Number(m[2]) }; -} +import type { PlaybackItem, PlayerStatus } from '../types'; function isLaterSegmentOfSameAssistantBubble( current: PlaybackItem | null | undefined, diff --git a/app-expo/tests/features/conversation/message-split.test.ts b/app-expo/tests/features/conversation/message-split.test.ts index 151595a..9bada8e 100644 --- a/app-expo/tests/features/conversation/message-split.test.ts +++ b/app-expo/tests/features/conversation/message-split.test.ts @@ -2,6 +2,8 @@ import { assistantSegmentMessageId, lastSegmentPreview, normalizeAssistantContentForSplit, + parseAssistantSplitListKey, + playbackListKeyMatchesBubble, splitMessageParts, splitStreamingSegments, } from '@/features/conversation/message-split'; @@ -75,6 +77,28 @@ describe('message-split', () => { expect(assistantSegmentMessageId('uuid-a', 1)).toBe('uuid-a_seg_1'); }); + it('playbackListKeyMatchesBubble aligns seg playback with part listKey', () => { + const uuid = '78b32c06-d2f9-453b-9cc4-354e68fbcb2d'; + expect( + playbackListKeyMatchesBubble( + `${uuid}_seg_1`, + `${uuid}_part_1`, + uuid, + ), + ).toBe(true); + expect( + playbackListKeyMatchesBubble( + `${uuid}_seg_0`, + `${uuid}_part_1`, + uuid, + ), + ).toBe(false); + expect(parseAssistantSplitListKey(`${uuid}_part_0`)).toEqual({ + messageId: uuid, + segmentIndex: 0, + }); + }); + it('normalizeAssistantContentForSplit maps fullwidth brackets', () => { expect(normalizeAssistantContentForSplit('[x]')).toBe('[x]'); expect(normalizeAssistantContentForSplit('【x】')).toBe('[x]'); From 6d281c92a59778fbec043476b16dc90b3e4baf28 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 19 May 2026 15:43:16 +0800 Subject: [PATCH 14/14] feat(app-expo): env variants, local iOS prebuild, and About diagnostics Align staging/production builds with APP_VARIANT bundle IDs, allow staging HTTP on iOS, add ios-prebuild scripts for TestFlight, and show connected API URL on About for non-production builds. Co-authored-by: Cursor --- app-expo/.env.example | 26 ++++++++- app-expo/.env.production | 5 +- app-expo/.env.staging | 7 ++- app-expo/app.config.ts | 32 +++++++++-- app-expo/package.json | 3 + app-expo/plugins/withIosInsecureHttp.js | 63 +++++++++++++++++++++ app-expo/scripts/ios-prebuild.sh | 67 +++++++++++++++++++++++ app-expo/scripts/use-env.js | 2 +- app-expo/src/app/(main)/about.tsx | 29 ++++++++-- app-expo/src/core/config.ts | 22 ++++++++ app-expo/src/i18n/locales/en/profile.json | 7 ++- app-expo/src/i18n/locales/zh/profile.json | 7 ++- app-expo/tests/core/config.test.ts | 23 ++++++++ 13 files changed, 275 insertions(+), 18 deletions(-) create mode 100644 app-expo/plugins/withIosInsecureHttp.js create mode 100755 app-expo/scripts/ios-prebuild.sh create mode 100644 app-expo/tests/core/config.test.ts diff --git a/app-expo/.env.example b/app-expo/.env.example index 8774536..cf03025 100644 --- a/app-expo/.env.example +++ b/app-expo/.env.example @@ -1,12 +1,34 @@ # 复制为 .env.development / .env.staging / .env.production 后填写(勿提交含密钥的副本)。 +# 仓库已提交三份模板:.env.development、.env.staging、.env.production。 # 本地:npm start 会通过 prestart 执行 `use-env development` 生成 .env; # 或手动 `npm run use-env -- staging` / `npm run use-env -- production`。 # CI:GitHub Actions 在构建 APK 前会按分支调用 use-env(main → staging,tag → production)。 +# iOS 本机 TestFlight:npm run ios:prebuild:staging|production(见 scripts/ios-prebuild.sh)。 +# +# APP_VARIANT / EXPO_PUBLIC_APP_VARIANT:控制 Bundle ID 与「关于」页是否显示后端地址 +# development / staging → 显示版本 + API 地址;production → 仅版本号 # # 变量在构建时注入;修改后需重新 prebuild/打包客户端。 # # 助手朗读:无独立 EXPO_PUBLIC_* TTS 开关。会话页顶栏在每轮 WebSocket 中带 `tts_this_turn`; # 服务端是否具备合成能力见 api/.env 中 ENABLE_TTS 等(模板见 api/.env.example)。 -EXPO_PUBLIC_API_URL=https://your-api.example.com -EXPO_PUBLIC_WS_URL=wss://your-api.example.com +# --- development(本地)--- +# APP_VARIANT=development +# EXPO_PUBLIC_APP_VARIANT=development +# EXPO_PUBLIC_API_URL=http://127.0.0.1:8000 +# EXPO_PUBLIC_WS_URL=ws://127.0.0.1:8000 + +# --- staging(预发 / TestFlight staging 包)--- +# APP_VARIANT=staging +# EXPO_PUBLIC_APP_VARIANT=staging +# iOS Bundle ID: org.brighteng.lifecho.staging +# EXPO_PUBLIC_API_URL=http://your-staging-host:8000 +# EXPO_PUBLIC_WS_URL=ws://your-staging-host:8000 + +# --- production(正式)--- +# APP_VARIANT=production +# EXPO_PUBLIC_APP_VARIANT=production +# iOS Bundle ID: org.brighteng.lifecho +# EXPO_PUBLIC_API_URL=https://your-api.example.com +# EXPO_PUBLIC_WS_URL=wss://your-api.example.com diff --git a/app-expo/.env.production b/app-expo/.env.production index d8b30e0..886120c 100644 --- a/app-expo/.env.production +++ b/app-expo/.env.production @@ -1,3 +1,6 @@ -# 仅 API/WS 基址;TTS 每轮开关由运行时 WS payload 与服务端 ENABLE_TTS 控制(见 api/.env.example)。 +# 正式:关于页仅显示版本号;iOS Bundle ID org.brighteng.lifecho +# TTS 每轮开关由运行时 WS payload 与服务端 ENABLE_TTS 控制(见 api/.env.example)。 +APP_VARIANT=production +EXPO_PUBLIC_APP_VARIANT=production EXPO_PUBLIC_API_URL=https://lifecho.worldsplats.com EXPO_PUBLIC_WS_URL=wss://lifecho.worldsplats.com diff --git a/app-expo/.env.staging b/app-expo/.env.staging index 7382083..83b33fa 100644 --- a/app-expo/.env.staging +++ b/app-expo/.env.staging @@ -1,2 +1,5 @@ -EXPO_PUBLIC_API_URL=http://1.15.29.57:8000/ -EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000/ +# 预发:关于页显示版本 + 后端地址;iOS Bundle ID org.brighteng.lifecho.staging +APP_VARIANT=staging +EXPO_PUBLIC_APP_VARIANT=staging +EXPO_PUBLIC_API_URL=http://1.15.29.57:8000 +EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000 diff --git a/app-expo/app.config.ts b/app-expo/app.config.ts index 97f74a9..3931de8 100644 --- a/app-expo/app.config.ts +++ b/app-expo/app.config.ts @@ -29,7 +29,28 @@ const LOCALES: Record = { const SUPPORTED_LOCALES = ['zh', 'en'] as const; const PRIMARY_LOCALE = process.env.EXPO_PUBLIC_PRIMARY_LOCALE ?? 'zh'; const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? ''; -const ALLOW_ANDROID_CLEARTEXT_TRAFFIC = API_BASE_URL.startsWith('http://'); +const ALLOW_INSECURE_HTTP = API_BASE_URL.startsWith('http://'); + +const APP_VARIANT = + process.env.APP_VARIANT ?? + process.env.EXPO_PUBLIC_APP_VARIANT ?? + 'development'; +const IS_STAGING = APP_VARIANT === 'staging'; +const IS_PRODUCTION = APP_VARIANT === 'production'; + +const IOS_BUNDLE_IDENTIFIER = IS_STAGING + ? 'org.brighteng.lifecho.staging' + : IS_PRODUCTION + ? 'org.brighteng.lifecho' + : 'com.anonymous.app-expo'; + +const ANDROID_PACKAGE = IS_STAGING + ? 'org.brighteng.lifecho.staging' + : IS_PRODUCTION + ? 'org.brighteng.lifecho' + : 'com.anonymous.appexpo'; + +const APP_DISPLAY_NAME = IS_STAGING ? 'Life Echo (Staging)' : 'Life Echo'; const PERMISSION_FALLBACKS: Record = { microphone: 'Allow $(PRODUCT_NAME) to access your microphone.', @@ -106,7 +127,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { return { ...config, - name: 'Life Echo', + name: APP_DISPLAY_NAME, slug: 'life-echo', version: '1.2.0', orientation: 'portrait', @@ -116,7 +137,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { ios: { ...config?.ios, icon: './assets/images/icon.png', - bundleIdentifier: 'com.anonymous.app-expo', + bundleIdentifier: IOS_BUNDLE_IDENTIFIER, config: { usesNonExemptEncryption: false, }, @@ -140,7 +161,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { */ softwareKeyboardLayoutMode: 'resize', // Reverse-DNS; no hyphens (Android package name rules). Matches iOS bundle id intent. - package: 'com.anonymous.appexpo', + package: ANDROID_PACKAGE, adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/android-icon-foreground.png', @@ -153,8 +174,9 @@ export default ({ config }: ConfigContext): ExpoConfig => { './plugins/withAndroidReleaseSigning', [ './plugins/withAndroidCleartextTraffic', - { enabled: ALLOW_ANDROID_CLEARTEXT_TRAFFIC }, + { enabled: ALLOW_INSECURE_HTTP }, ], + ['./plugins/withIosInsecureHttp', { enabled: ALLOW_INSECURE_HTTP }], 'expo-router', [ 'expo-splash-screen', diff --git a/app-expo/package.json b/app-expo/package.json index efa95db..bb68eb7 100644 --- a/app-expo/package.json +++ b/app-expo/package.json @@ -11,6 +11,9 @@ "reset-project": "node ./scripts/reset-project.js", "android": "npm run use-env -- development && expo run:android", "ios": "npm run use-env -- development && expo run:ios", + "ios:prebuild": "bash scripts/ios-prebuild.sh staging", + "ios:prebuild:staging": "bash scripts/ios-prebuild.sh staging", + "ios:prebuild:production": "bash scripts/ios-prebuild.sh production", "web": "npm run use-env -- development && expo start --web", "lint": "expo lint", "test": "jest --watch", diff --git a/app-expo/plugins/withIosInsecureHttp.js b/app-expo/plugins/withIosInsecureHttp.js new file mode 100644 index 0000000..9e9810c --- /dev/null +++ b/app-expo/plugins/withIosInsecureHttp.js @@ -0,0 +1,63 @@ +// @ts-check +/** + * Allow HTTP / WS to staging API host via App Transport Security exception. + * + * Enabled when EXPO_PUBLIC_API_URL uses http:// (same rule as Android cleartext). + * Host is parsed from the URL so IP:port staging endpoints work without hard-coding. + */ +const { withInfoPlist } = require('@expo/config-plugins'); + +/** + * @returns {string | null} + */ +function getHttpExceptionHost() { + const raw = process.env.EXPO_PUBLIC_API_URL ?? ''; + if (!raw.startsWith('http://')) { + return null; + } + try { + return new URL(raw).hostname; + } catch { + return null; + } +} + +/** + * @param {import('expo/config').ExpoConfig} config + * @param {{ enabled?: boolean }} props + */ +function withIosInsecureHttp(config, props = {}) { + const enabled = props.enabled ?? false; + + return withInfoPlist(config, (mod) => { + if (!enabled) { + return mod; + } + + const host = getHttpExceptionHost(); + if (!host) { + console.warn( + '[withIosInsecureHttp] enabled but EXPO_PUBLIC_API_URL has no http host; skipping ATS exception.', + ); + return mod; + } + + const existing = mod.modResults.NSAppTransportSecurity ?? {}; + const existingDomains = existing.NSExceptionDomains ?? {}; + + mod.modResults.NSAppTransportSecurity = { + ...existing, + NSExceptionDomains: { + ...existingDomains, + [host]: { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + }, + }; + + return mod; + }); +} + +module.exports = withIosInsecureHttp; diff --git a/app-expo/scripts/ios-prebuild.sh b/app-expo/scripts/ios-prebuild.sh new file mode 100755 index 0000000..415c362 --- /dev/null +++ b/app-expo/scripts/ios-prebuild.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# 本机 iOS Release:切换 env → expo prebuild → 打开 Xcode 打 Archive 上传 TestFlight。 +# +# 用法(在仓库任意目录): +# app-expo/scripts/ios-prebuild.sh staging +# app-expo/scripts/ios-prebuild.sh production +# +# 或通过 npm(在 app-expo 目录): +# npm run ios:prebuild:staging +# npm run ios:prebuild:production +# +# Xcode 内后续步骤(脚本不会自动执行): +# 1. 选 Any iOS Device(或 Generic iOS Device) +# 2. Product → Archive +# 3. Distribute App → App Store Connect → Upload(进入 TestFlight) + +set -euo pipefail + +ENV="${1:-}" +if [[ -z "$ENV" ]]; then + echo "Usage: $(basename "$0") " >&2 + exit 1 +fi + +case "$ENV" in + staging | production | development) ;; + *) + echo "Unknown environment: $ENV (expected staging, production, or development)" >&2 + exit 1 + ;; +esac + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "==> Switching to .env.${ENV}" +npm run use-env -- "$ENV" + +echo "==> expo prebuild --platform ios --clean" +npx expo prebuild --platform ios --clean + +shopt -s nullglob +WORKSPACES=(ios/*.xcworkspace) +shopt -u nullglob + +if [[ ${#WORKSPACES[@]} -eq 0 ]]; then + echo "No ios/*.xcworkspace found after prebuild." >&2 + exit 1 +fi + +if [[ ${#WORKSPACES[@]} -gt 1 ]]; then + echo "Multiple workspaces found; opening the first: ${WORKSPACES[0]}" >&2 +fi + +echo "==> Opening ${WORKSPACES[0]}" +open "${WORKSPACES[0]}" + +cat < 复制为 .env,供 Metro/Expo 读取 EXPO_PUBLIC_*。 * * 参数 name → 源文件: - * development → .env.development(本地默认:npm start / prestart) + * development → .env.development(仓库已提交;npm start / prestart 默认) * staging → .env.staging * production → .env.production * diff --git a/app-expo/src/app/(main)/about.tsx b/app-expo/src/app/(main)/about.tsx index 312be8c..37db148 100644 --- a/app-expo/src/app/(main)/about.tsx +++ b/app-expo/src/app/(main)/about.tsx @@ -1,25 +1,44 @@ import Constants from 'expo-constants'; import React from 'react'; import { View } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; +import { config } from '@/core/config'; export default function AboutScreen() { + const { t } = useTranslation('profile'); const version = Constants.expoConfig?.version ?? '1.0.0'; + const showBackend = config.showAboutBackendUrl; return ( - + - 岁月时书 + {t('about.appName')} - Life Echo - 版本 {version} + {t('about.appSubtitle')} + + {t('about.version', { version })} + + {showBackend ? ( + + + {t('about.backend')} + + + {config.apiBaseUrl} + + + ) : null} - 记录你的人生故事,让回忆成书。 + {t('about.tagline')} diff --git a/app-expo/src/core/config.ts b/app-expo/src/core/config.ts index 36d3525..67c98ef 100644 --- a/app-expo/src/core/config.ts +++ b/app-expo/src/core/config.ts @@ -2,6 +2,26 @@ function trimTrailingSlashes(value: string): string { return value.replace(/\/+$/, ''); } +export type AppVariant = 'development' | 'staging' | 'production'; + +function resolveAppVariant(): AppVariant { + const raw = process.env.EXPO_PUBLIC_APP_VARIANT; + if (raw === 'development' || raw === 'staging' || raw === 'production') { + return raw; + } + if (__DEV__) { + return 'development'; + } + return 'production'; +} + +/** Shown on About screen for dev/staging builds only. */ +export function shouldShowAboutBackendUrl(variant: AppVariant = appVariant): boolean { + return variant === 'development' || variant === 'staging'; +} + +export const appVariant = resolveAppVariant(); + export const config = { apiBaseUrl: trimTrailingSlashes( process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.10.151:8000', @@ -10,6 +30,8 @@ export const config = { process.env.EXPO_PUBLIC_WS_URL ?? 'ws://192.168.10.151:8000', ), isDebugMode: __DEV__, + appVariant, + showAboutBackendUrl: shouldShowAboutBackendUrl(), api: { timeoutMs: 30_000, diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index 99072ce..d042542 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -1,7 +1,12 @@ { "about": { "aboutUs": "About Us", - "title": "About" + "appName": "Life Echo", + "appSubtitle": "岁月时书", + "backend": "API endpoint", + "tagline": "Capture your life story and turn memories into a book.", + "title": "About", + "version": "Version {{version}}" }, "appExperience": { "language": "Language", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index 22007b7..e9e05fd 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -1,7 +1,12 @@ { "about": { "aboutUs": "关于我们", - "title": "关于" + "appName": "岁月时书", + "appSubtitle": "Life Echo", + "backend": "连接的后端", + "tagline": "记录你的人生故事,让回忆成书。", + "title": "关于", + "version": "版本 {{version}}" }, "appExperience": { "language": "语言", diff --git a/app-expo/tests/core/config.test.ts b/app-expo/tests/core/config.test.ts new file mode 100644 index 0000000..22cfa71 --- /dev/null +++ b/app-expo/tests/core/config.test.ts @@ -0,0 +1,23 @@ +import { + appVariant, + config, + shouldShowAboutBackendUrl, + type AppVariant, +} from '@/core/config'; + +describe('shouldShowAboutBackendUrl', () => { + it('shows backend URL for development and staging', () => { + expect(shouldShowAboutBackendUrl('development')).toBe(true); + expect(shouldShowAboutBackendUrl('staging')).toBe(true); + }); + + it('hides backend URL for production', () => { + expect(shouldShowAboutBackendUrl('production')).toBe(false); + }); + + it('matches config.showAboutBackendUrl for current build', () => { + expect(config.showAboutBackendUrl).toBe( + shouldShowAboutBackendUrl(appVariant as AppVariant), + ); + }); +});