diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/PreviewMetadata.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/PreviewMetadata.kt new file mode 100644 index 0000000..61866d3 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/PreviewMetadata.kt @@ -0,0 +1,207 @@ +package top.yeij.cyrene.domain.chat + +import java.util.Locale + +/** + * 预览内容**下方**那一行灰色小字的组装(纯函数,逐档穷举单测在 `PreviewMetadataTest`)。 + * + * ## 这一行是什么,不是什么 + * 它只放**关于这份文件本身**的格式事实:有多少行、按什么口径解码、多大、多少像素、什么时候拍的。 + * 它**不是**状态栏、也不是错误提示——「读不到 / 打开失败 / 保存失败」各有自己的去处(芯片行、 + * 正文区那句人话、弹层下方那行告知),混进来会让这一行读起来像出了什么事。 + * + * ## 纪律:拿不到的项就不出现 + * 每一项都在**有证据时才产出**,没有证据就整项缺席——**不留占位符、不写「未知」、不写 `--`**。 + * 一屏小字里堆三个「未知」换不来任何信息,却会让人以为本端查过了。三档证据强度各自成条: + * - **字节**(本端真的持有那一份):行数、像素、EXIF 拍摄日期、文件体积; + * - **服务端结论**(DTO 里的字段):注入字符数、注入是否被截断、嗅探所得的 mime; + * - **本端口径**(本端自己做了什么):`UTF-8` 只是**本端解码口径**,不是文件的真实编码(见下)。 + * + * ## 「编码」为什么敢写 UTF-8(且只敢这么写) + * 客户端**拿不到文件的真实编码**:磁盘上那份字节是按什么字符集写的,没有任何字段说过,本端也没有 + * 可靠的方式判定(猜 GBK / Shift-JIS 就是在编)。能确定的只有一件事:正文区那段文字是 + * [DocumentPreview.slice] 用 `String(bytes, Charsets.UTF_8)` 解出来的——所以这一项写的是 + * **「本端解码口径」**,即「你正在读的这段文字是怎么来的」。**表述里必须带这个限定语**: + * 裸写一个「UTF-8」会被读成「这份文件是 UTF-8 编码的」,那是本端不知道的事。 + * + * ## 与芯片行重合的两处:一处保留、一处省略 + * 预览弹层顶部那枚芯片已经写着「类型 · 体积 · 字数 · 状态 · 注入已截断」([DocumentChipModel.detail]), + * 因此这一行刻意**不重复**: + * - **省略**「体积」与「服务端状态」:[DocumentChips.sizeLabel] / [DocumentChips.statusLabel] 那两个 + * 数字/词与芯片行**逐字相同**、且没有口径可补,再写一遍只是噪音(图片那一路没有芯片行,所以 + * 体积在 [imageItems] 里是要显示的); + * - **保留**「字符数」并补上口径:芯片只写「8,000 字」,读者无法知道那是**服务端注入上下文**的 + * 字数还是这份文件的字数——这正是一句会把人带偏的话,补上「(服务端注入口径)」才算说清。 + * + * ## 本端的截断为什么不在这里再说一遍 + * [PreviewSlice.truncated](本端只预览了前 256 KB / 5,000 行)已经让 [PreviewSlice.notice] 在**正文 + * 上方**明说了,本行不再重复同一件事;而**服务端**的「注入被预算截断」是另一件事([DocumentRefDto.truncated], + * 正文照样进了上下文),它没有别的去处,所以在 [textItems] 里单列一条。 + */ +object PreviewMetadata { + + /** 这一行各项之间的分隔符。 */ + const val ITEM_SEPARATOR: String = " · " + + /** 把各项拼成能直接交给 `Text` 的那一行;空列表拼成空串(调用方据此不画这一行)。 */ + fun join(items: List): String = items.joinToString(ITEM_SEPARATOR) + + /** + * 文本类预览的那几项:行数 · 字符数(带口径)· 解码口径 · 服务端的截断标记。 + * + * @param slice 正文区的切段结果([DocumentPreview.slice] 的产物)。 + * @param serverCharsLabel 服务端下发的注入字符数**已格式化**的那一份 + * (`documents[].chars` → [DocumentChips.charsLabel],如 `"8,000 字"`);`null` = 服务端没报 + * (`chars` 整键缺席,契约 v0.59 §1.2),此时改报本端预览内容的字数。 + * @param contextTruncated 服务端是否报告了「注入被预算截断」(`documents[].truncated`)。 + * ⚠️ 与 [PreviewSlice.truncated](**本端**预览截断)是两件事,见本对象 KDoc。 + */ + fun textItems( + slice: PreviewSlice, + serverCharsLabel: String?, + contextTruncated: Boolean, + ): List = buildList { + add(linesItem(slice)) + add(charsItem(slice.text, serverCharsLabel)) + // 本端解码口径:正文就是 String(bytes, UTF_8) 的产物,说得出、也只在说这一件事。 + add(DECODED_AS_UTF8) + if (contextTruncated) add(TRUNCATED_FOR_CONTEXT) + } + + /** + * 非文本类(PDF 这类二进制)的那几项:只有服务端**字节嗅探**所得的 mime。 + * + * 体积与状态刻意不在这里(芯片行已经写着,见本对象 KDoc);服务端说「读不到」时下发的 + * **原因原话**也不在这里——它已经被 [DocumentPreviewGate.plan] 送进正文区那句人话里**原样显示**, + * 这里是同一屏的第二个位置,重复一遍只会让那句话看着像两条不同的消息。 + * + * @param serverMime DTO 的 `mime`(服务端嗅探所得基类型);本地待发附件**没有**这个结论 + * (还没上传过),传 `null` —— 这一项因此缺席,**不由本端按扩展名补一个**。 + */ + fun fileItems(serverMime: String?): List = + listOfNotNull(serverMime?.trim()?.takeIf { it.isNotEmpty() }) + + /** + * 图片的那几项:格式(mime)· 分辨率 · 体积 · 拍摄日期。 + * + * 图片这一路没有附件芯片行,所以体积在这里是要显示的。每一项都可能在拿不到时缺席: + * 格式(嗅探与平台声明都判不出来时)、分辨率(本端解不出边界时)、拍摄日期(EXIF 里没有 + * `DateTimeOriginal`,或那个值不是能认的日期时)——见各自的参数说明。 + * + * @param mime 交给别的应用的类型([ImageOpenTypes.openType] 的产物)。⚠️ 退化到「图片通配」 + * 时**必须传 `null`**:`image/` 通配([ImageOpenTypes.ANY_IMAGE])不是格式,写出来等于给了一个什么都没说的答案。 + * @param width / @param height 本端按 `inJustDecodeBounds` 读到的像素;任一侧 ≤ 0 / 缺席即整项不出现。 + * @param sizeBytes 本端实际持有的字节数;[UNKNOWN_BYTES] 即整项不出现。 + * @param capturedAt EXIF `DateTimeOriginal` 的**原始字符串**(`yyyy:MM:dd HH:mm:ss`, + * 没有时区、按本地墙钟读,见 [formatExifDateTime])。 + */ + fun imageItems( + mime: String?, + width: Int? = null, + height: Int? = null, + sizeBytes: Long = UNKNOWN_BYTES, + capturedAt: String? = null, + ): List = buildList { + mime?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) } + if (width != null && height != null && width > 0 && height > 0) add("$width × $height") + DocumentChips.sizeLabel(sizeBytes).takeIf { it.isNotEmpty() }?.let { add(it) } + formatExifDateTime(capturedAt)?.let { add(CAPTURED_AT_PREFIX + it) } + } + + /** + * EXIF 的拍摄时间 → 人能读的样子(`"2019:07:04 20:31:05"` → `"拍摄于 2019年7月4日 20:31"`); + * 认不出就回 `null`(宁可不显示,也不显示一个错的日期)。 + * + * ## 两个必须说清的取舍 + * 1. **没有时区**。EXIF 的 `DateTimeOriginal` 是纯墙钟字符串(`yyyy:MM:dd HH:mm:ss`),规范里 + * 根本没有偏移量字段。所以这里**不做任何时区换算**,按字面数字当日历时间读——把它当 UTC + * 再转本地,只会让一个原本正确的日期凭空错几个小时。 + * 2. **严格到不近人情**。只有逐位对上 `yyyy:MM:dd HH:mm:ss`(19 个字符、分隔符固定)且各段落在 + * 合法范围内才认。相机的「值未写入」占位符 `"0000:00:00 00:00:00"` 因此落在月份 0 上被判非法 + * ——这是对的,它本来就表示「不知道」。宽松解析(补零、猜分隔符)换来的是**看起来正常的错日期**, + * 比不显示糟得多。 + * + * 秒刻意丢掉:这一行是给人看的摘要,分钟已经足够定位(同秒的两张图对用户没有区别)。 + */ + fun formatExifDateTime(raw: String?): String? { + val value = raw?.trim().orEmpty() + val groups = EXIF_DATE_TIME.matchEntire(value) ?: return null + // 正则已保证每段都是纯数字,`toInt()` 在这里不会抛。 + val (year, month, day, hour, minute, second) = groups.destructured + val y = year.toInt() + val mo = month.toInt() + val d = day.toInt() + val h = hour.toInt() + val mi = minute.toInt() + val s = second.toInt() + if (y < MIN_YEAR) return null + if (mo !in 1..12) return null + if (d !in 1..31) return null + if (h !in 0..23) return null + if (mi !in 0..59) return null + // 60 是闰秒,相机会写出来,不该判成坏日期。 + if (s !in 0..60) return null + return String.format(Locale.ROOT, "%d年%d月%d日 %02d:%02d", y, mo, d, h, mi) + } + + /** + * 行数那一项。 + * + * 显示量按 [DocumentPreview.lineCount] **从正文本身重算**(不是取 [PreviewSlice.shownLines]): + * 这一行的标题就是「你看到的这段有多少行」,重算让它与正文永远自洽。被本端截断时补上全文行数 + * ([PreviewSlice.totalLines] 是按整份字节数出来的,本端确实持有那份字节)——只说「显示 N 行」 + * 会让人以为文件就这么长。 + */ + private fun linesItem(slice: PreviewSlice): String { + val shown = grouped(DocumentPreview.lineCount(slice.text)) + return if (slice.truncated) { + "显示 $shown 行 / 共 ${grouped(slice.totalLines)} 行" + } else { + "$shown 行" + } + } + + /** 字符数一项:服务端报了就报它那句(补口径),没报才用本端的计数(另一句口径)。 */ + private fun charsItem(text: String, serverCharsLabel: String?): String = + if (serverCharsLabel != null) { + serverCharsLabel + SERVER_CHARS_SCOPE + } else { + grouped(codePointCount(text)) + " 字" + LOCAL_CHARS_SCOPE + } + + /** + * 「字」数按 **Unicode 码点**算(`codePointCount`):一个 emoji / 一个汉字都算一个字。 + * 直接取 `String.length` 会把 BMP 之外的字符算成两个(UTF-16 代理对),与「字数」这个说法不符。 + */ + private fun codePointCount(text: String): Int = + text.codePointCount(0, text.length) + + /** 千分位分组(只用于计数,纯展示)。 */ + private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value) + + // —— 每一项的措辞都收在这里,测试锚的也是这几句 —— + + /** 本端解码口径。**限定语不许省**:省了就成了对文件真实编码的断言(见本对象 KDoc)。 */ + const val DECODED_AS_UTF8: String = "UTF-8(本端解码口径)" + + /** 服务端 `chars` 的口径:那是**注入上下文**的字数,不是这份文件的字数。 */ + const val SERVER_CHARS_SCOPE: String = "(服务端注入口径)" + + /** 本端自己数出来的字数口径:只数了预览内容(截断时就不是全文)。 */ + const val LOCAL_CHARS_SCOPE: String = "(本端预览内容)" + + /** 服务端的 `truncated`:注入被预算截断,**正文照样进了上下文**(与「读不到」是两件事)。 */ + const val TRUNCATED_FOR_CONTEXT: String = "正文被截断(进上下文的部分)" + + /** 拍摄日期前缀:一个光秃秃的日期在这一行里会被当成「文件修改时间」之类的东西。 */ + const val CAPTURED_AT_PREFIX: String = "拍摄于 " + + /** 字节数拿不到时的占位:交给 [DocumentChips.sizeLabel],它会回空串(于是整项不出现)。 */ + const val UNKNOWN_BYTES: Long = -1L + + private const val MIN_YEAR = 1 + + /** EXIF 规范里的日期时间形状;只认这一种,见 [formatExifDateTime]。 */ + private val EXIF_DATE_TIME = + Regex("""(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})""") +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt index 51c602c..797035c 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt @@ -22,7 +22,9 @@ import androidx.compose.ui.window.Dialog import top.yeij.cyrene.data.repository.CachedDocument import top.yeij.cyrene.domain.chat.DocumentPreview import top.yeij.cyrene.domain.chat.DocumentPreviewPlan +import top.yeij.cyrene.domain.chat.DocumentSource import top.yeij.cyrene.domain.chat.DocumentTarget +import top.yeij.cyrene.domain.chat.PreviewMetadata import top.yeij.cyrene.domain.chat.PreviewSlice import top.yeij.cyrene.ui.common.PreferenceCard import top.yukonga.miuix.kmp.basic.BasicComponent @@ -85,6 +87,15 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false * [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默 * 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。 * + * ## 正文下方那一行元数据 + * 正文(非文本时是那句人话)**下方**还有一行灰色小字,写的是「这份文件是什么」的格式事实: + * 文本给行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记,非文本给服务端嗅探所得的 mime。 + * 组装全在纯函数 [PreviewMetadata] 里(**「为什么某一项不显示」也写在那里**),这里只取证据、画一行。 + * + * 它与上方那枚芯片刻意**不重复**:体积与服务端状态芯片上已经写着(同一个数字、同一个词,再写 + * 一遍只是噪音);字符数虽然也在芯片上,但芯片那个「8,000 字」没说清是谁的口径——本行补上 + * 「(服务端注入口径)」,这才不算一句会把用户带偏的话。 + * * @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。 * @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。 * @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败); @@ -135,22 +146,25 @@ fun DocumentPreviewDialog( TextButton("重试", onClick = onRetry) } - is DocumentPreviewState.Ready -> when (plan) { - DocumentPreviewPlan.Text -> { - state.slice.notice?.let { text -> - Text( - text = text, - style = MiuixTheme.textStyles.footnote2, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - modifier = Modifier.padding(bottom = 8.dp), - ) + is DocumentPreviewState.Ready -> { + when (plan) { + DocumentPreviewPlan.Text -> { + state.slice.notice?.let { text -> + Text( + text = text, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + PreviewBody(state.slice) } - PreviewBody(state.slice) - } - // 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用), - // 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。 - is DocumentPreviewPlan.NotText -> PreviewHint(plan.message) + // 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用), + // 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。 + is DocumentPreviewPlan.NotText -> PreviewHint(plan.message) + } + PreviewMetadataLine(state = state, plan = plan, target = target) } } @@ -199,6 +213,50 @@ fun DocumentPreviewDialog( } } +/** + * 正文**下方**那一行灰色小字:关于这份文件的格式元数据([PreviewMetadata] 的产物)。 + * + * 组装全在纯函数里(逐档穷举单测),这里只做两件事:**取证据**、**画一行**。 + * 取证据的两处来源都不许在 UI 层临场发挥: + * - 服务端那两件事实(注入字数 / 注入是否被截断)从[芯片模型][DocumentChipModel]里取—— + * 它就是 DTO 那几字段的唯一换算落点([DocumentChips.of]); + * - 嗅探所得的 mime 只从 [DocumentSource.Server.mime] 取。本地待发附件**没有**这个结论 + * (那时还没上传过),所以它这一项缺席——**不拿扩展名编一个**(`DocumentTarget.openType()` + * 对本地来源回的是 `application/octet-stream` 这个兜底值,拿它当「类型」会是本端编的话)。 + * + * 拼出来是空串时**整行不画**(一项证据都没有就没有这一行),绝不留一个空行或一排「未知」。 + */ +@Composable +private fun PreviewMetadataLine( + state: DocumentPreviewState.Ready, + plan: DocumentPreviewPlan, + target: DocumentTarget, +) { + val items = remember(state.slice, plan, target) { + when (plan) { + // 文本:行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记。 + // `truncatedLabel` 非空 ⟺ DTO 的 `truncated == true`(见 DocumentChipModel)。 + DocumentPreviewPlan.Text -> PreviewMetadata.textItems( + slice = state.slice, + serverCharsLabel = target.chips.charsLabel, + contextTruncated = target.chips.truncatedLabel != null, + ) + + // 非文本:只有服务端嗅探所得的 mime(体积与状态已在上面那枚芯片里)。 + is DocumentPreviewPlan.NotText -> + PreviewMetadata.fileItems((target.source as? DocumentSource.Server)?.mime) + } + } + val line = PreviewMetadata.join(items) + if (line.isEmpty()) return + Spacer(Modifier.height(10.dp)) + Text( + text = line, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) +} + /** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */ @Composable private fun PreviewBody(slice: PreviewSlice) { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImagePreviewDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImagePreviewDialog.kt index 8a38218..2ddf76f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImagePreviewDialog.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ImagePreviewDialog.kt @@ -54,6 +54,7 @@ import top.yeij.cyrene.domain.chat.ImageOpenTypes import top.yeij.cyrene.domain.chat.ImageSize import top.yeij.cyrene.domain.chat.ImageSource import top.yeij.cyrene.domain.chat.ImageZoom +import top.yeij.cyrene.domain.chat.PreviewMetadata import top.yeij.cyrene.domain.chat.ZoomPan import top.yeij.cyrene.domain.chat.imageLoadFailureMessage import top.yeij.cyrene.ui.common.PreferenceCard @@ -145,6 +146,28 @@ fun ImagePreviewDialog( ) } + // 预览内容下方那一行元数据要用的事实:像素与 EXIF 拍摄日期都得先读字节,所以放到 IO 上算。 + // 键是**那一份缓存**:换一张图(或重试拿回新的字节)才重算,重组不会。 + val facts by produceState( + initialValue = PreviewImageFacts(), + key1 = (state as? ImagePreviewState.Ready)?.cached, + ) { + val cached = (state as? ImagePreviewState.Ready)?.cached ?: return@produceState + value = withContext(Dispatchers.IO) { readPreviewImageFacts(cached.bytes) } + } + val ready = state as? ImagePreviewState.Ready + val metadata = PreviewMetadata.join( + PreviewMetadata.imageItems( + // 「图片通配」不是格式:嗅探与平台声明都判不出来时这一项就缺席, + // **不拿 `image/*` 冒充一个答案**。 + mime = ready?.openType?.takeIf { it != ImageOpenTypes.ANY_IMAGE }, + width = facts.width, + height = facts.height, + sizeBytes = ready?.cached?.bytes?.size?.toLong() ?: PreviewMetadata.UNKNOWN_BYTES, + capturedAt = facts.capturedAt, + ), + ) + // 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节** // (不是重新编码过的位图,JPEG 存下来还是 JPEG)。 val createDocument = rememberLauncherForActivityResult( @@ -237,7 +260,8 @@ fun ImagePreviewDialog( } ExitPane( - ready = state as? ImagePreviewState.Ready, + ready = ready, + metadata = metadata, notice = notice, note = nameNoteFor(source), onOpen = { openElsewhere() }, @@ -344,12 +368,16 @@ private fun ZoomableImage(file: File, onUndecodable: () -> Unit) { /** * 底部那一列出口(与附件弹层同一形态)。 * + * 最上面那一行是**预览内容下方**的格式元数据([PreviewMetadata] 的产物,可能是空串—— + * 那就整行不画,绝不留一个空行或一排「未知」);再下面是 [notice](刚才那个动作的结果)。 + * * 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**: * 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。 */ @Composable private fun ExitPane( ready: ImagePreviewState.Ready?, + metadata: String, notice: DocumentPreviewNotice?, note: String, onOpen: () -> Unit, @@ -360,6 +388,14 @@ private fun ExitPane( .fillMaxWidth() .padding(top = 10.dp, bottom = 12.dp), ) { + if (metadata.isNotEmpty()) { + Text( + text = metadata, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp), + ) + } notice?.let { line -> Text( text = line.message, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/PreviewImageFacts.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/PreviewImageFacts.kt new file mode 100644 index 0000000..7d0f774 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/PreviewImageFacts.kt @@ -0,0 +1,74 @@ +package top.yeij.cyrene.ui.chat + +import android.graphics.BitmapFactory +import android.media.ExifInterface +import java.io.ByteArrayInputStream + +/** + * 从本端持有的那份字节里**现读**出来的图片事实:像素与拍摄日期。读不到的就是 `null`。 + * + * ## 为什么这一层不在 `domain/` 里 + * [PreviewMetadata][top.yeij.cyrene.domain.chat.PreviewMetadata] 是纯函数(JVM 单测里逐档穷举), + * 而这里调的两个都是 Android 平台类(`android.graphics.BitmapFactory` / `android.media.ExifInterface`), + * 在 JVM 单测里是**抛异常的 stub**。所以切口落在这里:读平台 → 得到两个可能为 `null` 的数 → + * 交给纯函数组装成那一行。判断「显示什么」的逻辑一个字都不在平台类里,测试因此能覆盖全部档位。 + * + * ## 分辨率为什么用 `inJustDecodeBounds` + * 为了一行小字把 20 MiB 的大图解成位图是不可接受的(内存 + 主线程耗时)。`inJustDecodeBounds` + * 只读文件头,`outWidth` / `outHeight` 就是答案,`decodeByteArray` 返回 `null` 是有意的。 + * + * ## EXIF 用的是**系统 API**,不引新依赖 + * `android.media.ExifInterface`(`InputStream` 构造自 API 24 起可用,本工程 `minSdk = 33`)。 + * 它认不出的容器(PNG / GIF / WebP / BMP 这些没有标准 EXIF 的格式)只会让 `getAttribute` 回 + * `null`——**那正是「拿不到就整项不缺席」要的答案**,不是错误。⚠️ 它**只读 JPEG 与 RAW**, + * 所以「所有图片都有拍摄日期」是错的预期,能报出来的基本只有相机/相册里的 JPEG。 + * + * ## 日期为什么不在这里格式化 + * EXIF 的 `DateTimeOriginal` 是**没有时区**的墙钟字符串(`yyyy:MM:dd HH:mm:ss`),本层**原样** + * 交给 [PreviewMetadata.formatExifDateTime][top.yeij.cyrene.domain.chat.PreviewMetadata.formatExifDateTime] + * 去严格校验与格式化——那一步是纯函数,能逐档单测,也能把「不做时区换算」这条取舍写在明面上。 + */ +data class PreviewImageFacts( + val width: Int? = null, + val height: Int? = null, + /** EXIF `DateTimeOriginal` 的原始字符串;缺席即 `null`。 */ + val capturedAt: String? = null, +) + +/** + * 读出这两个事实。**任何一步失败都只是少一项**(`runCatching`):这一行是锦上添花, + * 它自己绝不许成为「图片打不开」的原因。 + * + * 只从**已经在内存里**的那份字节读([top.yeij.cyrene.data.repository.CachedDocument.bytes], + * 预览与两条出口用的都是它),不再碰盘、不再联网。 + */ +fun readPreviewImageFacts(bytes: ByteArray): PreviewImageFacts { + val bounds = readBounds(bytes) + return PreviewImageFacts( + width = bounds?.first, + height = bounds?.second, + capturedAt = readCapturedAt(bytes), + ) +} + +/** + * 文件头里的像素:任一侧 ≤ 0(解不出来 / 不是图片)就是 `null`。 + * + * 与 `ZoomableImage` 里 Coil 报上来的那个尺寸是两回事:那个是**解码后**的位图尺寸,要等图片 + * 真的解出来才有;这里是**打开弹层时立刻**就有(只看文件头)。两者一致是常态,不一致时以这里 + * 为准——它描述的是文件本身。 + */ +private fun readBounds(bytes: ByteArray): Pair? = runCatching { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + val width = options.outWidth + val height = options.outHeight + if (width > 0 && height > 0) width to height else null +}.getOrNull() + +/** EXIF 的 `DateTimeOriginal`;没有这个标签 / 不是 EXIF / 读不动,一律 `null`。 */ +private fun readCapturedAt(bytes: ByteArray): String? = runCatching { + ByteArrayInputStream(bytes).use { stream -> + ExifInterface(stream).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + } +}.getOrNull()?.trim()?.takeIf { it.isNotEmpty() } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/PreviewMetadataTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/PreviewMetadataTest.kt new file mode 100644 index 0000000..bebba09 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/PreviewMetadataTest.kt @@ -0,0 +1,358 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.DocumentRefDto + +/** + * 预览内容下方那一行元数据的组装(逐档穷举)。 + * + * 这一块的价值全在「**不出现**」上:一屏小字里凭空多出一项(一个假的编码、一个猜的日期、 + * 一个 `--` 占位符)比少一项糟得多,而且它看起来完全正常。所以这里的断言分两类: + * 1. **有证据时逐字钉住那一项**(连限定语一起钉:`UTF-8(本端解码口径)` 少了括号里的四个字 + * 就从「本端口径」变成了对文件编码的断言); + * 2. **没证据时断言它整项缺席**(`assertFalse(line.contains(...))`),而不只是断言列表长度。 + * + * 另有一档是「行数」本身:空文本 / 只有换行 / `\r\n` / 结尾没有换行这四种边界都在 + * [textItems] 里重算过一次(不是照抄 `PreviewSlice.shownLines`),所以要从**字节**跑起, + * 走 [DocumentPreview.slice] 这条真实的路。 + */ +class PreviewMetadataTest { + + // —— 行数:从预览内容算,四个边界 —— + + @Test + fun `empty text is zero lines`() { + val items = textItems(DocumentPreview.slice(ByteArray(0))) + + assertEquals("0 行", items.first()) + } + + @Test + fun `a single newline is one line not two`() { + val items = textItems(DocumentPreview.slice("\n".toByteArray())) + + assertEquals("1 行", items.first()) + } + + @Test + fun `carriage returns do not add lines`() { + // `\r\n` 只数 `\n`:Windows 换行不该被算成两行,`\r` 单独出现也不是换行。 + val items = textItems(DocumentPreview.slice("a\r\nb\r\nc".toByteArray())) + + assertEquals("3 行", items.first()) + } + + @Test + fun `a missing trailing newline still counts the last line`() { + val items = textItems(DocumentPreview.slice("a\nb".toByteArray())) + + assertEquals("2 行", items.first()) + } + + @Test + fun `a trailing newline does not count an extra line`() { + val items = textItems(DocumentPreview.slice("a\nb\n".toByteArray())) + + assertEquals("2 行", items.first()) + } + + @Test + fun `a preview cut short reports both the shown and the total line count`() { + // 5000 行上限正好等于上限时不截断;这里给 5001 行,确认「显示 / 共」两个数都在。 + val bytes = lines(DocumentPreviewLimits.MAX_LINES + 1).toByteArray() + val slice = DocumentPreview.slice(bytes) + + assertEquals(PreviewTruncation.Lines, slice.truncatedBy) + assertEquals( + "显示 ${grouped(DocumentPreviewLimits.MAX_LINES)} 行 / 共 ${grouped(DocumentPreviewLimits.MAX_LINES + 1)} 行", + textItems(slice).first(), + ) + } + + @Test + fun `a complete preview says one number only`() { + val items = textItems(DocumentPreview.slice("a\nb\nc".toByteArray())) + + assertFalse(items.first(), items.first().contains("共")) + assertFalse(items.first(), items.first().contains("显示")) + } + + // —— 字符数:两个口径分得开 —— + + @Test + fun `the server character count is shown with its scope`() { + val items = textItems(sliceOf("hello"), serverCharsLabel = "8,000 字") + + assertEquals("8,000 字(服务端注入口径)", items[1]) + } + + @Test + fun `without a server count the local one is used and says so`() { + val items = textItems(sliceOf("你好"), serverCharsLabel = null) + + assertEquals("2 字(本端预览内容)", items[1]) + } + + @Test + fun `the local count is code points not utf16 units`() { + // 一个 emoji 在 UTF-16 里是两个单元(代理对);「字符数」该数成 1。 + val items = textItems(sliceOf("😀"), serverCharsLabel = null) + + assertEquals("1 字(本端预览内容)", items[1]) + } + + @Test + fun `a server count of zero is still a report and is shown`() { + // `chars: 0` 是「服务端报了 0」,与「服务端没报」是两件事——不许被归一成后者。 + val items = textItems(sliceOf("abc"), serverCharsLabel = DocumentChips.charsLabel(0)) + + assertEquals("0 字(服务端注入口径)", items[1]) + } + + // —— 解码口径 —— + + @Test + fun `the decoding scope is always stated and never as a bare utf8`() { + val items = textItems(sliceOf("x"), serverCharsLabel = null) + + assertTrue(items.toString(), items.contains("UTF-8(本端解码口径)")) + assertFalse("裸写 UTF-8 会被读成「这份文件是 UTF-8 编码的」", items.contains("UTF-8")) + } + + // —— 服务端的 truncated:两态 —— + + @Test + fun `the server truncation flag is spelled out when set`() { + val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = true) + + assertEquals("正文被截断(进上下文的部分)", items.last()) + } + + @Test + fun `nothing is said about truncation when the server did not report it`() { + val items = textItems(sliceOf("x"), serverCharsLabel = null, contextTruncated = false) + + assertFalse(items.toString(), items.any { it.contains("截断") }) + assertEquals(3, items.size) + } + + // —— 非文本档:只有嗅探所得的 mime —— + + @Test + fun `a binary file shows the sniffed mime`() { + assertEquals(listOf("application/pdf"), PreviewMetadata.fileItems("application/pdf")) + } + + @Test + fun `a local draft without a server mime shows nothing at all`() { + assertEquals(emptyList(), PreviewMetadata.fileItems(null)) + assertEquals(emptyList(), PreviewMetadata.fileItems("")) + assertEquals(emptyList(), PreviewMetadata.fileItems(" ")) + } + + @Test + fun `a binary file line never invents a placeholder`() { + val line = PreviewMetadata.join(PreviewMetadata.fileItems(null)) + + assertEquals("", line) + assertFalse(line.contains("未知")) + assertFalse(line.contains("--")) + } + + // —— 图片:四项各自的可有可无 —— + + @Test + fun `a full image line carries format, size in pixels, bytes and the shot date`() { + val items = PreviewMetadata.imageItems( + mime = "image/jpeg", + width = 4032, + height = 3024, + sizeBytes = 2_411_724, + capturedAt = "2019:07:04 20:31:05", + ) + + assertEquals( + listOf("image/jpeg", "4032 × 3024", "2.3 MB", "拍摄于 2019年7月4日 20:31"), + items, + ) + } + + @Test + fun `an image with no exif simply has no date`() { + val items = PreviewMetadata.imageItems( + mime = "image/png", + width = 100, + height = 50, + sizeBytes = 2048, + capturedAt = null, + ) + + assertEquals(listOf("image/png", "100 × 50", "2.0 KB"), items) + } + + @Test + fun `an undecodable size is left out rather than guessed`() { + val items = PreviewMetadata.imageItems(mime = "image/gif", width = null, height = null) + + assertEquals(listOf("image/gif"), items) + } + + @Test + fun `a half known size is not reported`() { + // `inJustDecodeBounds` 只会两侧都给或两侧都不给;真出现半边时宁可不报。 + val items = PreviewMetadata.imageItems(mime = null, width = 800, height = null) + + assertEquals(emptyList(), items) + } + + @Test + fun `non positive sizes are not reported`() { + val items = PreviewMetadata.imageItems(mime = null, width = -1, height = 0) + + assertEquals(emptyList(), items) + } + + @Test + fun `an unknown byte count leaves the size out`() { + val items = PreviewMetadata.imageItems( + mime = null, + sizeBytes = PreviewMetadata.UNKNOWN_BYTES, + ) + + assertEquals(emptyList(), items) + } + + @Test + fun `the image wildcard is not a format and must not be shown`() { + // 调用方在这一档传的是 null(`image/` 通配由弹层滤掉);纯函数这层再确认一遍空值不产出项。 + assertEquals(emptyList(), PreviewMetadata.imageItems(mime = null)) + assertEquals(emptyList(), PreviewMetadata.imageItems(mime = " ")) + } + + // —— EXIF 日期:严格到不近人情 —— + + @Test + fun `a canonical exif timestamp is formatted`() { + assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05")) + } + + @Test + fun `the time is zero padded and the date is not`() { + assertEquals("2001年1月2日 03:04", PreviewMetadata.formatExifDateTime("2001:01:02 03:04:09")) + } + + @Test + fun `surrounding whitespace is tolerated`() { + assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime(" 2019:07:04 20:31:05 ")) + } + + @Test + fun `the all zero placeholder means unknown and is rejected`() { + assertNull(PreviewMetadata.formatExifDateTime("0000:00:00 00:00:00")) + } + + @Test + fun `out of range components are rejected`() { + assertNull(PreviewMetadata.formatExifDateTime("2019:13:04 20:31:05")) + assertNull(PreviewMetadata.formatExifDateTime("2019:07:32 20:31:05")) + assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 24:31:05")) + assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:61:05")) + } + + @Test + fun `a leap second is a valid exif value`() { + assertEquals("2016年12月31日 23:59", PreviewMetadata.formatExifDateTime("2016:12:31 23:59:60")) + } + + @Test + fun `malformed values are rejected rather than repaired`() { + assertNull("另一个分隔符的形态不当成 EXIF 认", PreviewMetadata.formatExifDateTime("2019-07-04 20:31:05")) + assertNull(PreviewMetadata.formatExifDateTime("2019:07:04T20:31:05")) + assertNull("缺秒", PreviewMetadata.formatExifDateTime("2019:07:04 20:31")) + assertNull(PreviewMetadata.formatExifDateTime("2019:7:4 20:31:05")) + assertNull(PreviewMetadata.formatExifDateTime("hello")) + assertNull(PreviewMetadata.formatExifDateTime("")) + assertNull(PreviewMetadata.formatExifDateTime(null)) + assertNull(PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05+08:00")) + } + + @Test + fun `an invalid exif date leaves the whole item out`() { + val items = PreviewMetadata.imageItems( + mime = "image/jpeg", + capturedAt = "0000:00:00 00:00:00", + ) + + assertEquals(listOf("image/jpeg"), items) + } + + @Test + fun `exif time is read as local wall clock with no timezone conversion`() { + // 同一串数字在任何时区下都得原样出来——EXIF 里根本没有偏移量字段,换算就是凭空错几小时。 + assertEquals("2019年7月4日 20:31", PreviewMetadata.formatExifDateTime("2019:07:04 20:31:05")) + } + + // —— 拼接 —— + + @Test + fun `items are joined with a middle dot`() { + assertEquals("a · b · c", PreviewMetadata.join(listOf("a", "b", "c"))) + assertEquals("", PreviewMetadata.join(emptyList())) + } + + // —— 走一遍真实链路:DTO → 芯片 → 这一行 —— + + @Test + fun `the chip model feeds the two server facts without re-formatting them`() { + val chip = DocumentChips.of( + DocumentRefDto( + name = "0123456789abcdef0123456789abcdef.txt", + mime = "text/plain", + size = 12_345, + chars = 8_000, + truncated = true, + status = DocumentStatus.EXTRACTED, + ), + ) + val items = PreviewMetadata.textItems( + slice = DocumentPreview.slice("a\nb".toByteArray()), + serverCharsLabel = chip.charsLabel, + contextTruncated = chip.truncatedLabel != null, + ) + + assertEquals( + listOf("2 行", "8,000 字(服务端注入口径)", "UTF-8(本端解码口径)", "正文被截断(进上下文的部分)"), + items, + ) + } + + @Test + fun `a draft attachment only has the local half`() { + val chip = DocumentChips.of( + DraftDocument(uri = "content://x/y.txt", displayName = "notes.txt", sizeBytes = 1024), + ) + val items = PreviewMetadata.textItems( + slice = DocumentPreview.slice("hello\n".toByteArray()), + serverCharsLabel = chip.charsLabel, + contextTruncated = chip.truncatedLabel != null, + ) + + assertEquals(listOf("1 行", "6 字(本端预览内容)", "UTF-8(本端解码口径)"), items) + } + + private fun textItems( + slice: PreviewSlice, + serverCharsLabel: String? = null, + contextTruncated: Boolean = false, + ) = PreviewMetadata.textItems(slice, serverCharsLabel, contextTruncated) + + private fun sliceOf(text: String): PreviewSlice = DocumentPreview.slice(text.toByteArray()) + + private fun lines(count: Int): String = (1..count).joinToString("\n") { "line $it" } + "\n" + + private fun grouped(value: Int): String = String.format(java.util.Locale.ROOT, "%,d", value) +} diff --git a/devtools/fake-backend/README.md b/devtools/fake-backend/README.md index 5c16329..c751d96 100644 --- a/devtools/fake-backend/README.md +++ b/devtools/fake-backend/README.md @@ -357,6 +357,38 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&dur > 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段, > 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。 +### 8.2 图片夹具(灌一条带图的历史消息:`image_file`) + +**它解决什么问题**:要验「点开一张图 → 全屏预览」,手里就得有一条**带 `image` 键**的历史消息; +而预览里那些从**字节**现读的东西(分辨率、EXIF 拍摄日期、格式)必须来自**原样的字节**。 +相册选择器(`PickVisualMedia`)会把选中项**转码**一遍,转码后的副本**没有 EXIF**(实测:一张带 +`DateTimeOriginal` 的 41 KB JPEG,经它拿到手变成 23 KB 且不含 APP1)——用它验不了拍摄日期这一项。 + +本工具不代取外链、也不起静态文件服务,所以给的是 `data:` URI:客户端 +(`ImageSource.Inline` → `ImageDataUri.bytes`)解出来的就是文件**逐字节相同**的那份内容。 + +`POST /__control/messages` 多一个参数(其余不变): + +| 参数 | 取值 | 说明 | +|---|---|---| +| `image_file` | 本地图片路径,扩展名限 `.jpg` / `.jpeg` / `.png` / `.gif` / `.webp` / `.bmp` | 读成 base64 `data:` URI 打进这条消息的 `image` 键。扩展名不在白名单 ⇒ `400`;文件为空 ⇒ `400`(**不**退回 `application/octet-stream`:错的类型会让场景看起来和实际不是一回事) | + +```bash +B=http://127.0.0.1:18080 + +# 灌一条带图的消息(role=user 才长得像「自己发的一张图」) +curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=image%20probe&image_file=D:/tmp/w70/with_exif.jpg" +``` + +灌完之后**重进聊天页**,那条消息就是一个图片气泡;点它即进 `ImagePreviewDialog`。 + +> ⚠️ `data:` 只适合**小图**:整张图的 base64 会随这条消息进每一次历史响应(本工具没有缩略图链路)。 +> 40 KB 的图没问题,别拿 20 MB 的来灌。 +> +> ⚠️ 若 App 本机已经有一份**本地历史**(此前连过真后端),新灌的消息可能与它并存 / 不被立刻看到: +> 要让「服务端历史」成为唯一真相,得清 App 数据重登(`adb shell pm clear top.yeij.cyrene`)—— +> 这一步会连登录态一起清掉,动手前先想清楚。 + `/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`): | 端点 | 备注 | @@ -380,7 +412,7 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&dur | 端点 | 作用 | |---|---| -| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=` | 免费灌 N 条(`text` 支持 `{n}` 占位);`voice=` 灌一条**语音消息**(见 8.1) | +| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=&image_file=` | 免费灌 N 条(`text` 支持 `{n}` 占位);`voice=` 灌一条**语音消息**(见 8.1);`image_file=` 灌一条**带图的消息**(见 8.2) | | `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 | | `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` | | `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) | diff --git a/devtools/fake-backend/fake_backend.py b/devtools/fake-backend/fake_backend.py index 2dec4d4..85f1bab 100644 --- a/devtools/fake-backend/fake_backend.py +++ b/devtools/fake-backend/fake_backend.py @@ -187,6 +187,42 @@ def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS): VOICE_WAV = make_voice_wav() +IMAGE_MIME_BY_EXTENSION = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +} + + +def image_fixture(path: str) -> str: + """本地图片文件 → base64 `data:` URI(`/__control/messages` 的 `image_file` 用它灌一条带图的消息)。 + + mime 按扩展名给,白名单与客户端 upload / 附件那一套同源(png / jpeg / gif / webp / bmp); + 认不出的扩展名**直接报错**而不是编一个 `application/octet-stream`——客户端在字节那一步还会 + 自己嗅探(`ImageFormats.sniff`),这里给错只会让场景看起来和实际不是一回事。 + + 为什么给 `data:` 而不是 URL:本工具**不代取外链**、也不起静态文件服务。`data:` 让客户端 + (`ImageSource.Inline` → `ImageDataUri.bytes`)拿到的是**原样的字节**——预览里那些从字节里 + 现读的东西(分辨率、EXIF 拍摄日期)因此与源文件逐字节一致,不会被中间商转码掉。 + """ + extension = os.path.splitext(path)[1].lower() + mime = IMAGE_MIME_BY_EXTENSION.get(extension) + if mime is None: + raise ValueError( + "unknown image extension {!r} (expected one of {})".format( + extension, ", ".join(sorted(IMAGE_MIME_BY_EXTENSION)) + ) + ) + with open(path, "rb") as handle: + raw = handle.read() + if not raw: + raise ValueError("empty image file: {}".format(path)) + return "data:{};base64,{}".format(mime, base64.b64encode(raw).decode("ascii")) + + def iso_now() -> str: return datetime.now().astimezone().isoformat(timespec="milliseconds") @@ -350,12 +386,16 @@ class Store: created_at=None, message_id=None, voice=None, + image=None, ) -> dict: """HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。 [message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。 [voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)—— 只有控制端点能造出来,真后端的入站语音副本链本工具不模拟。 + [image] 非空 = 这条带一张图(契约 §1.1:base64 `data:` URI 或 http(s) URL,两种形状客户端 + 都认,判据见 `ImageSource.of`)。本工具**不代取 URL**,所以只由控制端点灌 `data:` URI + (见 `/__control/messages` 的 `image_file`)——与 `voice` 同一性质:造场景用,不是后端行为。 """ with self.lock: session = self.sessions.get(session_id) @@ -385,6 +425,8 @@ class Store: message["documents"] = documents if voice: message["voice"] = voice + if image: + message["image"] = image session.messages.append(message) return message @@ -1511,11 +1553,17 @@ class Handler(BaseHTTPRequestHandler): template = self._param(query, "text", None) platform = self._param(query, "platform", None) voice_kind = self._param(query, "voice", None) + image_file = self._param(query, "image_file", None) try: voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS)) except KeyError: self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none") return + try: + image = image_fixture(image_file) if image_file else None + except (OSError, ValueError) as error: + self._fail(400, "bad_request", "image_file unusable: {}".format(error)) + return if session_id not in self.store.sessions: self._fail(404, "session_not_found", "unknown session {}".format(session_id)) return @@ -1528,6 +1576,7 @@ class Handler(BaseHTTPRequestHandler): content, platform=platform, voice=voice, + image=image, ) first_id = first_id or message["id"] last_id = message["id"]