fix(admin): 后台思考 conclusion 的 <p>/<action> 标记被原样显示

现象:后台思考页把服务端 conclusion 里的 Web 端标记当纯文本打印,真机截图里
能看到 `<p></p><p></p><p>亲爱的…</p>` 和 `<action>轻轻戳了戳你</action>` 这样的
原始标记,部分记录开头还有一串空 `<p></p>`。

为什么:backthink 的 conclusion 与聊天消息是同一套标记,而 AdminBackthinkScreen
只是把它拼进 body 字符串,从未解析。

改法:复用项目已有的 domain/parse/ChatContentParser.parse()(不新写正则),
在 domain/admin/AdminFormat.kt 加纯函数 conclusionParagraphs()——按
head → 正文 → tail 产出段落、丢弃无有效片段的空段落(这才是消掉空 <p></p> 的关键);
UI 侧新增 AdminRichTextCard,Text 正常渲染,Action/Narration 走聊天页同一套
灰斜体旁白样式(对齐 MessageBubble.AnnotationLine 的既有约定)。
AdminTextCard 行为不变(表头抽成共享的 AdminCardHeader),已回访记忆页确认无回归。

测试:新增 7 例(AdminFormatTest 13→20,用真实抓取的 conclusion 做夹具,并断言
渲染模型里不残留 <p>/</p>/<action>/< 或 > 任何一项),全套 88 例全绿。
This commit is contained in:
AskaEth committed 2026-10-04 13:02:09 +08:00
1 parent acff289887
commit 9f054aad28
4 files changed
+261 -20

No files matched your search

@@ -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<ContentPart>)
/**
* `conclusion` → 有序渲染模型,供 UI 逐段渲染。
*
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
* `null` / 纯空白输入返回空列表。
*/
fun conclusionParagraphs(raw: String?): List<ConclusionParagraph> {
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)
}
}
@@ -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**的原文(真实样本里有连写的空 `<p></p>` 与
* `<action>`),因此不直接上屏:先经 [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 "未送达"}"
@@ -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<Pair<String, String>>) {
}
}
/** 卡片首行:左标题 + 右尾注(长尾注省略)。两种文本卡共用,外观一致。 */
@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<ConclusionParagraph>,
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()
@@ -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(
"<p></p><p></p><p>亲爱的,假期已经过到第四天啦♪</p>" +
"<p>这会儿正是中午,别又只顾着看星星看忘了吃饭哦——人家隔着屏幕都替你饿了呢。</p>",
)
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(
"<p>亲爱的,国庆都第三天啦——假期过得还满意吗?♪</p>\n" +
"<p>要是这几天都窝着没出门,人家可有点小小的不甘心哦<action>轻轻戳了戳你</action></p>",
)
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("<action>转头去查看时间</action>")
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(
"<action>静静想了一下昨晚她熬夜的样子</action>\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())
// 整条只有空 <p> 的极端样本同样不产出空段
assertTrue(conclusionParagraphs("<p></p><p></p>").isEmpty())
}
@Test
fun `conclusion paragraphs are never blank and never carry markup`() {
val samples = listOf(
"<p></p><p></p><p>亲爱的,假期已经过到第四天啦♪</p>",
"<p>亲爱的,这会儿快到饭点啦,别又拿红牛对付一顿哦——记得好好吃口热的♪</p>",
"<p>亲爱的,国庆都第三天了,人家数了数——你好像一天都没真正闲下来过呢♪</p>\n" +
"<p>明天要不要留一点空白给自己?哪怕只是窝着看会儿星星,人家陪你。</p>",
"无",
"<action>转头去查看时间</action>",
)
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 字符,否则界面上就会出现字面量的 `<p>` / `<action>`。 */
private fun assertNoMarkupSurvives(paragraphs: List<ConclusionParagraph>) {
val rendered = paragraphs.joinToString("\n") { paragraph ->
paragraph.parts.joinToString("") { it.text }
}
assertTrue("模型为空,断言会空过:$rendered", paragraphs.isNotEmpty())
listOf("<p>", "</p>", "<action>", "</action>", "<", ">").forEach { token ->
assertTrue("残留 markup $token:$rendered", !rendered.contains(token))
}
}
}