diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 334d9cf..687542a 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -5,8 +5,10 @@ import top.yeij.cyrene.core.auth.AuthManager import top.yeij.cyrene.core.auth.SessionStore import top.yeij.cyrene.core.prefs.AppSettings import top.yeij.cyrene.core.prefs.SessionPreferences +import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.SessionsApi +import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.SessionRepository import top.yeij.cyrene.data.ws.ChatSocket @@ -43,6 +45,28 @@ class AppContainer(context: Context) { val messageRepository: MessageRepository get() = MessageRepository(sessionsApi) + private var cachedAdminApi: AdminApi? = null + private var cachedAdminBaseUrl: String? = null + + /** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */ + val adminApi: AdminApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedAdminApi + if (cached != null && cachedAdminBaseUrl == baseUrl) return cached + val created = ApiClientFactory.create( + baseUrl = baseUrl, + service = AdminApi::class.java, + tokenProvider = { sessionStore.load().token }, + onUnauthorized = { authManager.onSessionExpired() }, + ) + cachedAdminBaseUrl = baseUrl + cachedAdminApi = created + return created + } + + val adminRepository: AdminRepository get() = AdminRepository(adminApi) + fun createChatSocket(): ChatSocket = ChatSocket( baseUrlProvider = { authManager.currentBaseUrl }, tokenProvider = { sessionStore.load().token }, diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/AdminApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/AdminApi.kt new file mode 100644 index 0000000..366175b --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/AdminApi.kt @@ -0,0 +1,39 @@ +package top.yeij.cyrene.data.api + +import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto +import top.yeij.cyrene.data.api.dto.AdminMetricsDto +import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto +import top.yeij.cyrene.data.api.dto.MemorySummaryDto +import top.yeij.cyrene.data.api.dto.PersonaSummaryDto +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。 + * + * RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**, + * 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。 + */ +interface AdminApi { + + /** 网关请求指标;`qps` 为近 1 秒窗口计数。 */ + @GET("api/v1/admin/observability/metrics") + suspend fun metrics(): Response> + + /** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */ + @GET("api/v1/admin/observability/trace") + suspend fun trace(@Query("limit") limit: Int? = null): Response>> + + /** 人格列表(含 admin 专属 `thinking`)。 */ + @GET("api/v1/admin/personas") + suspend fun personas(): Response>> + + /** 私人记忆正文;`viewer` 会拿到 403。 */ + @GET("api/v1/admin/memories") + suspend fun memories(): Response>> + + /** 后台思考记录;`viewer` 会拿到 403。 */ + @GET("api/v1/admin/backthink") + suspend fun backthink(): Response>> +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/AdminDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/AdminDto.kt new file mode 100644 index 0000000..9d2235f --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/AdminDto.kt @@ -0,0 +1,107 @@ +package top.yeij.cyrene.data.api.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。 + * 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。 + */ +@Serializable +data class AdminMetricsDto( + val totalRequests: Long = 0, + val qps: Double = 0.0, + val avgLatencyMs: Double = 0.0, + val maxLatencyMs: Long = 0, + val perMethod: Map = emptyMap(), + val perPath: Map = emptyMap(), + val statusCounts: Map = emptyMap(), + val llmCalls: Long = 0, + val subagentEvents: Long = 0, +) + +/** + * `GET /api/v1/admin/observability/trace?limit=N` 单个 span。 + * `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。 + * `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。 + */ +@Serializable +data class AdminTraceSpanDto( + val id: String, + val parentId: String? = null, + val name: String = "", + val kind: String = "", + val status: String = "", + val startedAt: String = "", + val endedAt: String? = null, + val durationNs: Long = 0, + val props: Map? = null, + val events: List? = null, +) + +/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */ +@Serializable +data class AdminTraceEventDto( + val time: String = "", + val type: String = "", + val props: Map? = null, +) + +/** + * 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。 + * admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。 + */ +@Serializable +data class PersonaSummaryDto( + val id: String, + val name: String = "", + val character: String = "", + val isDefault: Boolean = false, + val mode: String = "", + val memoryNamespace: String = "", + val avatar: String? = null, + val chatBackground: ChatBackgroundDto? = null, + val mood: String? = null, + val thinking: PersonaThinkingDto? = null, +) + +@Serializable +data class ChatBackgroundDto( + val landscape: String? = null, + val portrait: String? = null, +) + +/** + * 人格 `thinking` 读侧形状(v0.43)。 + * [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择; + * 不得把 null 拍平成 false。 + */ +@Serializable +data class PersonaThinkingDto( + val budgetTokens: Int = 0, + val effort: String = "", + val showToUser: Boolean? = null, + val mode: String = "", +) + +/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */ +@Serializable +data class MemorySummaryDto( + val id: String, + val scope: String = "", + val text: String = "", +) + +/** + * `GET /api/v1/admin/backthink` 一条后台思考记录。 + * [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。 + */ +@Serializable +data class AdminBackthinkItemDto( + val id: String, + val trigger: String = "", + val conclusion: String? = null, + val shouldNotify: Boolean = false, + val pushed: Boolean = false, + val at: Long = 0, +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/AdminRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/AdminRepository.kt new file mode 100644 index 0000000..c9e07d3 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/AdminRepository.kt @@ -0,0 +1,52 @@ +package top.yeij.cyrene.data.repository + +import top.yeij.cyrene.data.api.AdminApi +import top.yeij.cyrene.data.api.apiCall +import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto +import top.yeij.cyrene.data.api.dto.AdminMetricsDto +import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto +import top.yeij.cyrene.data.api.dto.MemorySummaryDto +import top.yeij.cyrene.data.api.dto.PersonaSummaryDto +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 管理只读监控取数层(api-client §2)。 + * 权限判断不在这里:仓库只管取数,403 由调用方按 [top.yeij.cyrene.domain.admin.AdminFailure] 收敛。 + */ +class AdminRepository( + private val api: AdminApi, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + + suspend fun metrics(): Result = withContext(dispatcher) { + apiCall { api.metrics() } + } + + /** `GET /observability/trace`;`limit` 与服务端对齐,钳制在 [1, [MAX_TRACE_LIMIT]]。 */ + suspend fun trace(limit: Int = DEFAULT_TRACE_LIMIT): Result> = + withContext(dispatcher) { + apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) } + } + + suspend fun personas(): Result> = withContext(dispatcher) { + apiCall { api.personas() } + } + + suspend fun memories(): Result> = withContext(dispatcher) { + apiCall { api.memories() } + } + + suspend fun backthink(): Result> = withContext(dispatcher) { + apiCall { api.backthink() } + } + + companion object { + /** trace 拉取条数:够看近期活动,又不会把一屏塞满。 */ + const val DEFAULT_TRACE_LIMIT = 100 + + /** 服务端 `?limit=` 上限。 */ + const val MAX_TRACE_LIMIT = 500 + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt new file mode 100644 index 0000000..ce16194 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt @@ -0,0 +1,60 @@ +package top.yeij.cyrene.domain.admin + +/** + * 管理侧权限矩阵(api-client §2.0)的**客户端镜像**。 + * + * 后端契约明确要求客户端自行降级(`Docs/api-client.md`:「客户端需按本矩阵自行降级」): + * 不该读的页**不发请求**,而不是发出去等 403。这里的判断是纯函数,便于单测。 + */ + +/** + * 管理页可见性分档,按权限递增。 + * + * - [Monitor]:api-client §2.0 的①档,`owner`/`admin`/`viewer` 均可读(只读非敏感投影)。 + * - [OwnerAdmin]:②档,仅 `owner`/`admin`(含他人数据 / 隐私正文),`viewer` 一律 403。 + */ +enum class AdminTier(val rank: Int) { + Monitor(0), + OwnerAdmin(1), +} + +/** 已接线角色,`rank` 与后端 `owner>admin>viewer` 递增一致。 */ +enum class AdminRole(val rank: Int) { + Viewer(0), + Admin(1), + Owner(2), + ; + + companion object { + /** + * 角色名解析。大小写 / 首尾空白不敏感;**未知或空值按 [Viewer] 处理**—— + * 与服务端一样 fail-closed:宁可少给可见面,也不去撞预期中的 403。 + */ + fun of(raw: String?): AdminRole = when (raw?.trim()?.lowercase()) { + "owner" -> Owner + "admin" -> Admin + else -> Viewer + } + } +} + +/** + * 管理页清单:只声明「有哪些页 + 需要哪一档权限」。 + * 入口页、ViewModel 与各页标题都按本清单驱动,新增管理模块只需在此追加一项。 + */ +enum class AdminPage( + val title: String, + val summary: String, + val required: AdminTier, +) { + Overview("概览", "请求指标与最近 trace", AdminTier.Monitor), + Personas("人格", "角色目录与思考旋钮", AdminTier.Monitor), + Memories("记忆", "记忆正文(scope / 内容)", AdminTier.OwnerAdmin), + Backthink("后台思考", "后台思考与主动推送记录", AdminTier.OwnerAdmin), +} + +/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */ +fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank + +/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */ +fun availablePages(role: AdminRole): List = AdminPage.entries.filter { canAccess(role, it) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFailure.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFailure.kt new file mode 100644 index 0000000..3d5353a --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFailure.kt @@ -0,0 +1,26 @@ +package top.yeij.cyrene.domain.admin + +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException + +/** + * 管理页取数失败。**「无权限」必须与其它失败分开**:UI 要能给出「无权限」状态, + * 而不是把 403 当成空列表渲染成「暂无数据」。 + */ +sealed interface AdminFailure { + /** HTTP 403(角色不够)或 401(会话失效)。 */ + data object Forbidden : AdminFailure + + data class Other(val message: String) : AdminFailure +} + +/** 把仓库层的 `Result` 失败收敛成 [AdminFailure];成功时返回 null。 */ +fun Throwable.toAdminFailure(): AdminFailure { + val error = (this as? CyreneException)?.error + return when { + error is CyreneError.Unauthorized -> AdminFailure.Forbidden + error is CyreneError.Server && error.httpStatus == 403 -> AdminFailure.Forbidden + error != null -> AdminFailure.Other(error.userMessage) + else -> AdminFailure.Other("操作失败,请稍后重试") + } +} 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 new file mode 100644 index 0000000..aa243f9 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt @@ -0,0 +1,121 @@ +package top.yeij.cyrene.domain.admin + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val LocalTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + +/** + * RFC3339(trace 的 `startedAt` / `endedAt`、trace event 的 `time`)→ 本地时间文本。 + * 秒精度串与纳秒精度串都要吃得下;解析不了就**原样返回**,绝不静默清空。 + */ +fun formatRfc3339(raw: String?): String? { + val text = raw?.takeIf { it.isNotBlank() } ?: return null + return runCatching { + OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).format(LocalTime) + }.getOrDefault(text) +} + +/** + * Unix 毫秒(backthink 的 `at`)→ 本地时间文本。 + * 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。 + */ +fun formatEpochMillis(millis: Long): String = + Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(LocalTime) + +/** 纳秒时长 → 按量级挑单位。 */ +fun formatNanos(nanos: Long): String = when { + nanos < 1_000 -> "$nanos ns" + nanos < 1_000_000 -> String.format(Locale.ROOT, "%.1f µs", nanos / 1_000.0) + nanos < 1_000_000_000 -> String.format(Locale.ROOT, "%.1f ms", nanos / 1_000_000.0) + else -> String.format(Locale.ROOT, "%.2f s", nanos / 1_000_000_000.0) +} + +/** + * 计数表(`perMethod` / `perPath` / `statusCounts`)→ 一行文本,按次数倒序。 + * 键名一律原样保留(开放集合,不做白名单过滤,也不截断条目)。 + */ +fun formatCounts(counts: Map): String = + counts.entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .joinToString(" · ") { (key, value) -> "$key $value" } + +/** 后端用 float64 表达计数 / 毫秒,整数时抹掉无意义的小数位。 */ +fun formatDouble(value: Double, decimals: Int = 2): String = + BigDecimal(value).setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + +/** 任意 `props` 对象 → 紧凑 JSON 文本,原样展示(不裁剪、不丢键)。 */ +fun formatProps(props: Map?): String? = + props?.takeIf { it.isNotEmpty() }?.let { JsonObject(it).toString() } + +private val traceKindLabels = mapOf( + "llm_call" to "LLM 调用", + "tool_call" to "工具调用", + "orchestration" to "编排", + "state_transition" to "状态迁移", + "subagent" to "子代理", + "memory" to "记忆", + "guardrail" to "护栏", +) + +private val traceStatusLabels = mapOf( + "ok" to "成功", + "error" to "失败", +) + +private val personaModeLabels = mapOf( + "chat" to "对话", + "code" to "代码", + "home" to "家居", + "work" to "工作", + "docs" to "文档", + "voice" to "语音", + "vision" to "视觉", + "agents" to "智能体", + "settings" to "设置", +) + +private val memoryScopeLabels = mapOf( + "user" to "用户", + "persona" to "人格", + "project" to "项目", +) + +private val moodLabels = mapOf( + "calm" to "平静", + "happy" to "开心", + "excited" to "兴奋", + "sad" to "低落", + "angry" to "生气", +) + +/** + * 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**—— + * 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。 + */ +fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw + +fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw + +fun personaModeLabel(raw: String): String = personaModeLabels[raw] ?: raw + +fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw + +fun moodLabel(raw: String): String = moodLabels[raw] ?: raw + +/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */ +fun triStateLabel(value: Boolean?): String = when (value) { + null -> "未配置" + true -> "显示" + false -> "不显示" +} + +/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */ +fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-" 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 new file mode 100644 index 0000000..9f62118 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminBackthinkScreen.kt @@ -0,0 +1,67 @@ +package top.yeij.cyrene.ui.admin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.formatEpochMillis +import top.yeij.cyrene.domain.admin.orDash + +/** + * 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。 + * `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。 + */ +@Composable +fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) { + val state by viewModel.backthink.collectAsState() + val reload = { viewModel.load(AdminPage.Backthink, role) } + + LaunchedEffect(Unit) { reload() } + + AdminPageScaffold(title = AdminPage.Backthink.title, onBack = onBack, onRefresh = reload) { padding -> + AdminLoadShell( + state = state, + onRetry = reload, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { records -> + if (records.isEmpty()) { + AdminCenterHint("暂无后台思考记录") + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = records, key = { it.id }) { record -> BackthinkCard(record) } + } + } + } + } +} + +@Composable +private fun BackthinkCard(record: AdminBackthinkItemDto) { + AdminTextCard( + title = orDash(record.trigger), + body = backthinkBody(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") 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 new file mode 100644 index 0000000..6705afd --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminCommon.kt @@ -0,0 +1,231 @@ +package top.yeij.cyrene.ui.admin + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import top.yeij.cyrene.domain.admin.AdminFailure +import top.yeij.cyrene.ui.common.PreferenceCard +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.HorizontalDivider +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SmallTopAppBar +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.ChevronBackward +import top.yukonga.miuix.kmp.icon.extended.Refresh +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 管理页统一外壳:独立的 Miuix [Scaffold] + 返回按钮,各页只填内容。 + * 管理员区是聊天之外的独立导航(design D15),所以每页都有自己的 Scaffold。 + */ +@Composable +fun AdminPageScaffold( + title: String, + onBack: () -> Unit, + onRefresh: (() -> Unit)? = null, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + topBar = { + SmallTopAppBar( + title = title, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回") + } + }, + actions = { + if (onRefresh != null) { + IconButton(onClick = onRefresh) { + Icon(imageVector = MiuixIcons.Refresh, contentDescription = "刷新") + } + } + }, + ) + }, + content = content, + ) +} + +@Composable +fun AdminSectionTitle(text: String) { + Text( + text = text, + style = MiuixTheme.textStyles.subtitle, + color = MiuixTheme.colorScheme.primary, + modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp), + ) +} + +/** 居中提示(加载中 / 空态 / 无权限),整页级别。 */ +@Composable +fun AdminCenterHint( + title: String, + detail: String? = null, + onRetry: (() -> Unit)? = null, +) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Text( + text = title, + style = MiuixTheme.textStyles.main, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + if (detail != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = detail, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + if (onRetry != null) { + Spacer(Modifier.height(12.dp)) + TextButton("重试", onClick = onRetry) + } + } + } +} + +/** 区块内的行内提示(不占满高度,可放进可滚动列 / LazyColumn 的 item)。 */ +@Composable +fun AdminInlineHint(text: String) { + Text( + text = text, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) +} + +/** + * 取数失败时的整页状态。 + * **无权限与其它失败必须分开**:403/401 给「无权限」+ 角色说明,绝不渲染成空列表。 + */ +@Composable +fun AdminFailureState( + failure: AdminFailure?, + onRetry: () -> Unit, +) { + when (failure) { + is AdminFailure.Forbidden -> AdminCenterHint( + title = "无权限", + detail = "当前角色不能读取该页,请用 owner 或 admin 账号登录。", + ) + + else -> AdminCenterHint( + title = "加载失败", + detail = (failure as? AdminFailure.Other)?.message ?: "请稍后重试", + onRetry = onRetry, + ) + } +} + +/** + * 列表/详情页共用的取数外壳:先判权限,再判加载,最后才渲染数据。 + * 顺序不能反——反了就会把 403 渲染成「暂无数据」。 + */ +@Composable +fun AdminLoadShell( + state: AdminUiState, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, +) { + val data = state.data + Box(modifier = modifier) { + when { + state.failure is AdminFailure.Forbidden -> AdminFailureState(state.failure, onRetry) + data == null && state.loading -> AdminCenterHint("加载中…") + data == null -> AdminFailureState(state.failure, onRetry) + else -> content(data) + } + } +} + +/** 「左标签 + 右值」一行;必须放在 [PreferenceCard] 内。 */ +@Composable +fun AdminValueRow(title: String, value: String, valueColor: Color? = null) { + BasicComponent( + title = title, + endActions = { + Text( + text = value, + style = MiuixTheme.textStyles.footnote1, + color = valueColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + }, + ) +} + +/** 指标卡片:一组「标签 → 值」,单张卡片内多行。 */ +@Composable +fun AdminMetricCard(rows: List>) { + PreferenceCard { + Column { + rows.forEachIndexed { index, (label, value) -> + if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + } + AdminValueRow(title = label, value = value) + } + } + } +} + +/** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */ +@Composable +fun AdminTextCard( + title: String, + body: String, + trailing: String? = null, + trailingColor: Color? = null, +) { + 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, + ) + } + } + if (body.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = body, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminHomeScreen.kt new file mode 100644 index 0000000..e137a48 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminHomeScreen.kt @@ -0,0 +1,154 @@ +package top.yeij.cyrene.ui.admin + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.core.auth.SessionState +import top.yeij.cyrene.domain.admin.AdminPage +import top.yeij.cyrene.domain.admin.AdminRole +import top.yeij.cyrene.domain.admin.canAccess +import top.yeij.cyrene.domain.admin.orDash +import top.yeij.cyrene.ui.common.PreferenceCard +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.ChevronForward +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 管理只读监控(C-2):独立的顶层屏幕 + 独立 Scaffold,**不在聊天界面里**(design D15)。 + * + * 结构是「入口页列出可用的管理页 → 逐页进入」的**可扩展壳**:页清单由 + * [AdminPage] 驱动,新增一个管理模块只需在清单里加一项、在 [AdminPageScreen] 里加一个分支, + * 不需要改动本文件的布局。 + * + * 权限降级(api-client §2.0):入口按角色禁用越权页(**不发请求**), + * ViewModel 再做一次同样的短路,服务端返回 403/401 时还有第三层「无权限」状态。 + */ +@Composable +fun AdminHomeScreen( + container: AppContainer, + onBack: () -> Unit = {}, +) { + val session by container.authManager.state.collectAsState() + val rawRole = (session as? SessionState.LoggedIn)?.role + val role = AdminRole.of(rawRole) + var openPage by remember { mutableStateOf(null) } + val viewModel: AdminViewModel = viewModel( + factory = viewModelFactory { initializer { AdminViewModel(container) } }, + ) + + val page = openPage + BackHandler(enabled = page != null) { openPage = null } + + if (page == null) { + AdminIndexScreen( + rawRole = rawRole, + role = role, + onOpen = { openPage = it }, + onBack = onBack, + ) + } else { + AdminPageScreen( + page = page, + role = role, + viewModel = viewModel, + onBack = { openPage = null }, + ) + } +} + +/** 管理入口页:列出全部监控页,越权页显示为禁用的「无权限」行而不是隐藏。 */ +@Composable +private fun AdminIndexScreen( + rawRole: String?, + role: AdminRole, + onOpen: (AdminPage) -> Unit, + onBack: () -> Unit, +) { + AdminPageScaffold(title = "管理员监控", onBack = onBack) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "只读监控 · 当前角色 ${orDash(rawRole)}", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(start = 20.dp, top = 16.dp), + ) + AdminSectionTitle("监控页") + AdminPage.entries.forEach { page -> + PreferenceCard { AdminPageRow(page = page, role = role, onOpen = onOpen) } + Spacer(Modifier.height(8.dp)) + } + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +private fun AdminPageRow( + page: AdminPage, + role: AdminRole, + onOpen: (AdminPage) -> Unit, +) { + if (canAccess(role, page)) { + BasicComponent( + title = page.title, + summary = page.summary, + onClick = { onOpen(page) }, + endActions = { + Icon(imageVector = MiuixIcons.ChevronForward, contentDescription = null) + }, + ) + } else { + BasicComponent( + title = page.title, + summary = "需要 owner 或 admin 权限", + endActions = { + Text( + text = "无权限", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.error, + ) + }, + ) + } +} + +/** 管理页分发:每个页在自己的文件里带 Scaffold,这里只做映射。 */ +@Composable +private fun AdminPageScreen( + page: AdminPage, + role: AdminRole, + viewModel: AdminViewModel, + onBack: () -> Unit, +) { + when (page) { + AdminPage.Overview -> AdminOverviewScreen(viewModel, role, onBack) + AdminPage.Personas -> AdminPersonaScreen(viewModel, role, onBack) + AdminPage.Memories -> AdminMemoryScreen(viewModel, role, onBack) + AdminPage.Backthink -> AdminBackthinkScreen(viewModel, role, onBack) + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminMemoryScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminMemoryScreen.kt new file mode 100644 index 0000000..984e55b --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminMemoryScreen.kt @@ -0,0 +1,59 @@ +package top.yeij.cyrene.ui.admin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import top.yeij.cyrene.domain.admin.AdminPage +import top.yeij.cyrene.domain.admin.AdminRole +import top.yeij.cyrene.domain.admin.memoryScopeLabel +import top.yeij.cyrene.domain.admin.orDash + +/** + * 记忆页:`GET /api/v1/admin/memories`(**仅 `owner`/`admin`**,含私人记忆正文)。 + * `viewer` 进不来:入口已禁用、ViewModel 也短路,服务端还会 403。 + */ +@Composable +fun AdminMemoryScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) { + val state by viewModel.memories.collectAsState() + val reload = { viewModel.load(AdminPage.Memories, role) } + + LaunchedEffect(Unit) { reload() } + + AdminPageScaffold(title = AdminPage.Memories.title, onBack = onBack, onRefresh = reload) { padding -> + AdminLoadShell( + state = state, + onRetry = reload, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { memories -> + if (memories.isEmpty()) { + AdminCenterHint("暂无记忆") + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = memories, key = { it.id }) { memory -> + AdminTextCard( + // scope 是开放集合:未知取值原样显示。 + title = orDash(memoryScopeLabel(memory.scope)), + body = memory.text, + trailing = memory.id, + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt new file mode 100644 index 0000000..ab82f71 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt @@ -0,0 +1,135 @@ +package top.yeij.cyrene.ui.admin + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import top.yeij.cyrene.data.api.dto.AdminMetricsDto +import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto +import top.yeij.cyrene.domain.admin.AdminFailure +import top.yeij.cyrene.domain.admin.AdminPage +import top.yeij.cyrene.domain.admin.AdminRole +import top.yeij.cyrene.domain.admin.formatCounts +import top.yeij.cyrene.domain.admin.formatDouble +import top.yeij.cyrene.domain.admin.formatNanos +import top.yeij.cyrene.domain.admin.formatProps +import top.yeij.cyrene.domain.admin.formatRfc3339 +import top.yeij.cyrene.domain.admin.orDash +import top.yeij.cyrene.domain.admin.traceKindLabel +import top.yeij.cyrene.domain.admin.traceStatusLabel +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。 + */ +@Composable +fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) { + val state by viewModel.overview.collectAsState() + val reload = { viewModel.load(AdminPage.Overview, role) } + + LaunchedEffect(Unit) { reload() } + + AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding -> + val data = state.data + val failure = state.failure + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + when { + failure is AdminFailure.Forbidden -> AdminFailureState(failure, reload) + + data == null && state.loading -> AdminCenterHint("加载中…") + + data == null || (data.metrics == null && data.trace.isEmpty()) -> + AdminFailureState(failure, reload) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + item(key = "__metrics__") { + Column { + AdminSectionTitle("指标") + if (data.metrics != null) { + AdminMetricCard(metricRows(data.metrics)) + Spacer(Modifier.height(8.dp)) + AdminMetricCard(countRows(data.metrics)) + } else { + AdminTextCard("指标不可用", failureMessage(failure)) + } + } + } + item(key = "__trace_title__") { AdminSectionTitle("最近 trace") } + when { + // trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。 + failure != null -> item(key = "__trace_error__") { + AdminTextCard("trace 不可用", failureMessage(failure)) + } + + data.trace.isEmpty() -> item(key = "__trace_empty__") { + AdminInlineHint("暂无 trace span") + } + + else -> items(items = data.trace, key = { it.id }) { span -> + TraceSpanCard(span) + } + } + item(key = "__bottom__") { Spacer(Modifier.height(32.dp)) } + } + } + } + } +} + +private fun metricRows(metrics: AdminMetricsDto): List> = listOf( + "总请求数" to metrics.totalRequests.toString(), + "QPS(近 1 秒)" to formatDouble(metrics.qps), + "平均延迟" to "${formatDouble(metrics.avgLatencyMs)} ms", + "最大延迟" to "${metrics.maxLatencyMs} ms", + "LLM 调用" to metrics.llmCalls.toString(), + "子代理事件" to metrics.subagentEvents.toString(), +) + +private fun countRows(metrics: AdminMetricsDto): List> = listOf( + "按方法" to formatCounts(metrics.perMethod).ifBlank { "-" }, + "按路径" to formatCounts(metrics.perPath).ifBlank { "-" }, + "状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" }, +) + +@Composable +private fun TraceSpanCard(span: AdminTraceSpanDto) { + AdminTextCard( + title = span.name.ifBlank { span.id }, + body = traceBody(span), + trailing = orDash(traceStatusLabel(span.status)), + trailingColor = if (span.status == "error") MiuixTheme.colorScheme.error else null, + ) +} + +private fun traceBody(span: AdminTraceSpanDto): String = buildList { + add("${traceKindLabel(span.kind)} · ${formatNanos(span.durationNs)}") + add("开始 ${formatRfc3339(span.startedAt) ?: orDash(span.startedAt)}") + span.endedAt?.let { add("结束 ${formatRfc3339(it) ?: it}") } + span.parentId?.let { add("父 span $it") } + formatProps(span.props)?.let { add("props $it") } + span.events?.forEach { event -> + val props = formatProps(event.props)?.let { " $it" }.orEmpty() + add("事件 ${event.type} @ ${formatRfc3339(event.time) ?: event.time}$props") + } +}.joinToString("\n") + +private fun failureMessage(failure: AdminFailure?): String = + (failure as? AdminFailure.Other)?.message ?: "请求失败,请重试" diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminPersonaScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminPersonaScreen.kt new file mode 100644 index 0000000..769a1e0 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminPersonaScreen.kt @@ -0,0 +1,112 @@ +package top.yeij.cyrene.ui.admin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import top.yeij.cyrene.data.api.dto.PersonaSummaryDto +import top.yeij.cyrene.data.api.dto.PersonaThinkingDto +import top.yeij.cyrene.domain.admin.AdminPage +import top.yeij.cyrene.domain.admin.AdminRole +import top.yeij.cyrene.domain.admin.moodLabel +import top.yeij.cyrene.domain.admin.orDash +import top.yeij.cyrene.domain.admin.personaModeLabel +import top.yeij.cyrene.domain.admin.triStateLabel +import top.yeij.cyrene.ui.common.PreferenceCard +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 人格页:`GET /api/v1/admin/personas`(`viewer` 可读),含 admin 专属 `thinking` 旋钮。 */ +@Composable +fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) { + val state by viewModel.personas.collectAsState() + val reload = { viewModel.load(AdminPage.Personas, role) } + + LaunchedEffect(Unit) { reload() } + + AdminPageScaffold(title = AdminPage.Personas.title, onBack = onBack, onRefresh = reload) { padding -> + AdminLoadShell( + state = state, + onRetry = reload, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { personas -> + if (personas.isEmpty()) { + AdminCenterHint("暂无人格") + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = personas, key = { it.id }) { PersonaCard(it) } + } + } + } + } +} + +@Composable +private fun PersonaCard(persona: PersonaSummaryDto) { + PreferenceCard { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(persona.name.ifBlank { persona.id }, style = MiuixTheme.textStyles.main) + if (persona.isDefault) { + Text( + text = " · 默认", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.primary, + ) + } + } + PersonaDetail("id", persona.id) + PersonaDetail("character", persona.character.ifBlank { "-" }) + // mode 是开放集合:未知取值原样显示。 + PersonaDetail("mode", personaModeLabel(persona.mode).ifBlank { "-" }) + PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" }) + // mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。 + PersonaDetail("情绪", persona.mood?.takeIf { it.isNotBlank() }?.let(::moodLabel) ?: "暂无") + PersonaDetail("思考", thinkingLabel(persona.thinking)) + persona.avatar?.let { PersonaDetail("avatar", it) } + } + } +} + +@Composable +private fun PersonaDetail(label: String, value: String) { + Spacer(Modifier.height(2.dp)) + Text( + text = "$label:$value", + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) +} + +/** + * `thinking` 未配置时该键**完全不出现**(不是全零对象);已配置时 `showToUser` 是三态, + * null 必须显示成「未配置」,不能拍平成「不显示」。 + */ +private fun thinkingLabel(thinking: PersonaThinkingDto?): String { + if (thinking == null) return "未配置" + return listOf( + "budgetTokens ${thinking.budgetTokens}", + "effort ${orDash(thinking.effort)}", + "mode ${orDash(thinking.mode)}", + "showToUser ${triStateLabel(thinking.showToUser)}", + ).joinToString(" · ") +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminViewModel.kt new file mode 100644 index 0000000..f0774ac --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminViewModel.kt @@ -0,0 +1,114 @@ +package top.yeij.cyrene.ui.admin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto +import top.yeij.cyrene.data.api.dto.AdminMetricsDto +import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto +import top.yeij.cyrene.data.api.dto.MemorySummaryDto +import top.yeij.cyrene.data.api.dto.PersonaSummaryDto +import top.yeij.cyrene.domain.admin.AdminFailure +import top.yeij.cyrene.domain.admin.AdminPage +import top.yeij.cyrene.domain.admin.AdminRole +import top.yeij.cyrene.domain.admin.canAccess +import top.yeij.cyrene.domain.admin.toAdminFailure + +/** + * 单个管理页的取数状态。 + * [data] 为 null 表示**还没拿到**(加载中或失败),空集合表示**确实没有数据**—— + * 两者在 UI 上必须分开,否则 403 会伪装成「暂无数据」。 + */ +data class AdminUiState( + val loading: Boolean = false, + val data: T? = null, + val failure: AdminFailure? = null, +) + +/** 概览页:metrics 与 trace 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */ +data class AdminOverviewData( + val metrics: AdminMetricsDto? = null, + val trace: List = emptyList(), +) + +/** + * 管理只读监控的四页取数(api-client §2)。 + * + * [load] 先按 [AdminRole] 短路:角色不够的页**不发请求**,直接给 + * [AdminFailure.Forbidden],UI 因此能显示「无权限」而不是等一个 403 回来。 + */ +class AdminViewModel(private val container: AppContainer) : ViewModel() { + + private val _overview = MutableStateFlow(AdminUiState()) + val overview: StateFlow> = _overview.asStateFlow() + + private val _personas = MutableStateFlow(AdminUiState>()) + val personas: StateFlow>> = _personas.asStateFlow() + + private val _memories = MutableStateFlow(AdminUiState>()) + val memories: StateFlow>> = _memories.asStateFlow() + + private val _backthink = MutableStateFlow(AdminUiState>()) + val backthink: StateFlow>> = _backthink.asStateFlow() + + /** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */ + fun load(page: AdminPage, role: AdminRole) { + if (!canAccess(role, page)) { + deny(page) + return + } + viewModelScope.launch { + when (page) { + AdminPage.Overview -> loadOverview() + AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() } + AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() } + AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() } + } + } + } + + /** 概览页两个请求并发;任一 403 即整页无权限,其它失败则保留已到的部分。 */ + private suspend fun loadOverview() { + _overview.value = _overview.value.copy(loading = true, failure = null) + val repository = container.adminRepository + val (metrics, trace) = coroutineScope { + val metricsCall = async { repository.metrics() } + val traceCall = async { repository.trace() } + metricsCall.await() to traceCall.await() + } + val failures = listOfNotNull(metrics.exceptionOrNull(), trace.exceptionOrNull()) + .map { it.toAdminFailure() } + _overview.value = AdminUiState( + data = AdminOverviewData( + metrics = metrics.getOrNull(), + trace = trace.getOrNull().orEmpty(), + ), + failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(), + ) + } + + private suspend fun loadList( + target: MutableStateFlow>>, + fetch: suspend () -> Result>, + ) { + target.value = target.value.copy(loading = true, failure = null) + fetch() + .onSuccess { list -> target.value = AdminUiState(data = list) } + .onFailure { e -> target.value = AdminUiState(failure = e.toAdminFailure()) } + } + + private fun deny(page: AdminPage) { + when (page) { + AdminPage.Overview -> _overview.value = AdminUiState(failure = AdminFailure.Forbidden) + AdminPage.Personas -> _personas.value = AdminUiState(failure = AdminFailure.Forbidden) + AdminPage.Memories -> _memories.value = AdminUiState(failure = AdminFailure.Forbidden) + AdminPage.Backthink -> _backthink.value = AdminUiState(failure = AdminFailure.Forbidden) + } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index 8cfb2db..6c8af5a 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -11,14 +11,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.ui.admin.AdminHomeScreen import top.yeij.cyrene.ui.common.MiuixDrawer import top.yeij.cyrene.ui.sessions.SessionDrawerContent import top.yeij.cyrene.ui.sessions.SessionsViewModel import top.yeij.cyrene.ui.settings.SettingsScreen /** - * 聊天页 + 侧边栏(会话切换)+ 设置页。 - * 返回键逐层回退:设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。 + * 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。 + * 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。 */ @Composable fun ChatHomeScreen( @@ -31,18 +32,25 @@ fun ChatHomeScreen( val state by sessionsViewModel.state.collectAsState() var drawerOpen by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) } + var showAdmin by remember { mutableStateOf(false) } + BackHandler(enabled = showAdmin) { showAdmin = false } BackHandler(enabled = showSettings) { showSettings = false } BackHandler(enabled = drawerOpen) { drawerOpen = false } - if (showSettings) { - SettingsScreen( + when { + showAdmin -> AdminHomeScreen( + container = container, + onBack = { showAdmin = false }, + ) + + showSettings -> SettingsScreen( container = container, onBack = { showSettings = false }, onLogout = onLogout, ) - } else { - MiuixDrawer( + + else -> MiuixDrawer( open = drawerOpen, onOpenChange = { drawerOpen = it }, content = { @@ -63,6 +71,10 @@ fun ChatHomeScreen( onCreate = sessionsViewModel::createSession, onDelete = { sessionsViewModel.deleteSession(it.id) }, onOpenIot = { /* P2:IoT 页面 */ }, + onOpenAdmin = { + drawerOpen = false + showAdmin = true + }, onOpenSettings = { drawerOpen = false showSettings = true diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt index 4ea359e..e47467f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionDrawerContent.kt @@ -42,6 +42,7 @@ fun ColumnScope.SessionDrawerContent( onCreate: () -> Unit, onDelete: (SessionItem) -> Unit, onOpenIot: () -> Unit, + onOpenAdmin: () -> Unit, onOpenSettings: () -> Unit, ) { var pendingDelete by remember { mutableStateOf(null) } @@ -98,6 +99,8 @@ fun ColumnScope.SessionDrawerContent( } Spacer(Modifier.height(12.dp)) + PreferenceCard { BasicComponent(title = "管理员监控", onClick = onOpenAdmin) } + Spacer(Modifier.height(8.dp)) PreferenceCard { BasicComponent(title = "IoT(规划中)", onClick = onOpenIot) } Spacer(Modifier.height(8.dp)) PreferenceCard { BasicComponent(title = "App 设置", onClick = onOpenSettings) } diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt new file mode 100644 index 0000000..66b111b --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt @@ -0,0 +1,155 @@ +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 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.ApiClientFactory +import top.yeij.cyrene.data.api.ApiEnvelope + +/** + * 用真实服务端形状的 JSON 校验 DTO 契约(api-client §1/§2): + * `{data}` 信封、开放集合取值、`thinking` 三态、RFC3339 与 Unix 毫秒两种时间。 + */ +class AdminDtoDecodingTest { + + 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()) + + @Test + fun `metrics envelope decodes integer-shaped doubles and count maps`() { + val body = """ + {"data":{"totalRequests":12,"qps":3,"avgLatencyMs":10,"maxLatencyMs":40, + "perMethod":{"GET":12},"perPath":{"/api/v1/x":12},"statusCounts":{"200":11,"500":1}, + "llmCalls":2,"subagentEvents":0}} + """.trimIndent() + + val data = json + .decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), body) + .data!! + + assertEquals(12L, data.totalRequests) + assertEquals(3.0, data.qps, 0.0) + assertEquals(10.0, data.avgLatencyMs, 0.0) + assertEquals(mapOf("GET" to 12L), data.perMethod) + assertEquals(mapOf("200" to 11L, "500" to 1L), data.statusCounts) + } + + @Test + fun `metrics tolerates missing optional fields`() { + val data = json + .decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), """{"data":{}}""") + .data!! + + assertEquals(0L, data.totalRequests) + assertTrue(data.perMethod.isEmpty()) + } + + @Test + fun `trace span keeps unknown kind and arbitrary props`() { + val body = """ + {"data":[{"id":"s1","parentId":"s0","name":"loop","kind":"quantum_hop","status":"ok", + "startedAt":"2026-03-04T04:34:56Z","endedAt":"2026-03-04T04:34:57Z","durationNs":1000000000, + "props":{"model":"gpt","n":3,"flag":true}, + "events":[{"time":"2026-03-04T04:34:56.123456789Z","type":"retry","props":{"attempt":2}}]}]} + """.trimIndent() + + val span = json + .decodeFromString(ApiEnvelope.serializer(traceSpans), body) + .data!! + .single() + + assertEquals("quantum_hop", span.kind) + assertEquals("s0", span.parentId) + assertEquals(1_000_000_000L, span.durationNs) + val props = span.props!! + assertTrue(props.isNotEmpty()) + assertEquals("gpt", (props["model"] as JsonPrimitive).content) + assertEquals(3, (props["n"] as JsonPrimitive).int) + assertTrue((props["flag"] as JsonPrimitive).boolean) + val events = span.events!! + assertEquals(1, events.size) + assertEquals("retry", events.single().type) + assertEquals(2, (events.single().props!!["attempt"] as JsonPrimitive).int) + } + + @Test + fun `trace span tolerates missing parent and events`() { + val span = json + .decodeFromString( + ApiEnvelope.serializer(traceSpans), + """{"data":[{"id":"s1","name":"loop","kind":"llm_call","status":"error","startedAt":"2026-03-04T04:34:56Z","durationNs":1}]}""", + ) + .data!! + .single() + + assertNull(span.parentId) + assertNull(span.endedAt) + assertNull(span.props) + assertNull(span.events) + } + + @Test + fun `persona thinking is absent when unconfigured and keeps tri-state when present`() { + val body = """ + {"data":[ + {"id":"cyrene","name":"昔涟","character":"memokeeper","isDefault":true,"mode":"chat", + "memoryNamespace":"cyrene","mood":"", + "thinking":{"budgetTokens":0,"effort":"","showToUser":null,"mode":""}}, + {"id":"other","name":"?","character":"","isDefault":false,"mode":"telepathy", + "memoryNamespace":"","mood":"euphoric"}]} + """.trimIndent() + + val personas = json + .decodeFromString(ApiEnvelope.serializer(personaList), body) + .data!! + + assertEquals(2, personas.size) + val configured = personas[0].thinking!! + assertNull("null 不能拍平成 false", configured.showToUser) + assertEquals(0, configured.budgetTokens) + + assertNull("未配置的人格 thinking 键完全不出现", personas[1].thinking) + assertEquals("euphoric", personas[1].mood) + // 未知 mode 原样保留,不丢弃、不替换占位符。 + assertEquals("telepathy", personas[1].mode) + } + + @Test + fun `memory scope and backthink millis decode as declared`() { + val memories = json + .decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"shared","text":"hi"}]}""") + .data!! + assertEquals("shared", memories.single().scope) + + val backthink = json + .decodeFromString( + ApiEnvelope.serializer(backthinkList), + """{"data":[{"id":"7","trigger":"cron","shouldNotify":true,"pushed":false,"at":1772598896000}]}""", + ) + .data!! + .single() + + assertNull(backthink.conclusion) + assertTrue(backthink.shouldNotify) + assertFalse(backthink.pushed) + assertEquals(1_772_598_896_000L, backthink.at) + } + + @Test + fun `unknown scope is preserved raw instead of being dropped`() { + val memories = json + .decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"galaxy","text":"hi"}]}""") + .data!! + assertEquals("galaxy", memories.single().scope) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt new file mode 100644 index 0000000..7477899 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt @@ -0,0 +1,71 @@ +package top.yeij.cyrene.domain.admin + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 管理页权限矩阵(api-client §2.0)的客户端降级判定。 */ +class AdminAccessTest { + + @Test + fun `owner can use every page`() { + AdminPage.entries.forEach { page -> + assertTrue(page.name, canAccess(AdminRole.Owner, page)) + } + } + + @Test + fun `admin can use every page`() { + AdminPage.entries.forEach { page -> + assertTrue(page.name, canAccess(AdminRole.Admin, page)) + } + } + + @Test + fun `viewer can read monitor pages only`() { + assertTrue(canAccess(AdminRole.Viewer, AdminPage.Overview)) + assertTrue(canAccess(AdminRole.Viewer, AdminPage.Personas)) + } + + @Test + fun `viewer cannot read memories or backthink`() { + assertFalse(canAccess(AdminRole.Viewer, AdminPage.Memories)) + assertFalse(canAccess(AdminRole.Viewer, AdminPage.Backthink)) + } + + @Test + fun `unknown role degrades to viewer`() { + assertEquals(AdminRole.Viewer, AdminRole.of("superowner")) + assertFalse(canAccess(AdminRole.of("superowner"), AdminPage.Memories)) + } + + @Test + fun `null and blank role degrade to viewer`() { + assertEquals(AdminRole.Viewer, AdminRole.of(null)) + assertEquals(AdminRole.Viewer, AdminRole.of(" ")) + } + + @Test + fun `role parsing tolerates case and surrounding blanks`() { + assertEquals(AdminRole.Owner, AdminRole.of(" OWNER ")) + assertEquals(AdminRole.Admin, AdminRole.of("Admin")) + assertEquals(AdminRole.Viewer, AdminRole.of("viewer")) + } + + @Test + fun `available pages follow the declared order`() { + assertEquals( + listOf(AdminPage.Overview, AdminPage.Personas), + availablePages(AdminRole.Viewer), + ) + assertEquals(AdminPage.entries.toList(), availablePages(AdminRole.Owner)) + } + + @Test + fun `monitor tier is weaker than owner admin tier`() { + assertTrue(AdminTier.Monitor.rank < AdminTier.OwnerAdmin.rank) + assertTrue(AdminRole.Viewer.rank < AdminRole.Admin.rank) + assertTrue(AdminRole.Admin.rank < AdminRole.Owner.rank) + } +} 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 new file mode 100644 index 0000000..b18eddc --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt @@ -0,0 +1,124 @@ +package top.yeij.cyrene.domain.admin + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 两种时间形状 + 未知枚举回落 + 三态展示。 + * + * 时间断言刻意不做时区假设:只比较**同一时刻的两种写法**是否归一,以及是否落成 + * `yyyy-MM-dd HH:mm:ss` 形态,因此在任何时区的机器上都成立。 + */ +class AdminFormatTest { + + private val timestamp = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""") + + @Test + fun `offset and zulu spellings of the same instant agree`() { + assertEquals( + formatRfc3339("2026-03-04T04:34:56Z"), + formatRfc3339("2026-03-04T12:34:56+08:00"), + ) + } + + @Test + fun `rfc3339 with nanosecond precision parses`() { + assertEquals( + formatRfc3339("2026-03-04T04:34:56Z"), + formatRfc3339("2026-03-04T04:34:56.123456789Z"), + ) + } + + @Test + fun `rfc3339 renders as a local timestamp`() { + val formatted = formatRfc3339("2026-03-04T04:34:56Z")!! + assertTrue(formatted, timestamp.matches(formatted)) + } + + @Test + fun `unparsable rfc3339 is returned raw instead of dropped`() { + assertEquals("not-a-time", formatRfc3339("not-a-time")) + } + + @Test + fun `blank rfc3339 becomes null`() { + assertNull(formatRfc3339(null)) + assertNull(formatRfc3339(" ")) + } + + @Test + fun `backthink millis use the epoch-millis parser, not the rfc3339 one`() { + // 同一时刻的两种形状:Unix 毫秒 1772598896000 = 2026-03-04T04:34:56Z + assertEquals( + formatRfc3339("2026-03-04T04:34:56Z"), + formatEpochMillis(1_772_598_896_000L), + ) + } + + @Test + fun `nanos pick a unit by magnitude`() { + assertEquals("512 ns", formatNanos(512)) + assertEquals("1.5 µs", formatNanos(1_500)) + assertEquals("2.5 ms", formatNanos(2_500_000)) + assertEquals("1.25 s", formatNanos(1_250_000_000)) + } + + @Test + fun `counts are sorted by hits descending`() { + assertEquals("GET 5 · POST 2", formatCounts(mapOf("POST" to 2L, "GET" to 5L))) + } + + @Test + fun `counts keep unknown keys verbatim`() { + assertEquals("999 1", formatCounts(mapOf("999" to 1L))) + } + + @Test + fun `whole doubles lose their decimals`() { + assertEquals("12", formatDouble(12.0)) + assertEquals("0", formatDouble(0.0)) + assertEquals("1.5", formatDouble(1.5)) + } + + @Test + fun `unknown enum values render raw`() { + assertEquals("LLM 调用", traceKindLabel("llm_call")) + assertEquals("quantum_hop", traceKindLabel("quantum_hop")) + assertEquals("成功", traceStatusLabel("ok")) + assertEquals("degraded", traceStatusLabel("degraded")) + assertEquals("对话", personaModeLabel("chat")) + assertEquals("telepathy", personaModeLabel("telepathy")) + assertEquals("人格", memoryScopeLabel("persona")) + assertEquals("shared", memoryScopeLabel("shared")) + assertEquals("平静", moodLabel("calm")) + assertEquals("euphoric", moodLabel("euphoric")) + } + + @Test + fun `showToUser keeps three distinct states`() { + assertEquals("未配置", triStateLabel(null)) + assertEquals("显示", triStateLabel(true)) + assertEquals("不显示", triStateLabel(false)) + } + + @Test + fun `props are rendered as compact json without losing keys`() { + assertEquals( + """{"model":"gpt"}""", + formatProps(mapOf("model" to JsonPrimitive("gpt"))), + ) + val two = formatProps( + mapOf( + "a" to JsonPrimitive(1), + "b" to JsonPrimitive(2), + ), + )!! + assertTrue(two, two.contains("\"a\":1") && two.contains("\"b\":2")) + assertNull(formatProps(emptyMap())) + assertNull(formatProps(null)) + } +}