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 003c3a5..43aba42 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -3,9 +3,45 @@ package top.yeij.cyrene.app import android.content.Context import top.yeij.cyrene.core.auth.AuthManager import top.yeij.cyrene.core.auth.SessionStore +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.SessionsApi +import top.yeij.cyrene.data.repository.MessageRepository +import top.yeij.cyrene.data.repository.SessionRepository +import top.yeij.cyrene.data.ws.ChatSocket /** 手写 DI 容器(D:不引 Hilt)。 */ class AppContainer(context: Context) { + val sessionStore: SessionStore = SessionStore(context.applicationContext) val authManager: AuthManager = AuthManager(sessionStore) + + private var cachedBaseUrl: String? = null + private var cachedSessionsApi: SessionsApi? = null + + /** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */ + val sessionsApi: SessionsApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedSessionsApi + if (cached != null && cachedBaseUrl == baseUrl) return cached + val created = ApiClientFactory.create( + baseUrl = baseUrl, + service = SessionsApi::class.java, + tokenProvider = { sessionStore.load().token }, + onUnauthorized = { authManager.onSessionExpired() }, + ) + cachedBaseUrl = baseUrl + cachedSessionsApi = created + return created + } + + val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi) + + val messageRepository: MessageRepository get() = MessageRepository(sessionsApi) + + fun createChatSocket(): ChatSocket = ChatSocket( + baseUrlProvider = { authManager.currentBaseUrl }, + tokenProvider = { sessionStore.load().token }, + onAuthFailed = { authManager.onSessionExpired() }, + ) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt index c8cd05c..4926996 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt @@ -5,8 +5,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import top.yeij.cyrene.core.auth.SessionState +import top.yeij.cyrene.ui.chat.ChatScreen import top.yeij.cyrene.ui.common.LoadingScreen -import top.yeij.cyrene.ui.home.HomeScreen import top.yeij.cyrene.ui.login.LoginScreen @Composable @@ -20,7 +20,8 @@ fun CyreneApp(container: AppContainer) { when (state) { SessionState.Unknown -> LoadingScreen() SessionState.LoggedOut -> LoginScreen(container) - is SessionState.LoggedIn -> HomeScreen( + is SessionState.LoggedIn -> ChatScreen( + container = container, onLogout = { container.authManager.logout() }, ) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt index a43da49..3ade1d8 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt @@ -19,11 +19,20 @@ object ApiClientFactory { coerceInputValues = true } + /** 登录接口(不注入 token)。 */ fun create( baseUrl: String, tokenProvider: () -> String? = { null }, onUnauthorized: () -> Unit = {}, - ): CyreneApi { + ): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, onUnauthorized) + + /** 通用入口:按需构建任意 Retrofit service。 */ + fun create( + baseUrl: String, + service: Class, + tokenProvider: () -> String? = { null }, + onUnauthorized: () -> Unit = {}, + ): T { val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) @@ -46,7 +55,7 @@ object ApiClientFactory { .client(client) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(CyreneApi::class.java) + .create(service) } /** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */ diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..abb0dc8 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -0,0 +1,189 @@ +package top.yeij.cyrene.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +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 kotlinx.coroutines.flow.distinctUntilChanged +import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.data.ws.WsState +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.basic.Sidebar +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +fun ChatScreen( + container: AppContainer, + onOpenDrawer: () -> Unit = {}, + onLogout: () -> Unit = {}, +) { + val viewModel: ChatViewModel = viewModel( + factory = viewModelFactory { initializer { ChatViewModel(container) } }, + ) + val state by viewModel.state.collectAsState() + val listState = rememberLazyListState() + + // reverseLayout 下「列表末尾」就是屏幕顶部;接近末尾时加载更早的一页。 + LaunchedEffect(listState) { + snapshotFlow { + val info = listState.layoutInfo + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 2 + } + .distinctUntilChanged() + .collect { nearTop -> if (nearTop) viewModel.loadOlder() } + } + + Scaffold( + topBar = { + SmallTopAppBar( + title = "与昔涟", + subtitle = statusLabel(state), + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单") + } + }, + actions = { + TextButton("登出", onClick = onLogout) + }, + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + when { + state.loading && state.messages.isEmpty() -> CenterHint("加载中…") + state.messages.isEmpty() -> CenterHint("还没有消息") + else -> LazyColumn( + state = listState, + reverseLayout = true, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(items = state.messages.asReversed(), key = { it.id }) { message -> + MessageBubble(message) + } + if (state.hasMore) { + item(key = "__load_older__") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (state.loadingOlder) "加载更早的消息…" else "上滑加载更早的消息", + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } + } + } + } + + state.error?.let { message -> + Text( + text = message, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .background(MiuixTheme.colorScheme.surfaceContainer) + .padding(12.dp), + ) + } + } + } +} + +@Composable +private fun MessageBubble(message: HistoryMessageDto) { + val isUser = message.role == "user" + val bubbleColor = if (isUser) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.surfaceContainer + val textColor = if (isUser) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onSurfaceContainer + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start, + ) { + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .background(color = bubbleColor, shape = RoundedCornerShape(18.dp)) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text( + text = message.content, + style = MiuixTheme.textStyles.main, + color = textColor, + ) + } + messageSourceLabel(message)?.let { source -> + Text( + text = source, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + modifier = Modifier.padding(top = 3.dp, start = 6.dp, end = 6.dp), + ) + } + } +} + +@Composable +private fun CenterHint(text: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = text, + style = MiuixTheme.textStyles.main, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } +} + +private fun statusLabel(state: ChatUiState): String = when { + state.busyMode != null && state.queueDepth > 0 -> "正在思考… 已排队 ${state.queueDepth} 条" + state.busyMode != null -> "正在思考…" + else -> when (state.connection) { + WsState.Connected -> "已连接" + WsState.Connecting -> "连接中…" + WsState.Reconnecting -> "重连中…" + WsState.Offline -> "离线" + WsState.Idle -> "" + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..2ae4ccb --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -0,0 +1,177 @@ +package top.yeij.cyrene.ui.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.data.repository.MessageRepository +import top.yeij.cyrene.data.repository.mergeMessages +import top.yeij.cyrene.data.ws.ChatSocket +import top.yeij.cyrene.data.ws.WsEvent +import top.yeij.cyrene.data.ws.WsState + +const val MAIN_SESSION_ID = "web" + +data class ChatUiState( + /** 升序(旧 → 新);渲染时反向喂给 reverseLayout 的列表。 */ + val messages: List = emptyList(), + val loading: Boolean = true, + val loadingOlder: Boolean = false, + val hasMore: Boolean = true, + val error: String? = null, + val connection: WsState = WsState.Idle, + /** 编排忙碌态(`idle`/`thinking`/`executing`/`subagent`),null = 空闲。 */ + val busyMode: String? = null, + val queueDepth: Int = 0, +) + +/** + * M2:只读主会话。 + * + * 数据来源三路合并(都走 `mergeMessages` 按 `id` 去重): + * 1. REST 首屏 `loadLatest`(权威、带分页游标) + * 2. WS 首帧 `history`(与 REST 重叠,靠 id 去重收敛) + * 3. WS 增量 `message_persisted`(其它端/本端落库后的消息) + * + * 注意(D14):结构化流帧不带 `sessionId`,同一时刻只允许一个会话在流式; + * 当前只有主会话,暂不涉及归属问题。 + */ +class ChatViewModel( + private val container: AppContainer, + private val sessionId: String = MAIN_SESSION_ID, +) : ViewModel() { + + private val _state = MutableStateFlow(ChatUiState()) + val state: StateFlow = _state.asStateFlow() + + private var socket: ChatSocket? = null + private var repository: MessageRepository? = null + private var oldestLoadedAt: Long? = null + + init { + start() + } + + private fun start() { + val repo = container.messageRepository + repository = repo + + viewModelScope.launch { + repo.loadLatest(sessionId) + .onSuccess { page -> + oldestLoadedAt = page.oldestCreatedAt + _state.update { + it.copy( + messages = mergeMessages(it.messages, page.messages), + loading = false, + hasMore = page.hasMore, + error = null, + ) + } + } + .onFailure { e -> + _state.update { it.copy(loading = false, error = e.userMessage()) } + } + } + + val ws = container.createChatSocket() + socket = ws + viewModelScope.launch { + ws.state.collect { connection -> _state.update { it.copy(connection = connection) } } + } + viewModelScope.launch { + ws.events.collect(::onEvent) + } + ws.connect() + } + + private fun onEvent(event: WsEvent) { + when (event) { + is WsEvent.History -> { + val incoming = event.messages + if (incoming.isEmpty()) { + _state.update { it.copy(loading = false) } + } else { + val oldest = incoming.minOf { it.createdAt } + oldestLoadedAt = oldestLoadedAt?.let { minOf(it, oldest) } ?: oldest + _state.update { + it.copy(messages = mergeMessages(it.messages, incoming), loading = false) + } + } + } + + is WsEvent.MessagePersisted -> { + val message = event.message ?: return + if (message.sessionId != sessionId) return + _state.update { it.copy(messages = mergeMessages(it.messages, listOf(message))) } + } + + is WsEvent.SessionBusy -> if (event.sessionId == sessionId) { + _state.update { it.copy(busyMode = event.mode) } + } + + is WsEvent.PendingAck -> if (event.sessionId == sessionId) { + _state.update { it.copy(busyMode = event.mode, queueDepth = event.queueDepth) } + } + + is WsEvent.PendingDrain -> if (event.sessionId == sessionId) { + _state.update { it.copy(queueDepth = event.queueDepth) } + } + + is WsEvent.SessionIdle -> if (event.sessionId == sessionId) { + _state.update { it.copy(busyMode = null, queueDepth = 0) } + } + + is WsEvent.SessionInterrupted -> if (event.sessionId == sessionId) { + _state.update { it.copy(busyMode = null, queueDepth = 0) } + } + + is WsEvent.Error -> _state.update { it.copy(error = event.message) } + + else -> Unit // 流式/思维链/工具卡等留到 M3、M4 + } + } + + /** 上拉加载更早的一页(`before` 游标 = 当前已加载最旧一条的 createdAt)。 */ + fun loadOlder() { + val repo = repository ?: return + val before = oldestLoadedAt ?: return + val snapshot = _state.value + if (snapshot.loadingOlder || !snapshot.hasMore) return + + _state.update { it.copy(loadingOlder = true) } + viewModelScope.launch { + repo.loadPage(sessionId, before = before) + .onSuccess { page -> + if (page.oldestCreatedAt != null) oldestLoadedAt = page.oldestCreatedAt + _state.update { + it.copy( + messages = mergeMessages(it.messages, page.messages), + loadingOlder = false, + hasMore = page.hasMore && page.messages.isNotEmpty(), + ) + } + } + .onFailure { e -> + _state.update { it.copy(loadingOlder = false, error = e.userMessage()) } + } + } + } + + fun dismissError() = _state.update { it.copy(error = null) } + + override fun onCleared() { + socket?.close() + socket = null + super.onCleared() + } +} + +private fun Throwable.userMessage(): String = + (this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试" diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt new file mode 100644 index 0000000..6a1c6fd --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt @@ -0,0 +1,34 @@ +package top.yeij.cyrene.ui.chat + +import top.yeij.cyrene.data.api.dto.HistoryMessageDto + +private val platformLabels = mapOf( + "web" to "Web", + "kook" to "KOOK", + "weixin-ilink" to "微信", + "qq-obv11" to "QQ", + "telegram" to "Telegram", + "webhook" to "Webhook", +) + +/** + * 来源角标(S1.3):每条消息默认显示来源。 + * 优先用 `deviceId` 区分具体端,再回落到 `platform`;两者都没有则返回 null(不显示)。 + */ +fun messageSourceLabel(message: HistoryMessageDto): String? { + val deviceId = message.deviceId + if (!deviceId.isNullOrBlank()) { + if (deviceId.startsWith("voiceassist")) return "来自安卓" + if (deviceId.startsWith("web:")) { + val browser = deviceId.removePrefix("web:").substringBefore(':') + return "来自 Web($browser)" + } + return "来自 $deviceId" + } + + val platform = message.platform + if (!platform.isNullOrBlank()) { + return "来自 ${platformLabels[platform] ?: platform}" + } + return null +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/home/HomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/home/HomeScreen.kt deleted file mode 100644 index ccacd33..0000000 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/home/HomeScreen.kt +++ /dev/null @@ -1,52 +0,0 @@ -package top.yeij.cyrene.ui.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -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.basic.Sidebar -import top.yukonga.miuix.kmp.theme.MiuixTheme - -@Composable -fun HomeScreen(onLogout: () -> Unit = {}) { - Scaffold( - topBar = { - SmallTopAppBar( - title = "Cyrene", - navigationIcon = { - IconButton(onClick = { /* M5:打开侧边栏 */ }) { - Icon(imageVector = MiuixIcons.Basic.Sidebar, contentDescription = "菜单") - } - }, - actions = { - TextButton("登出", onClick = onLogout) - }, - ) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = "与昔涟", style = MiuixTheme.textStyles.title2) - Text( - text = "会话列表 · 规划中", - style = MiuixTheme.textStyles.footnote1, - color = MiuixTheme.colorScheme.onSurfaceContainerVariant, - ) - } - } -} diff --git a/gradle.properties b/gradle.properties index f459e5b..5b8a2cb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,10 +6,4 @@ org.gradle.configuration-cache=false android.useAndroidX=true android.nonTransitiveRClass=true -# 本机无法访问 dl.google.com,禁止 AGP 尝试联网下载 SDK 组件(否则会卡住) -android.builder.sdkDownload=false - kotlin.code.style=official - -# ARM64 机器:Google Maven 只提供 x86_64 的 aapt2,改用本机 ARM64 版本 -android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/37.0.0/aapt2