chore: 机器特定的构建配置移出仓库
aapt2FromMavenOverride(ARM64 专用绝对路径)与 sdkDownload 移到本机 ~/.gradle/gradle.properties,仓库配置保持跨平台可用。
This commit is contained in:
1 parent
84de51c7b7
commit
253913ad82
8 files changed
+450
-62
No files matched your search
@@ -3,9 +3,45 @@ package top.yeij.cyrene.app
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import top.yeij.cyrene.core.auth.AuthManager
|
import top.yeij.cyrene.core.auth.AuthManager
|
||||||
import top.yeij.cyrene.core.auth.SessionStore
|
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)。 */
|
/** 手写 DI 容器(D:不引 Hilt)。 */
|
||||||
class AppContainer(context: Context) {
|
class AppContainer(context: Context) {
|
||||||
|
|
||||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
||||||
val authManager: AuthManager = AuthManager(sessionStore)
|
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() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,8 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import top.yeij.cyrene.core.auth.SessionState
|
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.common.LoadingScreen
|
||||||
import top.yeij.cyrene.ui.home.HomeScreen
|
|
||||||
import top.yeij.cyrene.ui.login.LoginScreen
|
import top.yeij.cyrene.ui.login.LoginScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -20,7 +20,8 @@ fun CyreneApp(container: AppContainer) {
|
|||||||
when (state) {
|
when (state) {
|
||||||
SessionState.Unknown -> LoadingScreen()
|
SessionState.Unknown -> LoadingScreen()
|
||||||
SessionState.LoggedOut -> LoginScreen(container)
|
SessionState.LoggedOut -> LoginScreen(container)
|
||||||
is SessionState.LoggedIn -> HomeScreen(
|
is SessionState.LoggedIn -> ChatScreen(
|
||||||
|
container = container,
|
||||||
onLogout = { container.authManager.logout() },
|
onLogout = { container.authManager.logout() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,20 @@ object ApiClientFactory {
|
|||||||
coerceInputValues = true
|
coerceInputValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 登录接口(不注入 token)。 */
|
||||||
fun create(
|
fun create(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
tokenProvider: () -> String? = { null },
|
tokenProvider: () -> String? = { null },
|
||||||
onUnauthorized: () -> Unit = {},
|
onUnauthorized: () -> Unit = {},
|
||||||
): CyreneApi {
|
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, onUnauthorized)
|
||||||
|
|
||||||
|
/** 通用入口:按需构建任意 Retrofit service。 */
|
||||||
|
fun <T : Any> create(
|
||||||
|
baseUrl: String,
|
||||||
|
service: Class<T>,
|
||||||
|
tokenProvider: () -> String? = { null },
|
||||||
|
onUnauthorized: () -> Unit = {},
|
||||||
|
): T {
|
||||||
val client = OkHttpClient.Builder()
|
val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
@@ -46,7 +55,7 @@ object ApiClientFactory {
|
|||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
.create(CyreneApi::class.java)
|
.create(service)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
|
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
|
||||||
|
|||||||
@@ -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 -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HistoryMessageDto> = 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<ChatUiState> = _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 ?: "加载失败,请稍后重试"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,4 @@ org.gradle.configuration-cache=false
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
# 本机无法访问 dl.google.com,禁止 AGP 尝试联网下载 SDK 组件(否则会卡住)
|
|
||||||
android.builder.sdkDownload=false
|
|
||||||
|
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
# ARM64 机器:Google Maven 只提供 x86_64 的 aapt2,改用本机 ARM64 版本
|
|
||||||
android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/37.0.0/aapt2
|
|
||||||
Reference in new issue
Block a user