feat(preview): 预览内容下方加一行格式元数据(行数 / 编码 / 分辨率 / EXIF 拍摄日期)

主人要的是「在预览界面下方拿小字放一点简要的格式元数据」。组装全收进一个纯函数
(`PreviewMetadata`,36 档穷举单测),两个预览弹层只负责取证据 + 画一行
(`footnote2` + `onSurfaceVariantSummary`,与项目里 footer / 角标同一套)。

- **文本 / Markdown**:行数(从正文重算;本端被截断时报「显示 N 行 / 共 M 行」,只说
  「显示 N 行」会让人以为文件就这么长)· 字符数(服务端 `chars` 在时报它并写明
  「服务端注入口径」,否则报本端码点数并写明「本端预览内容」——两个数不是一回事)·
  `UTF-8(本端解码口径)`(**只敢写本端口径**:客户端拿不到文件的真实编码,裸写一个
  UTF-8 等于替文件下结论)· 服务端 `truncated` 时「正文被截断(进上下文的部分)」。
- **图片**:格式(复用 `ImageOpenTypes.openType`,退化成「图片通配」时**整项不报**)·
  分辨率(`BitmapFactory.Options(inJustDecodeBounds = true)`,不整图解码)· 体积 ·
  「拍摄于 …」(`android.media.ExifInterface(InputStream)`,系统 API,**不引新依赖**;
  日期严格校验,`0000:00:00 00:00:00` 这类占位符判非法 ⇒ 整项缺席)。
- **非文本(PDF 这类)**:只有服务端字节嗅探所得的 mime。体积 / 服务端状态 / 「读不到」的
  原因原话各自已经在芯片行与正文区那句人话里,**不在这里重复**——宁可少一项,不要一句废话。
- **拿不到的项一律缺席**:不留占位符、不写「未知」、不写 `--`;一项证据都没有就整行不画。

`devtools/fake-backend` 补一个 `image_file` 灌图参数:服务端库里没有任何带 `image` 键的
历史消息可灌,而相册选择器会把选中项**转码**、把 EXIF 丢掉(实测:41 KB 带 EXIF 的 JPEG
经它拿到手变成 23 KB 且没有 APP1),没有它「拍摄日期」这一项在设备上根本验不到。

验收:`assembleDebug` 通过;`testDebugUnitTest` **127 suites / 1600 tests / 0 failures /
0 errors**(基线 126 / 1564,新增 36 条);真机(emulator-5554, API 37)实测:文本那条看到
「5 行 · 70 字(本端预览内容) · UTF-8(本端解码口径)」,图片那条看到
「image/jpeg · 1280 × 720 · 23.0 KB」(该源图不含 EXIF,所以「拍摄于」这一项按设计缺席)。
This commit is contained in:
AskaEth committed 2026-10-07 19:07:00 +08:00
1 parent 11602c0138
commit e962489520
7 files changed
+830 -16

No files matched your search

@@ -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>): 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<String> = 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<String> =
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<String> = 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})""")
}
@@ -22,7 +22,9 @@ import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.data.repository.CachedDocument import top.yeij.cyrene.data.repository.CachedDocument
import top.yeij.cyrene.domain.chat.DocumentPreview import top.yeij.cyrene.domain.chat.DocumentPreview
import top.yeij.cyrene.domain.chat.DocumentPreviewPlan 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.DocumentTarget
import top.yeij.cyrene.domain.chat.PreviewMetadata
import top.yeij.cyrene.domain.chat.PreviewSlice import top.yeij.cyrene.domain.chat.PreviewSlice
import top.yeij.cyrene.ui.common.PreferenceCard import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.BasicComponent
@@ -85,6 +87,15 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false
* [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默 * [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
* 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。 * 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
* *
* ## 正文下方那一行元数据
* 正文(非文本时是那句人话)**下方**还有一行灰色小字,写的是「这份文件是什么」的格式事实:
* 文本给行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记,非文本给服务端嗅探所得的 mime。
* 组装全在纯函数 [PreviewMetadata] 里(**「为什么某一项不显示」也写在那里**),这里只取证据、画一行。
*
* 它与上方那枚芯片刻意**不重复**:体积与服务端状态芯片上已经写着(同一个数字、同一个词,再写
* 一遍只是噪音);字符数虽然也在芯片上,但芯片那个「8,000 字」没说清是谁的口径——本行补上
* 「(服务端注入口径)」,这才不算一句会把用户带偏的话。
*
* @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。 * @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
* @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。 * @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。
* @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败); * @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
@@ -135,22 +146,25 @@ fun DocumentPreviewDialog(
TextButton("重试", onClick = onRetry) TextButton("重试", onClick = onRetry)
} }
is DocumentPreviewState.Ready -> when (plan) { is DocumentPreviewState.Ready -> {
DocumentPreviewPlan.Text -> { when (plan) {
state.slice.notice?.let { text -> DocumentPreviewPlan.Text -> {
Text( state.slice.notice?.let { text ->
text = text, Text(
style = MiuixTheme.textStyles.footnote2, text = text,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary, style = MiuixTheme.textStyles.footnote2,
modifier = Modifier.padding(bottom = 8.dp), color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
) modifier = Modifier.padding(bottom = 8.dp),
)
}
PreviewBody(state.slice)
} }
PreviewBody(state.slice)
}
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用), // 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。 // 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message) 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] 保证。 */ /** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */
@Composable @Composable
private fun PreviewBody(slice: PreviewSlice) { private fun PreviewBody(slice: PreviewSlice) {
@@ -54,6 +54,7 @@ import top.yeij.cyrene.domain.chat.ImageOpenTypes
import top.yeij.cyrene.domain.chat.ImageSize import top.yeij.cyrene.domain.chat.ImageSize
import top.yeij.cyrene.domain.chat.ImageSource import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.ImageZoom 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.ZoomPan
import top.yeij.cyrene.domain.chat.imageLoadFailureMessage import top.yeij.cyrene.domain.chat.imageLoadFailureMessage
import top.yeij.cyrene.ui.common.PreferenceCard 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 恒 */*,落盘的是缓存里那一份**原始字节** // 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节**
// (不是重新编码过的位图,JPEG 存下来还是 JPEG)。 // (不是重新编码过的位图,JPEG 存下来还是 JPEG)。
val createDocument = rememberLauncherForActivityResult( val createDocument = rememberLauncherForActivityResult(
@@ -237,7 +260,8 @@ fun ImagePreviewDialog(
} }
ExitPane( ExitPane(
ready = state as? ImagePreviewState.Ready, ready = ready,
metadata = metadata,
notice = notice, notice = notice,
note = nameNoteFor(source), note = nameNoteFor(source),
onOpen = { openElsewhere() }, onOpen = { openElsewhere() },
@@ -344,12 +368,16 @@ private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
/** /**
* 底部那一列出口(与附件弹层同一形态)。 * 底部那一列出口(与附件弹层同一形态)。
* *
* 最上面那一行是**预览内容下方**的格式元数据([PreviewMetadata] 的产物,可能是空串——
* 那就整行不画,绝不留一个空行或一排「未知」);再下面是 [notice](刚才那个动作的结果)。
*
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**: * 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。 * 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
*/ */
@Composable @Composable
private fun ExitPane( private fun ExitPane(
ready: ImagePreviewState.Ready?, ready: ImagePreviewState.Ready?,
metadata: String,
notice: DocumentPreviewNotice?, notice: DocumentPreviewNotice?,
note: String, note: String,
onOpen: () -> Unit, onOpen: () -> Unit,
@@ -360,6 +388,14 @@ private fun ExitPane(
.fillMaxWidth() .fillMaxWidth()
.padding(top = 10.dp, bottom = 12.dp), .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 -> notice?.let { line ->
Text( Text(
text = line.message, text = line.message,
@@ -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<Int, Int>? = 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() }
@@ -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<String>(), PreviewMetadata.fileItems(null))
assertEquals(emptyList<String>(), PreviewMetadata.fileItems(""))
assertEquals(emptyList<String>(), 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<String>(), items)
}
@Test
fun `non positive sizes are not reported`() {
val items = PreviewMetadata.imageItems(mime = null, width = -1, height = 0)
assertEquals(emptyList<String>(), items)
}
@Test
fun `an unknown byte count leaves the size out`() {
val items = PreviewMetadata.imageItems(
mime = null,
sizeBytes = PreviewMetadata.UNKNOWN_BYTES,
)
assertEquals(emptyList<String>(), items)
}
@Test
fun `the image wildcard is not a format and must not be shown`() {
// 调用方在这一档传的是 null(`image/` 通配由弹层滤掉);纯函数这层再确认一遍空值不产出项。
assertEquals(emptyList<String>(), PreviewMetadata.imageItems(mime = null))
assertEquals(emptyList<String>(), 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)
}
+33 -1
View File
@@ -357,6 +357,38 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&dur
> 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段, > 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 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`): `/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 基),脚本里用来**精确**拿游标 | | `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` | | `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) | | `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
+49
View File
@@ -187,6 +187,42 @@ def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS):
VOICE_WAV = make_voice_wav() 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: def iso_now() -> str:
return datetime.now().astimezone().isoformat(timespec="milliseconds") return datetime.now().astimezone().isoformat(timespec="milliseconds")
@@ -350,12 +386,16 @@ class Store:
created_at=None, created_at=None,
message_id=None, message_id=None,
voice=None, voice=None,
image=None,
) -> dict: ) -> dict:
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。 """HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。 [message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
[voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)—— [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: with self.lock:
session = self.sessions.get(session_id) session = self.sessions.get(session_id)
@@ -385,6 +425,8 @@ class Store:
message["documents"] = documents message["documents"] = documents
if voice: if voice:
message["voice"] = voice message["voice"] = voice
if image:
message["image"] = image
session.messages.append(message) session.messages.append(message)
return message return message
@@ -1511,11 +1553,17 @@ class Handler(BaseHTTPRequestHandler):
template = self._param(query, "text", None) template = self._param(query, "text", None)
platform = self._param(query, "platform", None) platform = self._param(query, "platform", None)
voice_kind = self._param(query, "voice", None) voice_kind = self._param(query, "voice", None)
image_file = self._param(query, "image_file", None)
try: try:
voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS)) voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS))
except KeyError: except KeyError:
self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none") self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none")
return 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: if session_id not in self.store.sessions:
self._fail(404, "session_not_found", "unknown session {}".format(session_id)) self._fail(404, "session_not_found", "unknown session {}".format(session_id))
return return
@@ -1528,6 +1576,7 @@ class Handler(BaseHTTPRequestHandler):
content, content,
platform=platform, platform=platform,
voice=voice, voice=voice,
image=image,
) )
first_id = first_id or message["id"] first_id = first_id or message["id"]
last_id = message["id"] last_id = message["id"]