diff --git a/api/agents/prompts/memory_prompts.py b/api/agents/prompts/memory_prompts.py index d80c0c9..620ad1b 100644 --- a/api/agents/prompts/memory_prompts.py +++ b/api/agents/prompts/memory_prompts.py @@ -43,7 +43,6 @@ STAGE_TO_ORDER = { "summary": 7, } - def get_system_prompt() -> str: """获取整理 Agent 的系统提示词""" return """你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。 @@ -323,4 +322,3 @@ def get_narrative_prompt( 只输出新对话内容的改写结果(包含图片占位符)。如果对话中没有值得记录的人生经历内容,输出空字符串。 """ - diff --git a/api/routers/chapters.py b/api/routers/chapters.py index 42355a0..f249e26 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -1,6 +1,8 @@ """ 章节相关 API 路由 """ +import logging +import os from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -12,11 +14,48 @@ from database.models import Chapter as ChapterModel from database.models import User as UserModel from middleware.auth import get_current_user from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER +from services.memoir_images.storage import ( + TencentCosStorageService, + normalize_cos_url, + resolve_image_storage_key, +) router = APIRouter(prefix="/api/chapters", tags=["chapters"]) +logger = logging.getLogger(__name__) + + +def _normalize_image_assets(images: list[dict] | None) -> list[dict]: + bucket = os.getenv("TENCENT_COS_BUCKET", "") + region = os.getenv("TENCENT_COS_REGION", "") + base_url = os.getenv("TENCENT_COS_BASE_URL", "") + storage = TencentCosStorageService.from_env() + normalized_assets: list[dict] = [] + + for item in (images or []): + asset = dict(item) + normalized_url = normalize_cos_url( + asset.get("url"), + bucket=bucket, + region=region, + base_url=base_url, + ) + storage_key = resolve_image_storage_key(asset) + if asset.get("status") == "completed" and storage_key: + try: + asset["url"] = storage.get_download_url(storage_key) + except Exception as exc: + logger.warning("章节图片签名失败: key=%s, error=%s", storage_key, exc) + asset["url"] = normalized_url + else: + asset["url"] = normalized_url + asset.pop("storage_key", None) + normalized_assets.append(asset) + + return normalized_assets def _chapter_to_dict(ch: ChapterModel) -> dict: + normalized_images = _normalize_image_assets(ch.images) return { "id": ch.id, "title": ch.title, @@ -24,7 +63,7 @@ def _chapter_to_dict(ch: ChapterModel) -> dict: "order_index": ch.order_index, "status": ch.status, "category": ch.category, - "images": ch.images or [], + "images": normalized_images, "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, "is_new": ch.is_new, "source_segments": ch.source_segments or [], @@ -105,7 +144,7 @@ async def get_chapter( "order_index": chapter.order_index, "status": chapter.status, "category": chapter.category, - "images": chapter.images or [], + "images": _normalize_image_assets(chapter.images), "updated_at": chapter.updated_at.isoformat() if chapter.updated_at else None, "is_new": chapter.is_new, "source_segments": chapter.source_segments or [], @@ -151,4 +190,3 @@ async def regenerate_chapter( # TODO: 实现重新整理逻辑 return {"status": "ok", "message": "Chapter regeneration triggered"} - diff --git a/api/services/memoir_images/parser.py b/api/services/memoir_images/parser.py index 7854f66..18f511e 100644 --- a/api/services/memoir_images/parser.py +++ b/api/services/memoir_images/parser.py @@ -1,13 +1,16 @@ import re from typing import Any -PLACEHOLDER_RE = re.compile(r"\{\{\{\{IMAGE:(.*?)\}\}\}\}") +PLACEHOLDER_RE = re.compile( + r"\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}", + re.DOTALL, +) def parse_image_placeholders(content: str, max_images: int) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for match in PLACEHOLDER_RE.finditer(content or ""): - description = match.group(1).strip() + description = (match.group(1) or match.group(2) or "").strip() if not description: continue items.append( diff --git a/api/services/memoir_images/settings.py b/api/services/memoir_images/settings.py index ec1e9df..e820e70 100644 --- a/api/services/memoir_images/settings.py +++ b/api/services/memoir_images/settings.py @@ -20,7 +20,7 @@ class MemoirImageSettings: max_per_chapter=int(os.getenv("MEMOIR_IMAGE_MAX_PER_CHAPTER", "2")), provider=os.getenv("MEMOIR_IMAGE_PROVIDER", "liblib"), default_style=os.getenv("MEMOIR_IMAGE_STYLE_DEFAULT", "watercolor"), - default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1024x1024"), + default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1280x720"), poll_interval_seconds=int(os.getenv("MEMOIR_IMAGE_POLL_INTERVAL", "3")), max_attempts=int(os.getenv("MEMOIR_IMAGE_MAX_ATTEMPTS", "60")), liblib_template_uuid=os.getenv( diff --git a/api/services/memoir_images/storage.py b/api/services/memoir_images/storage.py index ea41b8e..72bc783 100644 --- a/api/services/memoir_images/storage.py +++ b/api/services/memoir_images/storage.py @@ -1,12 +1,75 @@ import os +from urllib.parse import urlparse, urlunparse from qcloud_cos import CosConfig, CosS3Client +def normalize_cos_base_url(base_url: str, bucket: str, region: str) -> str: + candidate = (base_url or "").rstrip("/") + if not candidate and bucket and region: + candidate = f"https://{bucket}.cos.{region}.myqcloud.com" + if not candidate: + return "" + + parsed = urlparse(candidate) + duplicated_appid_host = f"{bucket}-appid.cos." if bucket else "" + if duplicated_appid_host and parsed.netloc.startswith(duplicated_appid_host): + parsed = parsed._replace( + netloc=parsed.netloc.replace(duplicated_appid_host, f"{bucket}.cos.", 1), + path=parsed.path.rstrip("/"), + ) + return urlunparse(parsed).rstrip("/") + + return candidate + + +def normalize_cos_url(url: str | None, bucket: str, region: str, base_url: str | None = None) -> str | None: + if not url: + return url + + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + return url + + normalized_base = normalize_cos_base_url( + base_url or f"{parsed.scheme}://{parsed.netloc}", + bucket=bucket, + region=region, + ) + if not normalized_base: + return url + + normalized_parsed = urlparse(normalized_base) + return urlunparse(parsed._replace(scheme=normalized_parsed.scheme, netloc=normalized_parsed.netloc)) + + +def resolve_image_storage_key(image: dict | None) -> str | None: + if not image: + return None + + explicit_key = image.get("storage_key") + if explicit_key: + return explicit_key + + url = image.get("url") + if not url: + return None + + parsed = urlparse(url) + if parsed.scheme and parsed.netloc: + key = parsed.path.lstrip("/") + return key or None + + return str(url).lstrip("/") or None + + class TencentCosStorageService: def __init__(self, secret_id: str, secret_key: str, region: str, bucket: str, base_url: str): + self.secret_id = secret_id + self.secret_key = secret_key self.bucket = bucket - self.base_url = base_url.rstrip("/") + self.region = region + self.base_url = normalize_cos_base_url(base_url, bucket=bucket, region=region) config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key) self.client = CosS3Client(config) @@ -19,6 +82,13 @@ class TencentCosStorageService: ) return f"{self.base_url}/{key}" + def get_download_url(self, key: str, expires: int = 3600) -> str: + return self.client.get_presigned_download_url( + Bucket=self.bucket, + Key=key, + Expired=expires, + ) + @classmethod def from_env(cls) -> "TencentCosStorageService": return cls( diff --git a/api/services/pdf_service.py b/api/services/pdf_service.py index bf7715d..77a91dd 100644 --- a/api/services/pdf_service.py +++ b/api/services/pdf_service.py @@ -13,6 +13,7 @@ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont from io import BytesIO +from services.memoir_images.storage import TencentCosStorageService, resolve_image_storage_key logger = logging.getLogger(__name__) @@ -42,6 +43,23 @@ def split_content_blocks(content: str, images: list[dict]) -> list[dict]: return blocks +def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]: + storage = TencentCosStorageService.from_env() + prepared_assets: list[dict] = [] + + for item in images or []: + asset = dict(item) + storage_key = resolve_image_storage_key(asset) + if asset.get("status") == "completed" and storage_key: + try: + asset["url"] = storage.get_download_url(storage_key) + except Exception as exc: + logger.warning(f"PDF 图片签名失败: key={storage_key}, error={exc}") + prepared_assets.append(asset) + + return prepared_assets + + class PDFService: """PDF 生成服务""" @@ -108,7 +126,7 @@ class PDFService: story.append(Paragraph(chapter.title, heading_style)) story.append(Spacer(1, 0.2 * inch)) - images = getattr(chapter, "images", None) or [] + images = _prepare_pdf_image_assets(getattr(chapter, "images", None) or []) blocks = split_content_blocks(chapter.content, images) for block in blocks: diff --git a/api/tasks/memoir_tasks.py b/api/tasks/memoir_tasks.py index 5517fcc..cc382fc 100644 --- a/api/tasks/memoir_tasks.py +++ b/api/tasks/memoir_tasks.py @@ -106,7 +106,9 @@ def _merge_chapter_image_assets( merged_item["size"] = merged_item.get("size") or size merged_item["created_at"] = merged_item.get("created_at") or now_iso merged_item["updated_at"] = merged_item.get("updated_at") or now_iso - if merged_item.get("status") == "completed" and not merged_item.get("url"): + if merged_item.get("status") == "completed" and not ( + merged_item.get("storage_key") or merged_item.get("url") + ): merged_item["status"] = "failed" merged_item["error"] = merged_item.get("error") or "missing image url" else: @@ -131,6 +133,7 @@ def initialize_chapter_images(chapter) -> list[dict]: settings = MemoirImageSettings.from_env() if not settings.enabled: chapter.images = [] + logger.info(f"章节图片初始化跳过: chapter={chapter.id}, enabled=false") return chapter.images prompt_service = MemoirImagePromptService(llm=None, settings=settings) @@ -144,6 +147,13 @@ def initialize_chapter_images(chapter) -> list[dict]: size=settings.default_size, now_iso=datetime.now(timezone.utc).isoformat(), ) + logger.info( + "章节图片初始化完成: chapter=%s, placeholders=%d, images=%d, statuses=%s", + chapter.id, + len(placeholders), + len(chapter.images or []), + [item.get("status") for item in (chapter.images or [])], + ) return chapter.images @@ -479,6 +489,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): for chapter_id in sorted(chapters_to_enqueue): try: + logger.info(f"派发章节补图任务: chapter={chapter_id}") generate_chapter_images.delay(chapter_id) except Exception as exc: logger.warning(f"补图任务派发失败: chapter={chapter_id}, error={exc}") @@ -599,6 +610,7 @@ def generate_chapter_images(self, chapter_id: str): try: chapter = db.get(Chapter, chapter_id) if not chapter or not chapter.images: + logger.info(f"章节补图跳过: chapter={chapter_id}, reason=no_images") return {"status": "no_images"} settings = MemoirImageSettings.from_env() @@ -606,9 +618,16 @@ def generate_chapter_images(self, chapter_id: str): provider = LiblibImageProvider(template_uuid=settings.liblib_template_uuid) storage = TencentCosStorageService.from_env() images = [dict(item) for item in (chapter.images or [])] + pending_count = sum(1 for item in images if item.get("status") in {"pending", "failed"}) + logger.info( + "章节补图开始: chapter=%s, total_images=%d, pending_images=%d", + chapter_id, + len(images), + pending_count, + ) for index, item in enumerate(images): - if item.get("status") == "completed" and item.get("url"): + if item.get("status") == "completed" and (item.get("storage_key") or item.get("url")): continue if item.get("status") not in {"pending", "failed"}: continue @@ -643,12 +662,19 @@ def generate_chapter_images(self, chapter_id: str): ) image_bytes = provider.download_image(job) key = build_cos_key(chapter.user_id, chapter.id, current_item["index"], prompt_data["prompt"]) + current_item["storage_key"] = key current_item["url"] = storage.upload_bytes(image_bytes, key, "image/png") current_item["prompt"] = prompt_data["prompt"] current_item["style"] = prompt_data["style"] current_item["size"] = prompt_data["size"] current_item["status"] = "completed" current_item["error"] = None + logger.info( + "章节补图成功: chapter=%s, index=%s, url=%s", + chapter_id, + current_item.get("index"), + current_item["url"], + ) except Exception as exc: current_item["status"] = "failed" current_item["error"] = str(exc) diff --git a/api/tests/test_chapters_router_images.py b/api/tests/test_chapters_router_images.py new file mode 100644 index 0000000..62c697a --- /dev/null +++ b/api/tests/test_chapters_router_images.py @@ -0,0 +1,58 @@ +import os +import unittest +from unittest.mock import Mock, patch + +from api.routers.chapters import _chapter_to_dict + + +class ChaptersRouterImagesTest(unittest.TestCase): + @patch("api.routers.chapters.TencentCosStorageService") + @patch.dict( + os.environ, + { + "TENCENT_COS_BUCKET": "life-echo-dev-1319381411", + "TENCENT_COS_REGION": "ap-shanghai", + "TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com", + }, + clear=False, + ) + def test_chapter_to_dict_returns_signed_image_urls_for_response(self, storage_cls): + storage = Mock() + storage.get_download_url.return_value = "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123" + storage_cls.from_env.return_value = storage + + chapter = type( + "ChapterStub", + (), + { + "id": "chapter-1", + "title": "童年的夏天", + "content": "{{IMAGE:南方小镇的青石板路}}", + "order_index": 0, + "status": "completed", + "category": "childhood", + "images": [ + { + "index": 0, + "placeholder": "{{IMAGE:南方小镇的青石板路}}", + "description": "南方小镇的青石板路", + "status": "completed", + "prompt": "A serene southern China town", + "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", + "storage_key": "memoirs/u1/c1/0-demo.png", + } + ], + "updated_at": None, + "is_new": False, + "source_segments": [], + }, + )() + + payload = _chapter_to_dict(chapter) + + self.assertEqual( + payload["images"][0]["url"], + "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123", + ) + self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town") + self.assertNotIn("storage_key", payload["images"][0]) diff --git a/api/tests/test_generate_chapter_images_task.py b/api/tests/test_generate_chapter_images_task.py index 08cc1d0..bc723ce 100644 --- a/api/tests/test_generate_chapter_images_task.py +++ b/api/tests/test_generate_chapter_images_task.py @@ -58,6 +58,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): generate_chapter_images.run("chapter-1") self.assertEqual(chapter.images[0]["status"], "completed") + self.assertEqual(chapter.images[0]["storage_key"], "memoirs/user-1/chapter-1/0-7e1f860790.png") self.assertEqual(chapter.images[0]["url"], "https://cos.example.com/memoirs/u1/c1/0.png") self.assertEqual(chapter.images[0]["prompt"], "A serene southern China town") db.commit.assert_called() diff --git a/api/tests/test_memoir_image_bootstrap.py b/api/tests/test_memoir_image_bootstrap.py index 2270eda..d076d5d 100644 --- a/api/tests/test_memoir_image_bootstrap.py +++ b/api/tests/test_memoir_image_bootstrap.py @@ -66,3 +66,23 @@ class MemoirImageBootstrapTest(unittest.TestCase): self.assertEqual(assets[0]["url"], "https://cos.example.com/existing.png") self.assertEqual(assets[1]["status"], "pending") self.assertEqual(assets[1]["description"], "奶奶坐在院子里的藤椅上") + + def test_initialize_chapter_images_accepts_double_brace_placeholders(self): + chapter = type( + "ChapterStub", + (), + { + "id": "chapter-1", + "title": "童年的夏天", + "category": "childhood", + "content": "开头。\n\n{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}\n\n结尾。", + "images": [], + }, + )() + + with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False): + assets = initialize_chapter_images(chapter) + + self.assertEqual(len(assets), 1) + self.assertEqual(assets[0]["status"], "pending") + self.assertEqual(assets[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}") diff --git a/api/tests/test_memoir_image_parser.py b/api/tests/test_memoir_image_parser.py index 3e29c0f..b1a1a92 100644 --- a/api/tests/test_memoir_image_parser.py +++ b/api/tests/test_memoir_image_parser.py @@ -43,3 +43,12 @@ class MemoirImageParserTest(unittest.TestCase): self.assertEqual(assets[0]["status"], "pending") self.assertEqual(assets[0]["provider"], "liblib") self.assertEqual(assets[0]["url"], None) + + def test_parse_image_placeholders_accepts_double_brace_variant(self): + content = "开头。\n\n{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}\n\n结尾。" + + items = parse_image_placeholders(content, max_images=2) + + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}") + self.assertEqual(items[0]["description"], "1938年初的上海弄堂口,冬日萧瑟") diff --git a/api/tests/test_memoir_image_prompting.py b/api/tests/test_memoir_image_prompting.py index a267787..e745b74 100644 --- a/api/tests/test_memoir_image_prompting.py +++ b/api/tests/test_memoir_image_prompting.py @@ -15,6 +15,7 @@ class MemoirImagePromptingTest(unittest.TestCase): default_size="1024x1024", poll_interval_seconds=3, max_attempts=20, + liblib_template_uuid="tpl-uuid", ) service = MemoirImagePromptService(llm=None, settings=settings) @@ -39,6 +40,7 @@ class MemoirImagePromptingTest(unittest.TestCase): default_size="1024x1024", poll_interval_seconds=3, max_attempts=20, + liblib_template_uuid="tpl-uuid", ) llm = Mock() llm.invoke.return_value.content = ( diff --git a/api/tests/test_memoir_image_storage.py b/api/tests/test_memoir_image_storage.py index 9e771c1..3f8262e 100644 --- a/api/tests/test_memoir_image_storage.py +++ b/api/tests/test_memoir_image_storage.py @@ -1,7 +1,11 @@ import unittest from unittest.mock import Mock, patch -from api.services.memoir_images.storage import TencentCosStorageService +from api.services.memoir_images.storage import ( + TencentCosStorageService, + normalize_cos_url, + resolve_image_storage_key, +) class MemoirImageStorageTest(unittest.TestCase): @@ -28,3 +32,80 @@ class MemoirImageStorageTest(unittest.TestCase): "https://memoir-1250000000.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", ) client.put_object.assert_called_once() + + @patch("api.services.memoir_images.storage.CosS3Client") + def test_upload_bytes_normalizes_duplicate_appid_suffix_in_base_url(self, client_cls): + client = Mock() + client_cls.return_value = client + storage = TencentCosStorageService( + secret_id="id", + secret_key="key", + region="ap-shanghai", + bucket="life-echo-dev-1319381411", + base_url="https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com", + ) + + url = storage.upload_bytes( + image_bytes=b"png-bytes", + key="memoirs/u1/c1/0-demo.png", + content_type="image/png", + ) + + self.assertEqual( + url, + "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", + ) + client.put_object.assert_called_once() + + def test_normalize_cos_url_repairs_existing_duplicate_appid_host(self): + normalized = normalize_cos_url( + "https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", + bucket="life-echo-dev-1319381411", + region="ap-shanghai", + ) + + self.assertEqual( + normalized, + "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", + ) + + @patch("api.services.memoir_images.storage.CosS3Client") + def test_get_download_url_returns_presigned_download_url(self, client_cls): + client = Mock() + client.get_presigned_download_url.return_value = "https://cos.example.com/0.png?q-sign-algorithm=sha1" + client_cls.return_value = client + storage = TencentCosStorageService( + secret_id="id", + secret_key="key", + region="ap-shanghai", + bucket="life-echo-dev-1319381411", + base_url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com", + ) + + url = storage.get_download_url("memoirs/u1/c1/0-demo.png", expires=1800) + + self.assertEqual(url, "https://cos.example.com/0.png?q-sign-algorithm=sha1") + client.get_presigned_download_url.assert_called_once_with( + Bucket="life-echo-dev-1319381411", + Key="memoirs/u1/c1/0-demo.png", + Expired=1800, + ) + + def test_resolve_image_storage_key_prefers_explicit_storage_key(self): + key = resolve_image_storage_key( + { + "storage_key": "memoirs/u1/c1/0-demo.png", + "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/other.png", + } + ) + + self.assertEqual(key, "memoirs/u1/c1/0-demo.png") + + def test_resolve_image_storage_key_derives_key_from_existing_url(self): + key = resolve_image_storage_key( + { + "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png?q-sign-algorithm=sha1" + } + ) + + self.assertEqual(key, "memoirs/u1/c1/0-demo.png") diff --git a/api/tests/test_pdf_service_images.py b/api/tests/test_pdf_service_images.py index 7fd5914..f77cc94 100644 --- a/api/tests/test_pdf_service_images.py +++ b/api/tests/test_pdf_service_images.py @@ -6,7 +6,12 @@ from api.services.pdf_service import PDFService class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase): @patch("api.services.pdf_service.httpx.AsyncClient") - async def test_generate_pdf_embeds_completed_images_and_removes_placeholders(self, async_client_cls): + @patch("api.services.pdf_service.TencentCosStorageService") + async def test_generate_pdf_embeds_completed_images_and_removes_placeholders( + self, + storage_cls, + async_client_cls, + ): png_bytes = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00" @@ -20,6 +25,9 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase): mock_client.get.return_value = mock_response async_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) async_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + storage = MagicMock() + storage.get_download_url.return_value = "https://signed.example.com/0.png?sig=123" + storage_cls.from_env.return_value = storage service = PDFService() book = type("BookStub", (), {"title": "我的回忆录"})() @@ -33,7 +41,8 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase): { "index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "url": "https://cos.example.com/0.png", + "url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0.png", + "storage_key": "memoirs/u1/c1/0.png", "status": "completed", } ], @@ -44,3 +53,4 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase): self.assertGreater(len(pdf_bytes), 100) self.assertNotIn(b"IMAGE:", pdf_bytes) + mock_client.get.assert_called_once_with("https://signed.example.com/0.png?sig=123") diff --git a/api/tests/test_process_memoir_segments_image_enqueue.py b/api/tests/test_process_memoir_segments_image_enqueue.py index f6b5de8..0e17efe 100644 --- a/api/tests/test_process_memoir_segments_image_enqueue.py +++ b/api/tests/test_process_memoir_segments_image_enqueue.py @@ -35,6 +35,7 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): default_size="1024x1024", poll_interval_seconds=3, max_attempts=20, + liblib_template_uuid="tpl-uuid", ) get_state_mock.return_value = SimpleNamespace(current_stage="childhood", slots={}) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt index 4a0b6fe..17746bd 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt @@ -18,7 +18,9 @@ fun splitMemoirContent(content: String, images: List): List() images.sortedBy { it.index }.forEach { image -> val placeholder = image.placeholder - if (!remaining.contains(placeholder)) return@forEach + if (!remaining.contains(placeholder)) { + return@forEach + } val parts = remaining.split(placeholder, limit = 2) val before = stripImagePlaceholders(parts.first()) if (before.isNotBlank()) blocks += MemoirContentBlock.Text(before) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt index 015bb0b..9eb60c3 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt @@ -8,8 +8,8 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,12 +20,53 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage +import coil.compose.SubcomposeAsyncImage import com.huaga.life_echo.network.models.ChapterImageDto import com.huaga.life_echo.ui.theme.AppTypography import com.huaga.life_echo.ui.theme.LightPurple import com.huaga.life_echo.ui.theme.SlatePurple +internal fun memoirImageAspectRatio(size: String?): Float { + val match = Regex("""^\s*(\d+)\s*x\s*(\d+)\s*$""").matchEntire(size.orEmpty()) ?: return 1f + val width = match.groupValues[1].toFloatOrNull() ?: return 1f + val height = match.groupValues[2].toFloatOrNull() ?: return 1f + if (width <= 0f || height <= 0f) return 1f + return width / height +} + +@Composable +private fun MemoirImageLoadingPlaceholder( + image: ChapterImageDto, + modifier: Modifier = Modifier, + text: String = "图片生成中…", +) { + val transition = rememberInfiniteTransition(label = "shimmer") + val alpha by transition.animateFloat( + initialValue = 0.10f, + targetValue = 0.25f, + animationSpec = infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse + ), + label = "shimmer-alpha" + ) + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(memoirImageAspectRatio(image.size)) + .clip(RoundedCornerShape(16.dp)) + .background(LightPurple.copy(alpha = alpha)) + .testTag("memoir-image-loading-${image.index}"), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + fontSize = AppTypography.bodyMedium, + color = SlatePurple.copy(alpha = 0.6f), + ) + } +} + @Composable fun MemoirInlineImage( image: ChapterImageDto, @@ -33,43 +74,36 @@ fun MemoirInlineImage( modifier: Modifier = Modifier, ) { when (image.status) { - "completed" -> AsyncImage( + "completed" -> SubcomposeAsyncImage( model = image.url, contentDescription = image.description, contentScale = ContentScale.FillWidth, + loading = { + MemoirImageLoadingPlaceholder( + image = image, + modifier = modifier, + text = "图片加载中…", + ) + }, + error = { + MemoirImageLoadingPlaceholder( + image = image, + modifier = modifier, + text = "图片暂不可用", + ) + }, modifier = modifier .fillMaxWidth() + .aspectRatio(memoirImageAspectRatio(image.size)) .clip(RoundedCornerShape(16.dp)) .clickable(onClick = onClick) .testTag("memoir-image-${image.index}") ) - "pending", "processing" -> { - val transition = rememberInfiniteTransition(label = "shimmer") - val alpha by transition.animateFloat( - initialValue = 0.10f, - targetValue = 0.25f, - animationSpec = infiniteRepeatable( - animation = tween(800), - repeatMode = RepeatMode.Reverse - ), - label = "shimmer-alpha" - ) - Box( - modifier = modifier - .fillMaxWidth() - .height(220.dp) - .clip(RoundedCornerShape(16.dp)) - .background(LightPurple.copy(alpha = alpha)) - .testTag("memoir-image-loading-${image.index}"), - contentAlignment = Alignment.Center, - ) { - Text( - text = "图片生成中…", - fontSize = AppTypography.bodyMedium, - color = SlatePurple.copy(alpha = 0.6f), - ) - } - } + "pending", "processing" -> MemoirImageLoadingPlaceholder( + image = image, + modifier = modifier, + text = "图片生成中…", + ) else -> Unit } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MemoirImagePolling.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MemoirImagePolling.kt new file mode 100644 index 0000000..2fad380 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MemoirImagePolling.kt @@ -0,0 +1,13 @@ +package com.huaga.life_echo.ui.screens + +import com.huaga.life_echo.network.models.ChapterDto + +internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 3_000L + +internal fun hasPendingMemoirImages(chapters: List): Boolean { + return chapters.any { chapter -> + chapter.images.any { image -> + image.status == "pending" || image.status == "processing" + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt index 12dc788..3b2dd0c 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt @@ -35,6 +35,7 @@ import com.huaga.life_echo.ui.settings.AppSettings import com.huaga.life_echo.ui.theme.* import com.huaga.life_echo.ui.viewmodel.MyMemoirViewModel import com.huaga.life_echo.ui.viewmodel.ViewModelFactory +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -164,6 +165,22 @@ fun MyMemoirScreen( val contentChapters = remember(chapterDtos) { chapterDtos.filter { it.content.isNotBlank() } } + val shouldPollChapterImages = remember(chapterDtos) { + hasPendingMemoirImages(chapterDtos) + } + val latestIsLoading by rememberUpdatedState(isLoading) + val latestIsRefreshing by rememberUpdatedState(isRefreshing) + + LaunchedEffect(shouldPollChapterImages) { + if (!shouldPollChapterImages) return@LaunchedEffect + + while (true) { + delay(MEMOIR_IMAGE_POLL_INTERVAL_MS) + if (!latestIsLoading && !latestIsRefreshing) { + viewModel.refreshChapters() + } + } + } AnimatedContent( targetState = showFullTextReading || selectedChapter != null, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt index a1023cc..b4c9e76 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt @@ -205,4 +205,3 @@ class MyMemoirViewModel( } } } - diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt index 1e698c5..b347ad4 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/chat/ChatInputPresentationTest.kt @@ -38,7 +38,6 @@ class ChatInputPresentationTest { ) assertEquals(ChatInputLeadingAction.SWITCH_TO_VOICE, presentation.leadingAction) - assertTrue(presentation.showEmojiAction) assertEquals(ChatInputTrailingAction.ADD, presentation.trailingAction) } @@ -51,7 +50,6 @@ class ChatInputPresentationTest { ) assertEquals(ChatInputTrailingAction.SEND, presentation.trailingAction) - assertTrue(presentation.showEmojiAction) } @Test @@ -63,7 +61,6 @@ class ChatInputPresentationTest { ) assertEquals(ChatInputLeadingAction.SWITCH_TO_TEXT, presentation.leadingAction) - assertTrue(presentation.showEmojiAction) assertEquals(ChatInputTrailingAction.ADD, presentation.trailingAction) } @@ -76,7 +73,6 @@ class ChatInputPresentationTest { ) assertEquals(ChatInputLeadingAction.SWITCH_TO_TEXT, presentation.leadingAction) - assertFalse(presentation.showEmojiAction) assertEquals(ChatInputTrailingAction.CANCEL, presentation.trailingAction) } diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageSizingTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageSizingTest.kt new file mode 100644 index 0000000..e2db6a2 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageSizingTest.kt @@ -0,0 +1,30 @@ +package com.huaga.life_echo.ui.components.memoir + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MemoirInlineImageSizingTest { + + @Test + fun memoirImageAspectRatio_defaultsToSquare_whenSizeMissing() { + assertEquals(1f, memoirImageAspectRatio(null), 0.0001f) + assertEquals(1f, memoirImageAspectRatio(""), 0.0001f) + } + + @Test + fun memoirImageAspectRatio_parsesSquareSize() { + assertEquals(1f, memoirImageAspectRatio("1024x1024"), 0.0001f) + } + + @Test + fun memoirImageAspectRatio_parsesLandscapeAndPortraitSizes() { + assertEquals(1.5f, memoirImageAspectRatio("1536x1024"), 0.0001f) + assertEquals(0.6667f, memoirImageAspectRatio("1024x1536"), 0.0001f) + } + + @Test + fun memoirImageAspectRatio_fallsBackToSquare_whenSizeMalformed() { + assertEquals(1f, memoirImageAspectRatio("oops"), 0.0001f) + assertEquals(1f, memoirImageAspectRatio("1024x0"), 0.0001f) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/MemoirImagePollingTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/MemoirImagePollingTest.kt new file mode 100644 index 0000000..eb7a53b --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/MemoirImagePollingTest.kt @@ -0,0 +1,60 @@ +package com.huaga.life_echo.ui.screens + +import com.huaga.life_echo.network.models.ChapterDto +import com.huaga.life_echo.network.models.ChapterImageDto +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MemoirImagePollingTest { + + @Test + fun hasPendingMemoirImages_returnsTrue_whenAnyImageIsPendingOrProcessing() { + val chapters = listOf( + chapterWithImages("completed"), + chapterWithImages("processing"), + ) + + assertTrue(hasPendingMemoirImages(chapters)) + } + + @Test + fun hasPendingMemoirImages_returnsFalse_whenAllImagesAreTerminal() { + val chapters = listOf( + chapterWithImages("completed"), + chapterWithImages("failed"), + ) + + assertFalse(hasPendingMemoirImages(chapters)) + } + + private fun chapterWithImages(status: String): ChapterDto { + return ChapterDto( + id = "chapter-$status", + title = "title-$status", + content = "content-$status", + order_index = 0, + status = "partial", + category = "childhood", + images = listOf( + ChapterImageDto( + index = 0, + placeholder = "{{IMAGE:$status}}", + description = status, + prompt = null, + url = if (status == "completed") "https://cos.example.com/$status.png" else null, + status = status, + provider = "liblib", + style = "memoir", + size = "1024x1024", + error = null, + created_at = null, + updated_at = null, + ) + ), + updated_at = null, + is_new = false, + source_segments = emptyList(), + ) + } +}