From 9f054aad288b0d6d1b3c23a3d6fa960324c74e9e Mon Sep 17 00:00:00 2001
From: AskaEth
Date: Sun, 4 Oct 2026 13:02:09 +0800
Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=90=8E=E5=8F=B0=E6=80=9D?=
=?UTF-8?q?=E8=80=83=20conclusion=20=E7=9A=84=20
/=20=E6=A0=87?=
=?UTF-8?q?=E8=AE=B0=E8=A2=AB=E5=8E=9F=E6=A0=B7=E6=98=BE=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
现象:后台思考页把服务端 conclusion 里的 Web 端标记当纯文本打印,真机截图里
能看到 `亲爱的…
` 和 `轻轻戳了戳你` 这样的
原始标记,部分记录开头还有一串空 ``。
为什么:backthink 的 conclusion 与聊天消息是同一套标记,而 AdminBackthinkScreen
只是把它拼进 body 字符串,从未解析。
改法:复用项目已有的 domain/parse/ChatContentParser.parse()(不新写正则),
在 domain/admin/AdminFormat.kt 加纯函数 conclusionParagraphs()——按
head → 正文 → tail 产出段落、丢弃无有效片段的空段落(这才是消掉空 的关键);
UI 侧新增 AdminRichTextCard,Text 正常渲染,Action/Narration 走聊天页同一套
灰斜体旁白样式(对齐 MessageBubble.AnnotationLine 的既有约定)。
AdminTextCard 行为不变(表头抽成共享的 AdminCardHeader),已回访记忆页确认无回归。
测试:新增 7 例(AdminFormatTest 13→20,用真实抓取的 conclusion 做夹具,并断言
渲染模型里不残留 /
//< 或 > 任何一项),全套 88 例全绿。
---
.../yeij/cyrene/domain/admin/AdminFormat.kt | 30 ++++
.../cyrene/ui/admin/AdminBackthinkScreen.kt | 17 ++-
.../top/yeij/cyrene/ui/admin/AdminCommon.kt | 101 +++++++++++--
.../cyrene/domain/admin/AdminFormatTest.kt | 133 +++++++++++++++++-
4 files changed, 261 insertions(+), 20 deletions(-)
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))
+ }
+ }
}