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:
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user