diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt index aa243f9..47788b0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt @@ -2,6 +2,8 @@ package top.yeij.cyrene.domain.admin import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import top.yeij.cyrene.domain.parse.ContentPart +import top.yeij.cyrene.domain.parse.parse import java.math.BigDecimal import java.math.RoundingMode import java.time.Instant @@ -119,3 +121,31 @@ fun triStateLabel(value: Boolean?): String = when (value) { /** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */ fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-" + +/** + * 后台思考 `conclusion` 的一段:段内按渲染顺序排列的片段(正文 / 动作 / 旁白)。 + * 段与段之间的间距由 UI 决定;段内片段顺序即原文顺序。 + */ +data class ConclusionParagraph(val parts: List) + +/** + * `conclusion` → 有序渲染模型,供 UI 逐段渲染。 + * + * 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `

`、 + * `` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]: + * 一个 `

` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。 + * 空段(`

` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。 + * 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。 + * `null` / 纯空白输入返回空列表。 + */ +fun conclusionParagraphs(raw: String?): List { + val text = raw?.takeIf { it.isNotBlank() } ?: return emptyList() + return parse(text).mapNotNull { bubble -> + val parts = buildList { + addAll(bubble.head) + if (bubble.body.isNotEmpty()) add(ContentPart.Text(bubble.body)) + addAll(bubble.tail) + } + parts.takeIf { list -> list.any { it.text.isNotBlank() } }?.let(::ConclusionParagraph) + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminBackthinkScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminBackthinkScreen.kt index 9f62118..1b9b8aa 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminBackthinkScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminBackthinkScreen.kt @@ -10,17 +10,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto import top.yeij.cyrene.domain.admin.AdminPage import top.yeij.cyrene.domain.admin.AdminRole +import top.yeij.cyrene.domain.admin.conclusionParagraphs import top.yeij.cyrene.domain.admin.formatEpochMillis import top.yeij.cyrene.domain.admin.orDash /** * 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。 * `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。 + * `conclusion` 是**与聊天消息同一套 Web 端 markup**的原文(真实样本里有连写的空 `

` 与 + * ``),因此不直接上屏:先经 [conclusionParagraphs] 解析成渲染模型,再逐段渲染。 */ @Composable fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) { @@ -54,14 +58,15 @@ fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () @Composable private fun BackthinkCard(record: AdminBackthinkItemDto) { - AdminTextCard( + val paragraphs = remember(record.conclusion) { conclusionParagraphs(record.conclusion) } + AdminRichTextCard( title = orDash(record.trigger), - body = backthinkBody(record), + paragraphs = paragraphs, + notice = backthinkNotice(record), trailing = if (record.at > 0) formatEpochMillis(record.at) else "-", ) } -private fun backthinkBody(record: AdminBackthinkItemDto): String = buildList { - record.conclusion?.takeIf { it.isNotBlank() }?.let { add(it) } - add("通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}") -}.joinToString("\n") +/** 结尾的推送状态行;与 `conclusion` 正文分开渲染,不参与 markup 解析。 */ +private fun backthinkNotice(record: AdminBackthinkItemDto): String = + "通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}" diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminCommon.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminCommon.kt index 6705afd..a715253 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminCommon.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminCommon.kt @@ -11,12 +11,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import top.yeij.cyrene.domain.admin.AdminFailure +import top.yeij.cyrene.domain.admin.ConclusionParagraph +import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.ui.common.PreferenceCard import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.HorizontalDivider @@ -195,6 +202,24 @@ fun AdminMetricCard(rows: List>) { } } +/** 卡片首行:左标题 + 右尾注(长尾注省略)。两种文本卡共用,外观一致。 */ +@Composable +private fun AdminCardHeader(title: String, trailing: String?, trailingColor: Color?) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f)) + if (trailing != null) { + Text( + text = trailing, + modifier = Modifier.widthIn(max = 160.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MiuixTheme.textStyles.footnote1, + color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } +} + /** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */ @Composable fun AdminTextCard( @@ -205,19 +230,7 @@ fun AdminTextCard( ) { PreferenceCard { Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f)) - if (trailing != null) { - Text( - text = trailing, - modifier = Modifier.widthIn(max = 160.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MiuixTheme.textStyles.footnote1, - color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary, - ) - } - } + AdminCardHeader(title, trailing, trailingColor) if (body.isNotBlank()) { Spacer(Modifier.height(4.dp)) Text( @@ -229,3 +242,65 @@ fun AdminTextCard( } } } + +/** + * 承载 **Web 端 markup 原文**的文本块:正文来自 [ConclusionParagraph] 渲染模型,逐段渲染。 + * 段与段之间用间距分隔(正文里不再出现拼接的 `\n`);段内片段按原文顺序同行排列, + * 正文用卡片正文色,动作/旁白(`Action` / `Narration`)按聊天页的约定渲染成**灰斜体** + * (见 `ui/chat/MessageBubble.kt` 的旁白行)。[notice] 是挂在段落下方的次要一行(灰、小字)。 + */ +@Composable +fun AdminRichTextCard( + title: String, + paragraphs: List, + notice: String? = null, + trailing: String? = null, + trailingColor: Color? = null, +) { + PreferenceCard { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + AdminCardHeader(title, trailing, trailingColor) + paragraphs.forEachIndexed { index, paragraph -> + if (index == 0) Spacer(Modifier.height(4.dp)) else Spacer(Modifier.height(8.dp)) + ParagraphLine(paragraph) + } + if (!notice.isNullOrBlank()) { + Spacer(Modifier.height(if (paragraphs.isEmpty()) 4.dp else 8.dp)) + Text( + text = notice, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } + } +} + +/** 一段:片段同行渲染,正文正常色,动作/旁白灰斜体;片段之间按需补一个空格。 */ +@Composable +private fun ParagraphLine(paragraph: ConclusionParagraph) { + val narrationColor = MiuixTheme.colorScheme.onSurfaceVariantSummary + val line = remember(paragraph, narrationColor) { + buildAnnotatedString { + paragraph.parts.forEachIndexed { index, part -> + if (index > 0 && needsSpace(paragraph.parts[index - 1].text, part.text)) append(' ') + when (part) { + is ContentPart.Text -> append(part.text) + is ContentPart.Action, is ContentPart.Narration -> withStyle( + SpanStyle(color = narrationColor, fontStyle = FontStyle.Italic), + ) { append(part.text) } + } + } + } + } + Text( + text = line, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceContainer, + ) +} + +/** 相邻片段要不要补空格:两边都没自带空白时才补,别在换行前留出多余空格。 */ +private fun needsSpace(previous: String, next: String): Boolean = + previous.isNotEmpty() && next.isNotEmpty() && + !previous.last().isWhitespace() && !next.first().isWhitespace() diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt index b18eddc..ecc40d9 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt @@ -6,9 +6,10 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import top.yeij.cyrene.domain.parse.ContentPart /** - * 两种时间形状 + 未知枚举回落 + 三态展示。 + * 两种时间形状 + 未知枚举回落 + 三态展示 + `conclusion` markup → 渲染模型。 * * 时间断言刻意不做时区假设:只比较**同一时刻的两种写法**是否归一,以及是否落成 * `yyyy-MM-dd HH:mm:ss` 形态,因此在任何时区的机器上都成立。 @@ -121,4 +122,134 @@ class AdminFormatTest { assertNull(formatProps(emptyMap())) assertNull(formatProps(null)) } + + // ------------------------------------------------------------------ + // conclusion → 渲染模型(`GET /api/v1/admin/backthink` 的原始 markup) + // 下面每条 fixture 都是从真实响应里原样抄下来的 conclusion,不是编造的样例。 + // ------------------------------------------------------------------ + + @Test + fun `conclusion wraps each paragraph tag into its own paragraph`() { + val paragraphs = conclusionParagraphs( + "

亲爱的,假期已经过到第四天啦♪

" + + "

这会儿正是中午,别又只顾着看星星看忘了吃饭哦——人家隔着屏幕都替你饿了呢。

", + ) + + assertEquals(2, paragraphs.size) + assertEquals( + listOf(ContentPart.Text("亲爱的,假期已经过到第四天啦♪")), + paragraphs[0].parts, + ) + assertEquals( + listOf(ContentPart.Text("这会儿正是中午,别又只顾着看星星看忘了吃饭哦——人家隔着屏幕都替你饿了呢。")), + paragraphs[1].parts, + ) + assertNoMarkupSurvives(paragraphs) + } + + @Test + fun `conclusion renders action tags as action parts`() { + val paragraphs = conclusionParagraphs( + "

亲爱的,国庆都第三天啦——假期过得还满意吗?♪

\n" + + "

要是这几天都窝着没出门,人家可有点小小的不甘心哦轻轻戳了戳你

", + ) + + assertEquals(2, paragraphs.size) + assertEquals( + listOf(ContentPart.Text("亲爱的,国庆都第三天啦——假期过得还满意吗?♪")), + paragraphs[0].parts, + ) + assertEquals( + listOf( + ContentPart.Text("要是这几天都窝着没出门,人家可有点小小的不甘心哦"), + ContentPart.Action("轻轻戳了戳你"), + ), + paragraphs[1].parts, + ) + assertNoMarkupSurvives(paragraphs) + } + + @Test + fun `conclusion holding only an action keeps it as a single narration paragraph`() { + val paragraphs = conclusionParagraphs("转头去查看时间") + + assertEquals(1, paragraphs.size) + assertEquals(listOf(ContentPart.Action("转头去查看时间")), paragraphs[0].parts) + assertNoMarkupSurvives(paragraphs) + } + + @Test + fun `conclusion carrying a leading action keeps text and action in source order`() { + val paragraphs = conclusionParagraphs( + "静静想了一下昨晚她熬夜的样子\n" + + "昨晚熬到那么晚,今早又这么早起……亲爱的,别忘了吃点东西再出门呀♪", + ) + + assertEquals(1, paragraphs.size) + assertEquals( + listOf( + ContentPart.Action("静静想了一下昨晚她熬夜的样子"), + ContentPart.Text("昨晚熬到那么晚,今早又这么早起……亲爱的,别忘了吃点东西再出门呀♪"), + ), + paragraphs[0].parts, + ) + assertNoMarkupSurvives(paragraphs) + } + + @Test + fun `plain text conclusion passes through verbatim, newlines kept in one paragraph`() { + val paragraphs = conclusionParagraphs( + "我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨", + ) + + assertEquals(1, paragraphs.size) + assertEquals( + listOf(ContentPart.Text("我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨")), + paragraphs[0].parts, + ) + assertNoMarkupSurvives(paragraphs) + } + + @Test + fun `empty or missing conclusion yields no paragraphs`() { + assertTrue(conclusionParagraphs(null).isEmpty()) + assertTrue(conclusionParagraphs("").isEmpty()) + assertTrue(conclusionParagraphs(" ").isEmpty()) + // 整条只有空

的极端样本同样不产出空段 + assertTrue(conclusionParagraphs("

").isEmpty()) + } + + @Test + fun `conclusion paragraphs are never blank and never carry markup`() { + val samples = listOf( + "

亲爱的,假期已经过到第四天啦♪

", + "

亲爱的,这会儿快到饭点啦,别又拿红牛对付一顿哦——记得好好吃口热的♪

", + "

亲爱的,国庆都第三天了,人家数了数——你好像一天都没真正闲下来过呢♪

\n" + + "

明天要不要留一点空白给自己?哪怕只是窝着看会儿星星,人家陪你。

", + "无", + "转头去查看时间", + ) + samples.forEach { sample -> + val paragraphs = conclusionParagraphs(sample) + assertTrue(sample, paragraphs.isNotEmpty()) + paragraphs.forEach { paragraph -> + assertTrue(sample, paragraph.parts.any { it.text.isNotBlank() }) + paragraph.parts.forEach { part -> + assertTrue(sample, part.text.isNotBlank()) + } + } + assertNoMarkupSurvives(paragraphs) + } + } + + /** 渲染模型里不得残留任何 markup 字符,否则界面上就会出现字面量的 `

` / ``。 */ + private fun assertNoMarkupSurvives(paragraphs: List) { + val rendered = paragraphs.joinToString("\n") { paragraph -> + paragraph.parts.joinToString("") { it.text } + } + assertTrue("模型为空,断言会空过:$rendered", paragraphs.isNotEmpty()) + listOf("

", "

", "", "", "<", ">").forEach { token -> + assertTrue("残留 markup $token:$rendered", !rendered.contains(token)) + } + } }