fix: harden memoir image generation flow

This commit is contained in:
Kevin
2026-03-11 11:26:42 +08:00
parent a76cf8da18
commit 00092d34c9
14 changed files with 1162 additions and 69 deletions

View File

@@ -0,0 +1,6 @@
package com.huaga.life_echo.network.models
const val MEMOIR_IMAGE_STATUS_PENDING = "pending"
const val MEMOIR_IMAGE_STATUS_PROCESSING = "processing"
const val MEMOIR_IMAGE_STATUS_COMPLETED = "completed"
const val MEMOIR_IMAGE_STATUS_FAILED = "failed"

View File

@@ -1,13 +1,41 @@
package com.huaga.life_echo.ui.screens
import com.huaga.life_echo.network.models.ChapterDto
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PENDING
import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PROCESSING
internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 3_000L
internal const val MEMOIR_IMAGE_PROVIDER_MAX_ATTEMPTS = 60L
internal const val MEMOIR_IMAGE_POLL_GRACE_MS = 30_000L
internal fun hasPendingMemoirImages(chapters: List<ChapterDto>): Boolean {
return chapters.any { chapter ->
chapter.images.any { image ->
image.status == "pending" || image.status == "processing"
image.status == MEMOIR_IMAGE_STATUS_PENDING || image.status == MEMOIR_IMAGE_STATUS_PROCESSING
}
}
}
internal fun longestPendingMemoirImageSequence(chapters: List<ChapterDto>): Long {
return chapters.maxOfOrNull { chapter ->
chapter.images.count { image ->
image.status == MEMOIR_IMAGE_STATUS_PENDING || image.status == MEMOIR_IMAGE_STATUS_PROCESSING
}.toLong()
} ?: 0L
}
internal fun memoirImagePollTimeoutMs(maxObservedPendingImages: Long): Long {
val pendingImages = maxOf(1L, maxObservedPendingImages)
return pendingImages * MEMOIR_IMAGE_POLL_INTERVAL_MS * MEMOIR_IMAGE_PROVIDER_MAX_ATTEMPTS +
MEMOIR_IMAGE_POLL_GRACE_MS
}
internal fun shouldContinueMemoirImagePolling(
chapters: List<ChapterDto>,
pollStartedAtMs: Long,
nowMs: Long,
maxObservedPendingImages: Long,
): Boolean {
if (!hasPendingMemoirImages(chapters)) return false
return nowMs - pollStartedAtMs < memoirImagePollTimeoutMs(maxObservedPendingImages)
}

View File

@@ -168,17 +168,31 @@ fun MyMemoirScreen(
val shouldPollChapterImages = remember(chapterDtos) {
hasPendingMemoirImages(chapterDtos)
}
val latestChapterDtos by rememberUpdatedState(chapterDtos)
val latestIsLoading by rememberUpdatedState(isLoading)
val latestIsRefreshing by rememberUpdatedState(isRefreshing)
LaunchedEffect(shouldPollChapterImages) {
if (!shouldPollChapterImages) return@LaunchedEffect
val pollStartedAtMs = System.currentTimeMillis()
var maxObservedPendingImages = longestPendingMemoirImageSequence(latestChapterDtos)
while (true) {
while (
shouldContinueMemoirImagePolling(
chapters = latestChapterDtos,
pollStartedAtMs = pollStartedAtMs,
nowMs = System.currentTimeMillis(),
maxObservedPendingImages = maxObservedPendingImages,
)
) {
delay(MEMOIR_IMAGE_POLL_INTERVAL_MS)
if (!latestIsLoading && !latestIsRefreshing) {
viewModel.refreshChapters()
}
maxObservedPendingImages = maxOf(
maxObservedPendingImages,
longestPendingMemoirImageSequence(latestChapterDtos),
)
}
}

View File

@@ -2,6 +2,7 @@ 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.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -28,7 +29,85 @@ class MemoirImagePollingTest {
assertFalse(hasPendingMemoirImages(chapters))
}
private fun chapterWithImages(status: String): ChapterDto {
@Test
fun memoirImagePollTimeoutMs_scalesWithPendingImagesInSameChapter() {
val chapters = listOf(
chapterWithImages("processing", imageCount = 2),
)
assertTrue(memoirImagePollTimeoutMs(2L) > MEMOIR_IMAGE_POLL_GRACE_MS)
assertEquals(
390_000L,
memoirImagePollTimeoutMs(2L),
)
assertEquals(2L, longestPendingMemoirImageSequence(chapters))
}
@Test
fun shouldContinueMemoirImagePolling_returnsFalse_afterDynamicTimeout() {
val chapters = listOf(
chapterWithImages("processing", imageCount = 2),
)
assertFalse(
shouldContinueMemoirImagePolling(
chapters = chapters,
pollStartedAtMs = 0L,
nowMs = memoirImagePollTimeoutMs(2L) + 1L,
maxObservedPendingImages = 2L,
)
)
}
@Test
fun shouldContinueMemoirImagePolling_returnsTrue_beforeDynamicTimeout_whenPending() {
val chapters = listOf(
chapterWithImages("processing", imageCount = 2),
)
assertTrue(
shouldContinueMemoirImagePolling(
chapters = chapters,
pollStartedAtMs = 0L,
nowMs = memoirImagePollTimeoutMs(2L) - 1L,
maxObservedPendingImages = 2L,
)
)
}
@Test
fun shouldContinueMemoirImagePolling_keepsOriginalWindow_whenPendingCountDropsMidSession() {
val chapters = listOf(
chapterWithImages("processing", imageCount = 1),
)
assertTrue(
shouldContinueMemoirImagePolling(
chapters = chapters,
pollStartedAtMs = 0L,
nowMs = 300_000L,
maxObservedPendingImages = 2L,
)
)
}
@Test
fun shouldContinueMemoirImagePolling_returnsFalse_whenLatestChapterStateHasNoPendingImages() {
val chapters = listOf(
chapterWithImages("completed", imageCount = 1),
)
assertFalse(
shouldContinueMemoirImagePolling(
chapters = chapters,
pollStartedAtMs = 0L,
nowMs = 1L,
maxObservedPendingImages = 1L,
)
)
}
private fun chapterWithImages(status: String, imageCount: Int = 1): ChapterDto {
return ChapterDto(
id = "chapter-$status",
title = "title-$status",
@@ -36,13 +115,13 @@ class MemoirImagePollingTest {
order_index = 0,
status = "partial",
category = "childhood",
images = listOf(
images = (0 until imageCount).map { index ->
ChapterImageDto(
index = 0,
placeholder = "{{IMAGE:$status}}",
index = index,
placeholder = "{{IMAGE:$status-$index}}",
description = status,
prompt = null,
url = if (status == "completed") "https://cos.example.com/$status.png" else null,
url = if (status == "completed") "https://cos.example.com/$status-$index.png" else null,
status = status,
provider = "liblib",
style = "memoir",
@@ -51,7 +130,7 @@ class MemoirImagePollingTest {
created_at = null,
updated_at = null,
)
),
},
updated_at = null,
is_new = false,
source_segments = emptyList(),