Compare commits
2
Commits
a7b3452e6d
...
9f054aad28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f054aad28 | ||
|
|
acff289887 |
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()
|
||||
@@ -13,8 +13,12 @@ import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
|
||||
/**
|
||||
* 用真实服务端形状的 JSON 校验 DTO 契约(api-client §1/§2):
|
||||
* `{data}` 信封、开放集合取值、`thinking` 三态、RFC3339 与 Unix 毫秒两种时间。
|
||||
* 手写 JSON 覆盖**线上暂未出现、但契约允许**的边界形状(未知 kind/scope、`thinking` 三态、
|
||||
* 可选字段整体缺失等)。真实响应体的钉死测试见 [AdminDtoRealPayloadTest]——两者互补,
|
||||
* 手写用例不能替代真实抓包证据。
|
||||
*
|
||||
* 解码器同样是 [ApiClientFactory.json](api-client §1/§2 的 `{data}` 信封、
|
||||
* 开放集合取值、RFC3339 与 Unix 毫秒两种时间)。
|
||||
*/
|
||||
class AdminDtoDecodingTest {
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
|
||||
/**
|
||||
* 契约钉死测试:用的是**线上后端实际抓下来的响应体**(`owner` 账号,2026-10-04),
|
||||
* 只把数组裁到 1–3 条,其余字段逐字节照抄。
|
||||
*
|
||||
* 解码器用 [ApiClientFactory.json] —— 与生产同一份 `Json` 配置
|
||||
* (`ignoreUnknownKeys` / `explicitNulls=false` / `coerceInputValues`),
|
||||
* 所以这里过不了的形状,线上也一定过不了。
|
||||
*
|
||||
* 覆盖线上真实出现、而手写 JSON 容易漏掉的形状:`qps` 以整数下发、
|
||||
* `parentId` 在根 span 上整键缺失、`thinking` 整键缺失、`mode`/`memoryNamespace` 是空串而非缺失、
|
||||
* `id` 以 JSON 字符串下发、`props` 同一 map 内混装数字与字符串。
|
||||
*/
|
||||
class AdminDtoRealPayloadTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val traceSpans = ListSerializer(AdminTraceSpanDto.serializer())
|
||||
private val personaList = ListSerializer(PersonaSummaryDto.serializer())
|
||||
private val memoryList = ListSerializer(MemorySummaryDto.serializer())
|
||||
private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer())
|
||||
|
||||
// —— 以下 5 段是真实响应体(数组已裁剪) ——
|
||||
|
||||
private val realMetrics = """
|
||||
{"data":{"totalRequests":326,"qps":1,"avgLatencyMs":1,"maxLatencyMs":40,"perMethod":{"GET":321,"POST":5},"perPath":{"/":2,"/api/v1/admin/automations":17,"/api/v1/admin/backthink":1,"/api/v1/admin/channels":30,"/api/v1/admin/iot":34,"/api/v1/admin/llm":18,"/api/v1/admin/llm/catalog":2,"/api/v1/admin/memories":21,"/api/v1/admin/observability/metrics":24,"/api/v1/admin/observability/trace":1,"/api/v1/admin/personas":31,"/api/v1/admin/plugins":23,"/api/v1/admin/plugins/installed":17,"/api/v1/admin/rag":17,"/api/v1/admin/sessions":30,"/api/v1/admin/users":17,"/api/v1/auth/login":5,"/api/v1/health":27,"/api/v1/meta/enums":2,"/api/v1/personas":1,"/api/v1/sessions":1,"/api/v1/sessions/web/messages":1,"/assets/index-DrDuaeXG.js":1,"/assets/index-SA9l26Q_.css":1,"/sw.js":1,"/ws/chat":1},"statusCounts":{"200":319,"304":1,"401":4,"404":2},"llmCalls":1,"subagentEvents":0}}
|
||||
""".trimIndent()
|
||||
|
||||
private val realTrace = """
|
||||
{"data":[{"id":"2e1e3027e74622c6-1","name":"loop.step","kind":"state_transition","status":"ok","startedAt":"2026-10-04T04:36:14Z","endedAt":"2026-10-04T04:36:15Z","durationNs":1170379200,"props":{"session":"sess-backthink","step":1,"stop_reason":"stop"}},{"id":"2e1e3027e74622c6-2","parentId":"2e1e3027e74622c6-1","name":"llm.call","kind":"llm_call","status":"ok","startedAt":"2026-10-04T04:36:14Z","endedAt":"2026-10-04T04:36:15Z","durationNs":1167475900,"props":{"model_stop_reason":"end_turn","session":"sess-backthink","stop_reason":"stop","tool_calls":0},"events":[{"time":"2026-10-04T12:36:15.3410926+08:00","type":"llm.done","props":{"elapsed_ms":1167,"model_stop_reason":"end_turn","stop_reason":"stop","tool_calls":0}}]}]}
|
||||
""".trimIndent()
|
||||
|
||||
private val realPersonas = """
|
||||
{"data":[{"id":"cyrene","name":"昔涟","character":"昔涟","isDefault":true,"mode":"","memoryNamespace":"","avatar":"/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png","chatBackground":{"landscape":"/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png","portrait":"/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png"},"mood":"calm"}]}
|
||||
""".trimIndent()
|
||||
|
||||
private val realMemories = """
|
||||
{"data":[{"id":"ed5bc1b2596282e4c33d3eed68722b3f","scope":"user","text":"用户有熬夜的习惯,助手会对此表示关心,并猜测用户可能在熬夜看星星。"},{"id":"87e712cd5cba928ad81c0baf70b7f23f","scope":"persona","text":"助手与用户关系亲密,助手常称呼用户“亲爱的”。"}]}
|
||||
""".trimIndent()
|
||||
|
||||
private val realBackthink = """
|
||||
{"data":[{"id":"487","trigger":"startup","conclusion":"我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨","shouldNotify":true,"pushed":false,"at":1791088571785},{"id":"486","trigger":"heartbeat","conclusion":"亲爱的,国庆第三天了,难得放假——别又熬到后半夜哦。人家就悄悄提醒一句,说完就去忙自己的了♪","shouldNotify":true,"pushed":false,"at":1791031297192}]}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `real metrics body decodes integer-shaped doubles and large perPath map`() {
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), realMetrics)
|
||||
.data!!
|
||||
|
||||
assertEquals(326L, data.totalRequests)
|
||||
// 线上 `qps` / `avgLatencyMs` 是整数下发的,Double 字段必须照单全收。
|
||||
assertEquals(1.0, data.qps, 0.0)
|
||||
assertEquals(1.0, data.avgLatencyMs, 0.0)
|
||||
assertEquals(40L, data.maxLatencyMs)
|
||||
assertEquals(mapOf("GET" to 321L, "POST" to 5L), data.perMethod)
|
||||
assertEquals(mapOf("200" to 319L, "304" to 1L, "401" to 4L, "404" to 2L), data.statusCounts)
|
||||
assertEquals(1L, data.llmCalls)
|
||||
assertEquals(0L, data.subagentEvents)
|
||||
|
||||
// perPath 有 26 条真实路径,含 admin 前缀;数量级和前缀都不能被改写。
|
||||
assertEquals(26, data.perPath.size)
|
||||
assertEquals(31L, data.perPath["/api/v1/admin/personas"])
|
||||
assertEquals(24L, data.perPath["/api/v1/admin/observability/metrics"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real trace body keeps parent link only on child span`() {
|
||||
val spans = json
|
||||
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
|
||||
.data!!
|
||||
|
||||
assertEquals(2, spans.size)
|
||||
val root = spans[0]
|
||||
val child = spans[1]
|
||||
|
||||
// 根 span 上 `parentId` 整键缺失 → null,而不是空串。
|
||||
assertNull(root.parentId)
|
||||
assertEquals(child.parentId, root.id)
|
||||
assertEquals("state_transition", root.kind)
|
||||
assertEquals("llm_call", child.kind)
|
||||
assertEquals("loop.step", root.name)
|
||||
assertEquals("2026-10-04T04:36:14Z", root.startedAt)
|
||||
assertEquals("2026-10-04T04:36:15Z", root.endedAt)
|
||||
assertEquals("ok", child.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real trace durationNs is a Long that survives values past Int MAX_VALUE`() {
|
||||
val spans = json
|
||||
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
|
||||
.data!!
|
||||
|
||||
// 真实纳秒值:窄化成 Int 会溢出,所以字段必须是 Long。
|
||||
assertEquals(1_170_379_200L, spans[0].durationNs)
|
||||
assertEquals(1_167_475_900L, spans[1].durationNs)
|
||||
|
||||
// 边界:同一个真实根 span,只把 durationNs 抬到 3s(> 2_147_483_647),
|
||||
// 证明 Long 位宽足够——Int 字段在这里必然截断/抛错。
|
||||
val threeSecondSpan = realTrace.replace("\"durationNs\":1170379200", "\"durationNs\":3000000000")
|
||||
val wide = json
|
||||
.decodeFromString(ApiEnvelope.serializer(traceSpans), threeSecondSpan)
|
||||
.data!!
|
||||
.first()
|
||||
|
||||
assertEquals(3_000_000_000L, wide.durationNs)
|
||||
assertTrue("3s 已经超过 Int.MAX_VALUE", wide.durationNs > Int.MAX_VALUE.toLong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real trace props and event props mix numbers and strings in one map`() {
|
||||
val spans = json
|
||||
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
|
||||
.data!!
|
||||
|
||||
val rootProps = spans[0].props!!
|
||||
assertEquals(3, rootProps.size)
|
||||
assertTrue((rootProps["session"] as JsonPrimitive).isString)
|
||||
assertEquals("sess-backthink", (rootProps["session"] as JsonPrimitive).content)
|
||||
assertFalse("step 是数字,不能当字符串读", (rootProps["step"] as JsonPrimitive).isString)
|
||||
assertEquals(1, (rootProps["step"] as JsonPrimitive).int)
|
||||
|
||||
val childProps = spans[1].props!!
|
||||
assertEquals(0, (childProps["tool_calls"] as JsonPrimitive).int)
|
||||
assertEquals("end_turn", (childProps["model_stop_reason"] as JsonPrimitive).content)
|
||||
|
||||
// events[].time 带 `+08:00` 偏移和 7 位小数——不能只按 `Z` 结尾解析。
|
||||
val event = spans[1].events!!.single()
|
||||
assertEquals("llm.done", event.type)
|
||||
assertEquals("2026-10-04T12:36:15.3410926+08:00", event.time)
|
||||
val eventProps = event.props!!
|
||||
assertEquals(1167, (eventProps["elapsed_ms"] as JsonPrimitive).int)
|
||||
assertEquals("end_turn", (eventProps["model_stop_reason"] as JsonPrimitive).content)
|
||||
assertFalse((eventProps["tool_calls"] as JsonPrimitive).isString)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real persona has no thinking key and empty-string mode fields`() {
|
||||
val raw = json
|
||||
.parseToJsonElement(realPersonas)
|
||||
.jsonObject["data"]!!
|
||||
.jsonArray
|
||||
.single()
|
||||
.jsonObject
|
||||
|
||||
// 未配置的人格:`thinking` 键**完全不出现**(不是 null,也不是空对象)。
|
||||
assertFalse("thinking 键不该出现", raw.containsKey("thinking"))
|
||||
// 而 `mode` / `memoryNamespace` 是**空串**,键在、值是 ""——两者不能混为一谈。
|
||||
assertTrue(raw.containsKey("mode"))
|
||||
assertTrue(raw.containsKey("memoryNamespace"))
|
||||
assertEquals("", raw["mode"]!!.jsonPrimitive.content)
|
||||
assertEquals("", raw["memoryNamespace"]!!.jsonPrimitive.content)
|
||||
|
||||
val persona = json
|
||||
.decodeFromString(ApiEnvelope.serializer(personaList), realPersonas)
|
||||
.data!!
|
||||
.single()
|
||||
|
||||
assertNull("thinking 缺失必须解成 null,不能拍成零值对象", persona.thinking)
|
||||
assertEquals("", persona.mode)
|
||||
assertEquals("", persona.memoryNamespace)
|
||||
assertEquals("cyrene", persona.id)
|
||||
assertEquals("昔涟", persona.name)
|
||||
assertEquals("昔涟", persona.character)
|
||||
assertTrue(persona.isDefault)
|
||||
assertEquals("calm", persona.mood)
|
||||
assertNotNull(persona.avatar)
|
||||
assertEquals("/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png", persona.chatBackground!!.portrait)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real memories carry both user and persona scopes`() {
|
||||
val memories = json
|
||||
.decodeFromString(ApiEnvelope.serializer(memoryList), realMemories)
|
||||
.data!!
|
||||
|
||||
assertEquals(2, memories.size)
|
||||
assertEquals(listOf("user", "persona"), memories.map { it.scope })
|
||||
assertEquals("ed5bc1b2596282e4c33d3eed68722b3f", memories[0].id)
|
||||
assertTrue(memories[0].text.startsWith("用户有熬夜的习惯"))
|
||||
assertTrue(memories[1].text.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real backthink keeps string id and numeric millis`() {
|
||||
val raw = json
|
||||
.parseToJsonElement(realBackthink)
|
||||
.jsonObject["data"]!!
|
||||
.jsonArray
|
||||
|
||||
// `id` 线上是 JSON **字符串**("487"),`at` 是 **数字** 毫秒。
|
||||
assertTrue(raw[0].jsonObject["id"]!!.jsonPrimitive.isString)
|
||||
assertFalse(raw[0].jsonObject["at"]!!.jsonPrimitive.isString)
|
||||
assertEquals(1_791_088_571_785L, raw[0].jsonObject["at"]!!.jsonPrimitive.content.toLong())
|
||||
|
||||
val items = json
|
||||
.decodeFromString(ApiEnvelope.serializer(backthinkList), realBackthink)
|
||||
.data!!
|
||||
|
||||
assertEquals(2, items.size)
|
||||
assertEquals("487", items[0].id)
|
||||
assertEquals("486", items[1].id)
|
||||
assertEquals(1_791_088_571_785L, items[0].at)
|
||||
assertEquals(1_791_031_297_192L, items[1].at)
|
||||
assertEquals("startup", items[0].trigger)
|
||||
assertEquals("heartbeat", items[1].trigger)
|
||||
assertTrue(items[0].shouldNotify)
|
||||
assertFalse(items[0].pushed)
|
||||
// conclusion 里有真实换行转义,解出来必须是多行文本。
|
||||
assertTrue(items[0].conclusion!!.contains("\n"))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user