chore: 机器特定的构建配置移出仓库

aapt2FromMavenOverride(ARM64 专用绝对路径)与 sdkDownload 移到本机 ~/.gradle/gradle.properties,仓库配置保持跨平台可用。
This commit is contained in:
AskaEth committed 2026-09-08 20:49:00 +08:00
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 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() },
)
}
@@ -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() },
)
}
@@ -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 <T : Any> create(
baseUrl: String,
service: Class<T>,
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://`。 */
@@ -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
View File
@@ -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