W5: 新建会话时选角色(persona)

改了什么:
- `POST /api/v1/sessions` 支持可选 `personaId`:null(或全空白)走**不带 body** 的重载
  (与选角之前逐字节相同),非空才发 `{"personaId":"…"}`;新增 CreateSessionRequest /
  CreateSessionResponse.personaId / SessionSummaryDto.personaId。
- 新增**用户侧**角色目录取数:PersonaApi + UserPersonaDto + PersonaRepository(沿用 apiCall/Result)。
  刻意不用 admin 的 PersonaSummaryDto/AdminApi:用户侧不下发 admin 专属 thinking,admin 端点还要管理档。
- 抽屉「+」不再直接建会话,而是先开角色弹层(「默认角色」+ 各角色,角色名 + character 简述,
  失败/加载中各自有话说);确认后按所选创建。角色表拿不到时降级为「只有默认角色」,不挡建会话。
- 会话行摘要前缀该会话绑定的角色名:能拿到角色表就显示名字,拿不到就原样显示 id,未绑就不显示。

为什么:
- 此前 createSession 完全没有 body,新建的会话一律是默认角色,后端 v0.42 起的多角色能力用不上。
- 会话角色**创建后不可变**(`PATCH /sessions/{id}` 一旦收到 personaId 就 400),所以选角这一步
  只在建会话时问一次,也必须在建会话时问。

测试 353 → 385(+32),assembleDebug 与 testDebugUnitTest 均通过。
变异验证:把仓库改回「null 也发 body」后,SessionRepositoryCreateTest 有 2 条断言 FAIL。
This commit is contained in:
AskaEth committed 2026-10-04 18:06:11 +08:00
1 parent 664fd8dcef
commit 217cfe3d5f
15 files changed
+917 -11

No files matched your search

@@ -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 }
}
/**