merge: W5 — 新建会话时选角色(POST /sessions {personaId})
This commit is contained in:
commit
557cdb213d
15 files changed
+917
-11
No files matched your search
@@ -10,12 +10,14 @@ import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.MetaRepository
|
||||
import top.yeij.cyrene.data.repository.PersonaRepository
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
import top.yeij.cyrene.data.repository.UploadRepository
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
@@ -147,6 +149,28 @@ class AppContainer(context: Context) {
|
||||
|
||||
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
|
||||
|
||||
private var cachedPersonaApi: PersonaApi? = null
|
||||
private var cachedPersonaBaseUrl: String? = null
|
||||
|
||||
/** 用户侧角色目录(`GET /api/v1/personas`);与 [metaApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val personaApi: PersonaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedPersonaApi
|
||||
if (cached != null && cachedPersonaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = PersonaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedPersonaBaseUrl = baseUrl
|
||||
cachedPersonaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val personaRepository: PersonaRepository get() = PersonaRepository(personaApi)
|
||||
|
||||
fun createChatSocket(): ChatSocket = ChatSocket(
|
||||
baseUrlProvider = { authManager.currentBaseUrl },
|
||||
tokenProvider = authManager::currentToken,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.UserPersonaDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* **用户侧**角色目录(api-client §1 行 9)。任意已认证角色可读,无 token 才 `401`。
|
||||
*
|
||||
* 与 [AdminApi.personas] 分开是有意的:admin 端点每项多一个 `thinking`,而且整条路径要求
|
||||
* `viewer` 以上的管理档——拿它当用户侧接口用,普通用户身上会拿到 `403`。
|
||||
*/
|
||||
interface PersonaApi {
|
||||
|
||||
/** 角色列表;服务端顺序即权威顺序,客户端不重排。 */
|
||||
@GET("api/v1/personas")
|
||||
suspend fun personas(): Response<ApiEnvelope<List<UserPersonaDto>>>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
@@ -26,10 +27,26 @@ interface SessionsApi {
|
||||
@GET("api/v1/sessions")
|
||||
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
|
||||
|
||||
/** 无 body;服务端返回 `201 {data:{sessionId}}`。 */
|
||||
/**
|
||||
* 新建次级会话,**完全不带 body**(= 不绑角色,跟随全局默认角色)。
|
||||
*
|
||||
* 这个无参重载是给「不选角色」这条路留的:`personaId` 为 null 时走它,线上请求与本端支持
|
||||
* 选角之前**逐字节相同**——不会因为多了一个可选字段而改变任何既有行为。
|
||||
* 服务端返回 `201 {data:{sessionId}}`。
|
||||
*/
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
|
||||
|
||||
/**
|
||||
* 新建会话并**在创建时绑定角色**(v0.42)。
|
||||
*
|
||||
* `personaId` 为**未知角色** → `400 bad_request`;非空时响应会回显它。
|
||||
* 会话角色**只在创建时写入、之后不可变**:`PATCH /sessions/{id}` 收到 `personaId` 一律 `400`,
|
||||
* 要换角色只能新开会话(见 [UpdateSessionRequest])。
|
||||
*/
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(@Body body: CreateSessionRequest): Response<ApiEnvelope<CreateSessionResponse>>
|
||||
|
||||
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
|
||||
@DELETE("api/v1/sessions/{id}")
|
||||
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `GET /api/v1/personas`(**用户侧**角色目录,api-client §1 行 9)的一项。
|
||||
*
|
||||
* 与 admin 侧的 [PersonaSummaryDto] 同源同形,但**用户侧不下发 admin 专属的 `thinking`**
|
||||
* ——这正是不能拿 admin 端点当用户侧端点用的原因之一(用户侧不暴露模型调用旋钮)。
|
||||
*
|
||||
* [mode] / [mood] 都是**开放集合**:未知取值原样保留(不丢弃、不报错、不占位符),渲染时才映射。
|
||||
* `mood` 为空串是「该角色暂无被跟踪的情绪」,不是异常,也不伪造默认值。
|
||||
* [chatBackground] 与 admin 侧共用 [ChatBackgroundDto](同一个后端结构)。
|
||||
*/
|
||||
@Serializable
|
||||
data class UserPersonaDto(
|
||||
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,
|
||||
)
|
||||
@@ -31,6 +31,13 @@ data class SessionSummaryDto(
|
||||
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
|
||||
*/
|
||||
val unreadCount: Int = 0,
|
||||
/**
|
||||
* 该会话绑定的角色 id(v0.42,可选);**空/缺省 = 继承全局默认角色**。
|
||||
*
|
||||
* 只读字段:会话角色**只在创建时**写入,之后不可变(`PATCH` 一旦收到 `personaId` 一律 400),
|
||||
* 所以它不可能被本端改。默认值是为了旧快照仍能解码(同 [unreadCount] 的理由)。
|
||||
*/
|
||||
val personaId: String? = null,
|
||||
) {
|
||||
val isMain: Boolean
|
||||
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
|
||||
@@ -42,10 +49,32 @@ data class SessionSummaryDto(
|
||||
}
|
||||
}
|
||||
|
||||
/** `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。 */
|
||||
/**
|
||||
* `POST /api/v1/sessions` → `201 {"data":{"sessionId":"web:<uuid>"}}`。
|
||||
*
|
||||
* [personaId] 只在**创建时显式绑了角色**才回显(v0.42);省略时响应形状与 v0.41 完全一致,
|
||||
* 所以它是可空字段,不能拿它的有无去判断别的事。
|
||||
*/
|
||||
@Serializable
|
||||
data class CreateSessionResponse(
|
||||
val sessionId: String,
|
||||
val personaId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions` 的请求体(v0.42:创建时绑定角色)。
|
||||
*
|
||||
* 服务端把「省略 `personaId`」与「`personaId` 为空」当成同一件事:**不绑**
|
||||
* (`persona_id=''`,继承全局默认角色)。`personaId` 为**未知角色**时回 `400 bad_request`。
|
||||
*
|
||||
* 生产路径上 null 不会走到这里——仓库对 null 走的是**完全不带 body** 的重载
|
||||
* (与本端支持选角之前的线上请求逐字节相同)。这里保留可空字段,是为了让
|
||||
* 「null 序列化后整个键缺席」这件事有一处**可被测试钉住**的表达
|
||||
* (生产 `Json` 的 `explicitNulls = false`)。
|
||||
*/
|
||||
@Serializable
|
||||
data class CreateSessionRequest(
|
||||
val personaId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.UserPersonaDto
|
||||
|
||||
/**
|
||||
* 用户侧角色目录取数层(api-client §1 行 9),供「新建会话时选角色」用。
|
||||
*
|
||||
* 仓库不持有持久化状态:调用方失败时保留上一次拿到的列表——角色目录拿不到只会让弹层少几行,
|
||||
* 不该连带挡掉「用默认角色新建会话」这条降级路径。
|
||||
*/
|
||||
class PersonaRepository(
|
||||
private val api: PersonaApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun personas(): Result<List<UserPersonaDto>> = withContext(dispatcher) {
|
||||
apiCall { api.personas() }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
@@ -30,9 +31,24 @@ class SessionRepository(
|
||||
apiCall { api.listSessions() }
|
||||
}
|
||||
|
||||
/** `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。 */
|
||||
suspend fun createSession(): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.createSession() }.map { it.sessionId }
|
||||
/**
|
||||
* `POST /sessions`,成功返回新建的 `sessionId`(形如 `web:<uuid>`)。
|
||||
*
|
||||
* @param personaId null(或全空白)= **不绑角色**:走**不带 body** 的请求,线上与本端支持选角
|
||||
* 之前逐字节相同。空白按 null 处理是有依据的——契约里「省略 `personaId`」与「`personaId`
|
||||
* 为空」是同一件事(不绑,继承全局默认角色),没必要把 `{"personaId":""}` 发上去。
|
||||
* 非空 = 创建时绑定该角色,未知角色服务端回 `400 bad_request`。
|
||||
*
|
||||
* 角色**只在创建时**能定:`PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,要换角色只能新开。
|
||||
*/
|
||||
suspend fun createSession(personaId: String? = null): Result<String> = withContext(dispatcher) {
|
||||
val persona = personaId?.takeIf { it.isNotBlank() }
|
||||
val created = if (persona == null) {
|
||||
apiCall { api.createSession() }
|
||||
} else {
|
||||
apiCall { api.createSession(CreateSessionRequest(personaId = persona)) }
|
||||
}
|
||||
created.map { it.sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.UserPersonaDto
|
||||
|
||||
/**
|
||||
* 「新建会话时选角色」的纯逻辑:弹层选项的构造、会话行上的角色名回填。
|
||||
* 抽在这里是为了能直接单测——不依赖 Compose,也不依赖服务端。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 弹层里的一项。[personaId] 为 null 就是**「默认角色」**:不绑 personaId,跟随全局默认角色。
|
||||
*
|
||||
* [title] 一定非空(服务端 `name` 为空白时回落到 `id`);[description] 为 null 表示
|
||||
* **没有简述**,此时只显示名字,不编占位符。
|
||||
*/
|
||||
data class PersonaChoice(
|
||||
val personaId: String?,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/** 「默认角色」那一行的标题([PersonaChoice.personaId] 为 null 的那一项恒是它)。 */
|
||||
internal const val DEFAULT_PERSONA_TITLE = "默认角色"
|
||||
|
||||
/** 「默认角色」的简述:只写契约里写明的事。 */
|
||||
private const val DEFAULT_PERSONA_DESCRIPTION = "不绑定角色,跟随全局默认"
|
||||
|
||||
/**
|
||||
* 构造弹层选项:**「默认角色」恒在第一位**,其余按服务端顺序原样排——服务端是排序的权威,
|
||||
* 客户端不重排、不去重、不丢弃不认识的取值。
|
||||
*
|
||||
* 角色名空白时回落到 `id`(与管理页同一口径:宁可显示一个 id,也不显示空行);
|
||||
* `character` 空白 = 没有简述,只显示名字。
|
||||
*/
|
||||
internal fun personaChoices(personas: List<UserPersonaDto>): List<PersonaChoice> = buildList {
|
||||
add(
|
||||
PersonaChoice(
|
||||
personaId = null,
|
||||
title = DEFAULT_PERSONA_TITLE,
|
||||
description = defaultRoleDescription(personas),
|
||||
),
|
||||
)
|
||||
personas.forEach { persona ->
|
||||
add(
|
||||
PersonaChoice(
|
||||
personaId = persona.id,
|
||||
title = persona.name.ifBlank { persona.id },
|
||||
description = persona.character.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「默认角色」那一行的简述:能看出全局默认是哪个角色时**点名**它,免得弹层里两行都叫「默认」
|
||||
* 分不清——服务端 `isDefault` 标记的那一位就是 `personaId` 省略时的回落目标。
|
||||
*
|
||||
* 角色表里没有任何默认标记(或它没有名字也没有 id)时不点名:**不猜**。
|
||||
*/
|
||||
private fun defaultRoleDescription(personas: List<UserPersonaDto>): String {
|
||||
val fallback = personas.firstOrNull { it.isDefault }?.let { it.name.ifBlank { it.id } }
|
||||
return if (fallback.isNullOrBlank()) {
|
||||
DEFAULT_PERSONA_DESCRIPTION
|
||||
} else {
|
||||
"$DEFAULT_PERSONA_DESCRIPTION(当前「$fallback」)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话行上的角色名:**只有真的绑了角色(`personaId` 非空白)才显示**。
|
||||
*
|
||||
* - 绑了 + 角色表里有 → 角色名;
|
||||
* - 绑了 + 角色表里没有(角色表没拉回来 / 角色被改名或删掉)→ **原样显示那个 id**,
|
||||
* 不猜、不硬编码任何映射表;
|
||||
* - `personaId` 空/缺省(继承全局默认角色)→ null(不显示):那是绝大多数会话的常态,
|
||||
* 每行都挂一个「默认」只是噪声。
|
||||
*/
|
||||
internal fun personaLabelFor(personaId: String?, personas: List<PersonaChoice>): String? {
|
||||
val id = personaId?.takeIf { it.isNotBlank() } ?: return null
|
||||
return personas.firstOrNull { it.personaId == id }?.title ?: id
|
||||
}
|
||||
|
||||
/** 会话行的摘要行文本:`[角色名 ·] 最后一条消息预览`;两者都没有 → null(该行不画摘要)。 */
|
||||
internal fun sessionSummaryText(personaLabel: String?, preview: String?): String? =
|
||||
listOfNotNull(personaLabel, preview)
|
||||
.filter { it.isNotBlank() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.joinToString(" · ")
|
||||
|
||||
/**
|
||||
* 角色目录的拉取结果 → 新状态(纯函数)。
|
||||
*
|
||||
* **只有成功才动列表**:失败保留上一次拿到的列表(一次网络抖动不该把好数据擦掉),
|
||||
* 也不把 [SessionsUiState.personasLoaded] 打回 false——拿到过就是拿到过,那时列表里还是有内容的。
|
||||
* 失败只把原因写进 [SessionsUiState.personaError]:弹层里显示它,用默认角色建会话这条路不受影响。
|
||||
*/
|
||||
internal fun SessionsUiState.withPersonaResult(
|
||||
loaded: List<UserPersonaDto>? = null,
|
||||
failure: String? = null,
|
||||
): SessionsUiState = if (loaded != null) {
|
||||
copy(personas = personaChoices(loaded), personasLoaded = true, personaError = null)
|
||||
} else {
|
||||
copy(personaError = failure)
|
||||
}
|
||||
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -48,12 +51,20 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
*
|
||||
* 行内交互:单击切换会话、**长按重命名**、行尾置顶/取消置顶;删除只对次级会话开放。
|
||||
* 标题是服务端的自动标题(首条用户消息生成),没有就落到兜底标题。
|
||||
*
|
||||
* ## 新建会话要先选角色
|
||||
* 「+」不再直接建会话,而是先开 [NewSessionDialog]。理由是角色**只在创建时**能定、之后不可变
|
||||
* (`PATCH /sessions/{id}` 收到 `personaId` 一律 400),选错了只能删掉重开——这一步值得停下来问一次。
|
||||
*
|
||||
* @param onCreate 参数是这次要绑定的角色 id:**null = 「默认角色」**(不绑,跟随全局默认角色)。
|
||||
* 类型必须是 `(String?) -> Unit`:调用方传的是 `SessionsViewModel::createSession` 这个方法引用,
|
||||
* 两边签名逐字对应。
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.SessionDrawerContent(
|
||||
state: SessionsUiState,
|
||||
onSelect: (String) -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
onCreate: (String?) -> Unit,
|
||||
onDelete: (SessionItem) -> Unit,
|
||||
onTogglePin: (SessionItem) -> Unit,
|
||||
onRename: (String, String) -> Unit,
|
||||
@@ -63,6 +74,7 @@ fun ColumnScope.SessionDrawerContent(
|
||||
) {
|
||||
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
|
||||
var pendingRename by remember { mutableStateOf<SessionItem?>(null) }
|
||||
var pendingNewSession by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) {
|
||||
Text("Cyrene", style = MiuixTheme.textStyles.title2)
|
||||
@@ -80,7 +92,7 @@ fun ColumnScope.SessionDrawerContent(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("会话", style = MiuixTheme.textStyles.subtitle, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onCreate) {
|
||||
IconButton(onClick = { pendingNewSession = true }) {
|
||||
Icon(imageVector = MiuixIcons.Add, contentDescription = "新建会话")
|
||||
}
|
||||
}
|
||||
@@ -92,6 +104,9 @@ fun ColumnScope.SessionDrawerContent(
|
||||
items(items = state.sessions, key = { it.id }) { session ->
|
||||
SessionRow(
|
||||
session = session,
|
||||
// 角色名在这里回填而不是在 VM 里拼进 SessionItem:这里能拿到角色表(也就能表达
|
||||
// 「角色表还没回来」的降级),而且 [personaLabelFor] 是一条可单测的纯函数。
|
||||
personaLabel = personaLabelFor(session.personaId, state.personas),
|
||||
active = session.id == state.activeSessionId,
|
||||
onClick = { onSelect(session.id) },
|
||||
onTogglePin = { onTogglePin(session) },
|
||||
@@ -157,8 +172,118 @@ fun ColumnScope.SessionDrawerContent(
|
||||
onDismiss = { pendingRename = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingNewSession) {
|
||||
NewSessionDialog(
|
||||
personas = state.personas,
|
||||
personasLoaded = state.personasLoaded,
|
||||
personaError = state.personaError,
|
||||
onConfirm = { personaId ->
|
||||
onCreate(personaId)
|
||||
pendingNewSession = false
|
||||
},
|
||||
onDismiss = { pendingNewSession = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「新建会话」的角色弹层。
|
||||
*
|
||||
* 用 Compose 的 `Dialog` + Miuix 内容,与同文件的重命名 / 删除确认**同一套做法**:抽屉只有 300dp 宽,
|
||||
* 角色列表需要一块能盖住全屏的空间,也没必要去猜 Miuix 的弹层组件 API。选项行仍是
|
||||
* `PreferenceCard { BasicComponent(...) }`(Miuix 列表项的底座,自身没有背景与圆角)。
|
||||
*
|
||||
* 选中项是一个 `String?`,与 [PersonaChoice.personaId] 同一口径(null = 「默认角色」);
|
||||
* **每次打开都从「默认角色」开始**——新建会话是一次新决定,不沿用上一次的选择。
|
||||
*
|
||||
* 角色目录没拉回来时,这里只剩「默认角色」:**正在拉**与**服务端真的没有角色**要分开说
|
||||
* ([personasLoaded]),失败则把原因也摆出来。降级目标是「照样能用默认角色建会话」,
|
||||
* 而不是把新建会话整个挡掉。
|
||||
*/
|
||||
@Composable
|
||||
private fun NewSessionDialog(
|
||||
personas: List<PersonaChoice>,
|
||||
personasLoaded: Boolean,
|
||||
personaError: String?,
|
||||
onConfirm: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var selected by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MiuixTheme.colorScheme.surface, RoundedCornerShape(20.dp))
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Text("新建会话", style = MiuixTheme.textStyles.title3)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "选择这个会话的角色。角色创建后不可更改,要换角色请新开会话。",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = PERSONA_LIST_MAX_HEIGHT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
personas.forEach { choice ->
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = choice.title,
|
||||
summary = choice.description,
|
||||
onClick = { selected = choice.personaId },
|
||||
endActions = {
|
||||
if (selected == choice.personaId) {
|
||||
Text(
|
||||
text = "✓",
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (!personasLoaded && personaError == null) {
|
||||
// 「还没拉到」不能长得像「服务端没有角色」——那会让人以为角色功能没了。
|
||||
Text(
|
||||
text = "角色列表加载中…",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (personaError != null) {
|
||||
Text(
|
||||
text = "角色列表加载失败:$personaError",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton("取消", onClick = onDismiss)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton("创建", onClick = { onConfirm(selected) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 弹层里角色列表的最大高度:超出就滚动,别把「创建」按钮挤出窄屏的可见区。 */
|
||||
private val PERSONA_LIST_MAX_HEIGHT = 320.dp
|
||||
|
||||
/**
|
||||
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
|
||||
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
|
||||
@@ -176,10 +301,14 @@ fun ColumnScope.SessionDrawerContent(
|
||||
* `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**:
|
||||
* 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的
|
||||
* 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。
|
||||
*
|
||||
* @param personaLabel 该会话绑定的**角色名**([personaLabelFor] 的回填;拿不到就不显示)。
|
||||
* 它挂在摘要行前面——「这个会话是谁在答话」比最后一条消息更该先被看到。
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
session: SessionItem,
|
||||
personaLabel: String?,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onTogglePin: () -> Unit,
|
||||
@@ -189,6 +318,7 @@ private fun SessionRow(
|
||||
val titleColor = BasicComponentDefaults.titleColor().color
|
||||
val summaryColor = BasicComponentDefaults.summaryColor().color
|
||||
val badge = unreadBadgeText(session.unreadCount)
|
||||
val summary = sessionSummaryText(personaLabel, session.lastMessage)
|
||||
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
@@ -254,9 +384,9 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
session.lastMessage?.let { preview ->
|
||||
summary?.let { line ->
|
||||
Text(
|
||||
text = preview,
|
||||
text = line,
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = summaryColor,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -29,6 +29,11 @@ data class SessionItem(
|
||||
val lastMessageAt: Long? = null,
|
||||
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
|
||||
val unreadCount: Int = 0,
|
||||
/**
|
||||
* 该会话绑定的角色 id(服务端 `personaId`,v0.42);null = 未绑(继承全局默认角色)。
|
||||
* 只用来在列表行上回填角色名,见 [personaLabelFor]。
|
||||
*/
|
||||
val personaId: String? = null,
|
||||
)
|
||||
|
||||
data class SessionsUiState(
|
||||
@@ -39,6 +44,17 @@ data class SessionsUiState(
|
||||
val fromCache: Boolean = false,
|
||||
val syncedAt: Long = 0L,
|
||||
val error: String? = null,
|
||||
/**
|
||||
* 角色目录(`GET /personas`),渲染「新建会话」的角色弹层用;第一项恒是「默认角色」。
|
||||
*
|
||||
* 拿不到时它就是空的——弹层里只剩「默认角色」,新建会话照样能走完。这是**有意的降级**:
|
||||
* 角色目录拿不到不该挡掉建会话。
|
||||
*/
|
||||
val personas: List<PersonaChoice> = emptyList(),
|
||||
/** 角色目录**已经成功拉回过一次**。用来把「还没拉到」与「服务端就是没有角色」分开。 */
|
||||
val personasLoaded: Boolean = false,
|
||||
/** 角色目录拉取失败的原因。与会话列表的 [error] 分开:它只影响弹层里那几行,不动列表状态。 */
|
||||
val personaError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -134,6 +150,24 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉角色目录(用户侧 `GET /personas`),供「新建会话」的角色弹层用。
|
||||
*
|
||||
* **跟着 [refresh] 走**:初始化 / 新建 / 删除 / 抽屉打开,与会话列表同一节奏——角色也可能在
|
||||
* 管理侧被改名或增删,而那些改动**没有任何 WS 帧**会推过来,只能靠这里对齐。
|
||||
*
|
||||
* 失败**只记 [SessionsUiState.personaError]**,并**保留上一次拿到的列表**(一次网络抖动不该把
|
||||
* 好数据擦掉);不碰会话列表的错误态——角色目录拿不到只影响「能不能选角色」,
|
||||
* 用默认角色新建会话这条路照样通。
|
||||
*/
|
||||
private fun loadPersonas() {
|
||||
viewModelScope.launch {
|
||||
container.personaRepository.personas()
|
||||
.onSuccess { list -> _state.update { it.withPersonaResult(loaded = list) } }
|
||||
.onFailure { e -> _state.update { it.withPersonaResult(failure = e.userMessage()) } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取服务端列表。
|
||||
*
|
||||
@@ -141,6 +175,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
* 只是去对齐服务端状态的场景(抽屉打开、改名/置顶之后),避免闪一下横幅。
|
||||
*/
|
||||
fun refresh(silent: Boolean = false) {
|
||||
loadPersonas()
|
||||
viewModelScope.launch {
|
||||
if (!silent) _state.update { it.copy(loading = true) }
|
||||
container.sessionRepository.listSessions()
|
||||
@@ -195,9 +230,15 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
pushReadCursor(sessionId)
|
||||
}
|
||||
|
||||
fun createSession() {
|
||||
/**
|
||||
* 新建会话;[personaId] 为 null 表示**「默认角色」**(不绑 personaId,跟随全局默认角色)。
|
||||
*
|
||||
* 角色**只在创建时**能定:服务端 `PATCH /sessions/{id}` 一旦收到 `personaId` 就回 `400`,
|
||||
* 所以本端没有「会话内换角色」这个动作,选角只发生在这里。
|
||||
*/
|
||||
fun createSession(personaId: String? = null) {
|
||||
viewModelScope.launch {
|
||||
container.sessionRepository.createSession()
|
||||
container.sessionRepository.createSession(personaId)
|
||||
.onSuccess { sessionId ->
|
||||
refresh()
|
||||
select(sessionId)
|
||||
@@ -340,6 +381,8 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
|
||||
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
|
||||
lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 },
|
||||
unreadCount = unreadCount,
|
||||
// 空串与缺省同义(契约:空/缺省 = 继承全局默认角色),统一收成 null——UI 上只有「绑没绑」两种。
|
||||
personaId = personaId?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user