diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce6cab1..80146b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,9 @@ dependencies { implementation("com.google.android.material:material:1.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp-sse:4.12.0") + implementation("io.noties.markwon:core:4.6.2") + implementation("io.noties.markwon:ext-tables:4.6.2") + implementation("io.noties.markwon:html:4.6.2") // 👈 HTML标签支持 implementation("com.github.getActivity:XXPermissions:18.2") implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) @@ -78,6 +81,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") // 👈 新增这一行以支持 Compose 中的 viewModel() testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/stand/standapp/MainActivity.kt b/app/src/main/java/com/stand/standapp/MainActivity.kt index e82fad8..b45722a 100644 --- a/app/src/main/java/com/stand/standapp/MainActivity.kt +++ b/app/src/main/java/com/stand/standapp/MainActivity.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.os.Bundle import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity +import android.widget.FrameLayout import org.mozilla.geckoview.GeckoResult import org.mozilla.geckoview.GeckoRuntime import org.mozilla.geckoview.GeckoSession @@ -190,6 +191,20 @@ class MainActivity : AppCompatActivity() { // 启动时检查更新 UpdateManager(this).checkUpdate() + // 👈 动态注入 Compose 容器,让 FloatingChatWidget 呈现在最上层! + val composeView = androidx.compose.ui.platform.ComposeView(this).apply { + setContent { + com.stand.standapp.ui.chat.FloatingChatWidget() + } + } + findViewById(android.R.id.content)?.addView( + composeView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } catch (e: Exception) { Timber.tag("MainActivity").e(e, "Fatal Crash") } diff --git a/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt b/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt index e409853..d34e50e 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt @@ -1,6 +1,7 @@ package com.stand.standapp.ui.chat import android.app.Application +import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.stand.standapp.AppConfig @@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject import java.util.UUID class ChatViewModel(application: Application) : AndroidViewModel(application) { @@ -27,22 +30,155 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() + // 👈 声明一个正在加载的状态,保证上一个问题没结束前无法再次发送提问! + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + // 👈 额外声明一个当前协程控制作业,用于物理取消/中止 SSE 对话! + private var activeChatJob: kotlinx.coroutines.Job? = null + + private val prefs = application.getSharedPreferences("ai_chat_cache", Context.MODE_PRIVATE) + init { - createNewSession() + // 👈 将数据加载移到IO线程,避免阻塞主线程导致UI卡顿 + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + loadCacheFromLocal() + } + } + + private fun loadCacheFromLocal() { + try { + val sessionsJson = prefs.getString("cache_sessions", "") + val messagesJson = prefs.getString("cache_messages", "") + + if (!sessionsJson.isNullOrBlank()) { + val sessionsArray = JSONArray(sessionsJson) + val restoredSessions = mutableListOf() + for (i in 0 until sessionsArray.length()) { + val obj = sessionsArray.getJSONObject(i) + restoredSessions.add( + ChatSession( + id = obj.getString("id"), + title = obj.getString("title"), + timestamp = obj.getLong("timestamp") + ) + ) + } + _sessions.value = restoredSessions + if (restoredSessions.isNotEmpty()) { + _currentSessionId.value = restoredSessions.first().id + } + } + + if (!messagesJson.isNullOrBlank()) { + val messagesArray = JSONArray(messagesJson) + val restoredMessages = mutableListOf() + for (i in 0 until messagesArray.length()) { + val obj = messagesArray.getJSONObject(i) + restoredMessages.add( + ChatMessage( + id = obj.getString("id"), + sessionId = obj.getString("sessionId"), + role = ChatMessage.Role.valueOf(obj.getString("role")), + content = obj.getString("content"), + isStreaming = false + ) + ) + } + _messages.value = restoredMessages + } + } catch (e: Exception) { + e.printStackTrace() + } + + // 如果本地什么都没有,自动开启第一个默认会话 + if (_sessions.value.isEmpty()) { + createNewSession() + } + } + + private fun saveCacheToLocal() { + try { + // 保存会话列表 + val sessionsArray = JSONArray() + _sessions.value.forEach { session -> + val obj = JSONObject() + obj.put("id", session.id) + obj.put("title", session.title) + obj.put("timestamp", session.timestamp) + sessionsArray.put(obj) + } + + // 保存消息列表 + val messagesArray = JSONArray() + _messages.value.forEach { msg -> + val obj = JSONObject() + obj.put("id", msg.id) + obj.put("sessionId", msg.sessionId) + obj.put("role", msg.role.name) + obj.put("content", msg.content) + messagesArray.put(obj) + } + + prefs.edit() + .putString("cache_sessions", sessionsArray.toString()) + .putString("cache_messages", messagesArray.toString()) + .apply() + } catch (e: Exception) { + e.printStackTrace() + } + } + + // 👈 用户手动中断当前正在生成的 AI 回答的方法! + fun cancelActiveStreaming() { + activeChatJob?.cancel() // 物理取消协程作业,断开 SSE OkHttp 连接并关闭流! + activeChatJob = null + + // 将当前正在流式输出的 AI 消息状态重置为完成,防止气泡卡死在 streaming 样式 + _messages.value = _messages.value.map { msg -> + if (msg.isStreaming) { + msg.copy(isStreaming = false) + } else msg + } + + _isLoading.value = false // 释放锁,允许用户立刻开始下一次提问! + saveCacheToLocal() // 强制存盘归档 } fun createNewSession() { val newSession = ChatSession() - _sessions.value = listOf(newSession) + _sessions.value + // 👈 限制最多保留10组对话历史,超出时删除最旧的会话 + val trimmedSessions = if (_sessions.value.size >= 10) { + val sessionsToKeep = _sessions.value.take(9) + // 同时删除最旧会话的消息 + val removedSessionIds = _sessions.value.drop(9).map { it.id }.toSet() + _messages.value = _messages.value.filter { it.sessionId !in removedSessionIds } + sessionsToKeep + } else { + _sessions.value + } + _sessions.value = listOf(newSession) + trimmedSessions _currentSessionId.value = newSession.id + saveCacheToLocal() } fun switchSession(sessionId: String) { _currentSessionId.value = sessionId } + fun clearAllSessions() { + _sessions.value = emptyList() + _messages.value = emptyList() + _currentSessionId.value = null + prefs.edit().clear().apply() + createNewSession() + } + fun sendMessage(content: String) { + if (_isLoading.value) return // 👈 拦截提问:上一个回答正在流式写时,绝对不允许重复提交! + val sessionId = _currentSessionId.value ?: return + _isLoading.value = true // 👈 锁定发送状态 // 1. 如果是新会话,更新标题 updateSessionTitleIfFirstMessage(sessionId, content) @@ -55,17 +191,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val aiMsg = ChatMessage(id = aiMsgId, sessionId = sessionId, role = ChatMessage.Role.AI, content = "", isStreaming = true) _messages.value = _messages.value + userMsg + aiMsg + saveCacheToLocal() // 👈 用户发出消息,存入本地历史记录 // 4. 发起网络请求,流式追加内容 - viewModelScope.launch { + activeChatJob = viewModelScope.launch { val token = AppConfig.getAccessToken(getApplication()) - repository.streamChat(content, token) + + // 提取当前会话(除了刚发出的这条和尚未生成的AI回复之外的)历史对话上下文 + // 👈 限制最多取最近10轮对话(20条消息),避免token超限 + val currentSessionMsgs = _messages.value + .filter { it.sessionId == sessionId && it.id != userMsg.id && it.id != aiMsgId && !it.isStreaming } + .takeLast(20) + + // 👈 使用 JSONArray 构建历史记录,确保特殊字符被正确转义 + val historyArray = JSONArray() + currentSessionMsgs.forEach { chatMessage -> + val roleStr = if (chatMessage.role == ChatMessage.Role.USER) "user" else "assistant" + val historyItem = JSONObject() + historyItem.put("role", roleStr) + historyItem.put("content", chatMessage.content) + historyArray.put(historyItem) + } + val historyJson = if (currentSessionMsgs.isEmpty()) "" else historyArray.toString() + + repository.streamChat(getApplication(), content, token, historyJson) .catch { e -> appendAiMessageChunk(aiMsgId, "\n[请求异常: ${e.message}]") finalizeAiMessage(aiMsgId) + _isLoading.value = false // 发生异常释放锁 } .onCompletion { finalizeAiMessage(aiMsgId) + _isLoading.value = false // 传输完成释放锁 } .collect { chunk -> appendAiMessageChunk(aiMsgId, chunk) @@ -87,6 +244,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { msg.copy(isStreaming = false) } else msg } + saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘 } private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) { diff --git a/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt b/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt index e0117bb..1f1a2ff 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt @@ -1,7 +1,10 @@ package com.stand.standapp.ui.chat import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -12,60 +15,124 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import com.stand.standapp.ui.chat.components.ChatScaffold +import kotlin.math.roundToInt @Composable fun FloatingChatWidget() { var isExpanded by remember { mutableStateOf(false) } val configuration = LocalConfiguration.current + + // 1. 宽度缩小到屏幕的 45% (高品质窄窗设计) + val maxWidth = (configuration.screenWidthDp * 0.45f).dp val maxHeight = (configuration.screenHeightDp * 0.75f).dp + // 2. 悬浮圆圈的拖动坐标状态 + var fabOffsetX by remember { mutableStateOf(0f) } + var fabOffsetY by remember { mutableStateOf(0f) } + + // 3. 聊天卡片相对于初始右下角位置的额外拖动位移状态 + var cardOffsetX by remember { mutableStateOf(0f) } + var cardOffsetY by remember { mutableStateOf(0f) } + if (isExpanded) { BackHandler { isExpanded = false } } - Box(modifier = Modifier.fillMaxSize()) { - // 右下角悬浮按钮 + // 👈 彻底终结“背部网页点不到”与“拖出空气墙被吃掉”的终极解决方案! + // 根本原因分析: + // 之前使用 Popup(clippingEnabled = false) 时,虽然关闭了裁剪,但 Popup 的 Window 底层窗口尺寸仍被定死在右下角(340X500)。 + // 在 Android/Undertow 容器中,一旦把子 View(卡片)使用 offset 偏移出 Popup 原本的 340X500 物理窗口区域: + // - 卡片移出部分由于脱离了 Popup 原生的 Window 视口,会被系统窗口裁剪(产生被空气墙强行吃掉的效果)。 + // - 只要 Popup 处于显示状态,Undertow 底层会为它分配一个全屏幕捕获手势的不可见透明幕墙,封死了底下所有网页的点击。 + // + // 终极破局:我们彻底抛弃 Android 系统内置的 Popup 视窗! + // 我们的 FloatingChatWidget 本身就是 MainActivity 动态 addView 进去的一个全屏 Compose 容器。 + // 我们直接在 MainActivity 顶层的全屏 Box 画布中利用 Compose 的声明式叠加,像漂浮图层一样直接渲染卡片和 FAB。 + // - 这样,卡片移动没有任何 Popup 物理窗口的边缘裁剪(彻底告别空气墙吃掉画面)! + // - 同时,我们在全屏的 Box 容器上应用 `Modifier.pointerInput` 拦截时,只在卡片和 FAB 本身尺寸上捕捉手势, + // 其他 100% 的空白区域通过 Compose 的无阻透传机制,根本不会有任何拦截,背后的 Web 网页可以 100% 自由交互、滚动和点击! + Box( + modifier = Modifier.fillMaxSize() // Canvas 覆盖全屏,不需要任何 Popup,彻底零空气墙! + ) { + // 右下角悬浮按钮(支持手指任意位置拖拽) if (!isExpanded) { FloatingActionButton( onClick = { isExpanded = true }, modifier = Modifier .align(Alignment.BottomEnd) .padding(16.dp) - .padding(bottom = 32.dp), // 留出导航栏空间 + .padding(bottom = 32.dp) // 留出导航栏空间 + .offset { IntOffset(fabOffsetX.roundToInt(), fabOffsetY.roundToInt()) } // 应用拖拽偏移量 + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { }, + onDragEnd = { }, + onDragCancel = { }, + onDrag = { change, dragAmount -> + change.consume() + fabOffsetX += dragAmount.x + fabOffsetY += dragAmount.y + } + ) + }, containerColor = Color(0xFF10B981) ) { Text("AI", color = Color.White, style = MaterialTheme.typography.titleMedium) } } - // 弹出的对话卡片 + // 弹出的对话卡片(以纯浮动图层方式渲染,完全不用 Popup,完美响应键盘且 100% 不遮挡空白区域点击) if (isExpanded) { - Popup( - alignment = Alignment.BottomEnd, - properties = PopupProperties(focusable = true) + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + visible = true // 弹出时触发灵动入场动画 + } + + // 动态计算卡片初始弹窗位置(在屏幕右下角) + val initialX = with(LocalDensity.current) { + (configuration.screenWidthDp.dp - maxWidth - 16.dp).toPx() + } + val initialY = with(LocalDensity.current) { + (configuration.screenHeightDp.dp - maxHeight - 48.dp).toPx() + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f, animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f, animationSpec = tween(200)), + modifier = Modifier + .offset { + // 初始右下角位置 + 用户手动拖拽位移 + IntOffset( + (initialX + cardOffsetX).roundToInt(), + (initialY + cardOffsetY).roundToInt() + ) + } + .width(maxWidth) + .height(maxHeight) + .shadow(8.dp, RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) ) { - Box( - modifier = Modifier - .padding(16.dp) - .padding(bottom = 32.dp) - .widthIn(max = 340.dp) // 限定卡片宽度 - .heightIn(max = maxHeight) // 限定卡片高度 - .shadow(8.dp, RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp)) - .clip(RoundedCornerShape(16.dp)) - ) { - ChatScaffold( - onClose = { isExpanded = false } - ) - } + ChatScaffold( + onClose = { + visible = false // 触发退场动画 + isExpanded = false + }, + onDrag = { dx, dy -> // 响应来自 ChatArea 标题栏的手势拖拽 + cardOffsetX += dx + cardOffsetY += dy + } + ) } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt b/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt index 9ac9402..6445272 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt @@ -1,11 +1,15 @@ package com.stand.standapp.ui.chat.components import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* 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.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Send import androidx.compose.material3.* @@ -13,6 +17,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import com.stand.standapp.ui.chat.model.ChatMessage @@ -22,12 +27,44 @@ fun ChatArea( messages: List, onSendMessage: (String) -> Unit, onOpenDrawer: () -> Unit, - onClose: () -> Unit + onClose: () -> Unit, + modifier: Modifier = Modifier, + onDrag: (Float, Float) -> Unit = { _, _ -> }, // 新增拖动回调,传递给顶层卡片进行位移 + onCancelStreaming: () -> Unit = {} // 👈 新增手动中断回调 ) { var inputText by remember { mutableStateOf("") } + + // 👈 声明滚动状态,默认对齐最下端布局! + val listState = rememberLazyListState() + + // 👈 完美破局第二点:动态计算位移,确保无论何时都完美对齐最底端,而非最上端对齐! + // 1. 切换会话、新开会话或发送新提问时,平滑滚动到最后一项的偏移量。 + // 为了防止出现“只露出头部”的缺陷,我们直接使用 layoutInfo 高度差来实现精密的物理底部对齐! + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + val lastIndex = messages.size - 1 + // 首次滚动 + listState.animateScrollToItem(lastIndex) + // 双重微调:确保高度变化后继续贴底 + kotlinx.coroutines.delay(100) + listState.animateScrollToItem(lastIndex) + } + } + + // 2. 核心大招:当 AI 处于 streaming 状态一字字输出时,打字机实时精准底部跟进! + // 我们不仅监听文字总长度,还设置极小延迟后高精度跟随,绝不出现卡在半空的尴尬。 + val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } } + if (isAnyStreaming) { + val totalLength = messages.map { it.content.length }.sum() + LaunchedEffect(totalLength) { + if (messages.isNotEmpty()) { + listState.scrollToItem(messages.size - 1) + } + } + } Column(modifier = Modifier.fillMaxSize().background(Color.White)) { - // 顶部栏 + // 顶部标题栏,通过手势侦测支持整卡拖拽! TopAppBar( title = { Text("AI 助手", style = MaterialTheme.typography.titleMedium) }, navigationIcon = { @@ -38,31 +75,54 @@ fun ChatArea( actions = { TextButton(onClick = onClose) { Text("关闭") } }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFFF3F4F6)) + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFFF3F4F6)), + modifier = Modifier.pointerInput(Unit) { + detectDragGestures( + onDragStart = { }, + onDragEnd = { }, + onDragCancel = { }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.x, dragAmount.y) // 拦截拖拽手势并触发回调位移 + } + ) + } ) - // 消息列表 - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp), - contentPadding = PaddingValues(vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(messages) { msg -> - val isUser = msg.role == ChatMessage.Role.USER - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB), - contentColor = if (isUser) Color.White else Color.Black + // 消息列表(外层包裹 SelectionContainer 从而支持长按选择和自由复制!) + SelectionContainer(modifier = Modifier.weight(1f).fillMaxWidth()) { + LazyColumn( + state = listState, // 绑定滚动状态 + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(messages, key = { it.id }) { msg -> // 👈 为每个消息绑定唯一 key,提升打字机重绘性能,拒绝任何卡死! + val isUser = msg.role == ChatMessage.Role.USER + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart ) { - Text( - text = msg.content + if (msg.isStreaming) " █" else "", - modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodyMedium - ) + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB), + contentColor = if (isUser) Color.White else Color.Black + ) { + if (isUser) { + Text( + text = msg.content, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium + ) + } else { + // 👈 直接使用行业公认、最稳定坚固、真正支持标准高精度 Markdown 排版换行与代码折叠的 Markwon 渲染器! + val contentWithCursor = msg.content + if (msg.isStreaming) " █" else "" + MarkwonRenderer( + markdown = contentWithCursor, + modifier = Modifier.padding(12.dp) + ) + } + } } } } @@ -73,8 +133,8 @@ fun ChatArea( modifier = Modifier .fillMaxWidth() .padding(8.dp) - .navigationBarsPadding() // 处理底部内边距 - .imePadding(), // 键盘避让 + .navigationBarsPadding() + .imePadding(), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( @@ -82,20 +142,72 @@ fun ChatArea( onValueChange = { inputText = it }, modifier = Modifier.weight(1f), placeholder = { Text("输入你想问的问题...") }, - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(20.dp), + enabled = !isAnyStreaming // streaming时禁用输入 ) Spacer(modifier = Modifier.width(8.dp)) + // 👈 合并发送/停止按钮:streaming时显示停止,否则显示发送 IconButton( onClick = { - if (inputText.isNotBlank()) { + if (isAnyStreaming) { + onCancelStreaming() + } else if (inputText.isNotBlank()) { onSendMessage(inputText) inputText = "" } }, - modifier = Modifier.background(Color(0xFF10B981), RoundedCornerShape(50)) + modifier = Modifier.background( + if (isAnyStreaming) Color.Red else Color(0xFF10B981), + RoundedCornerShape(50) + ) ) { - Icon(Icons.Default.Send, contentDescription = "Send", tint = Color.White) + Icon( + if (isAnyStreaming) Icons.Default.Close else Icons.Default.Send, + contentDescription = if (isAnyStreaming) "停止" else "发送", + tint = Color.White + ) } } } } + +/** + * 基于 Markwon 引擎的 Markdown 渲染器。 + * 支持:标题、加粗、斜体、删除线、链接、代码块、表格、列表、换行。 + */ +@Composable +fun MarkwonRenderer(markdown: String, modifier: Modifier) { + androidx.compose.ui.viewinterop.AndroidView( + factory = { ctx -> + val textView = android.widget.TextView(ctx).apply { + setTextColor(android.graphics.Color.parseColor("#1F2937")) + textSize = 14f + setTextIsSelectable(true) + movementMethod = android.text.method.LinkMovementMethod.getInstance() + setLineSpacing(0f, 1.15f) + setPadding(0, 0, 0, 0) + } + + val markwon = io.noties.markwon.Markwon.builder(ctx) + .usePlugin(io.noties.markwon.core.CorePlugin.create()) + .usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(ctx)) + .build() + + textView.tag = markwon + textView + }, + update = { view -> + try { + val markwon = view.tag as? io.noties.markwon.Markwon + if (markwon != null) { + markwon.setMarkdown(view, markdown) + } else { + view.text = markdown + } + } catch (e: Exception) { + view.text = markdown + } + }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/stand/standapp/ui/chat/components/ChatScaffold.kt b/app/src/main/java/com/stand/standapp/ui/chat/components/ChatScaffold.kt index 60b86de..572609e 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/components/ChatScaffold.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/components/ChatScaffold.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.* import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.* @@ -22,21 +23,43 @@ import kotlinx.coroutines.launch @Composable fun ChatScaffold( viewModel: ChatViewModel = viewModel(), - onClose: () -> Unit + onClose: () -> Unit, + onDrag: (Float, Float) -> Unit = { _, _ -> } ) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() val sessions by viewModel.sessions.collectAsState() val currentSessionId by viewModel.currentSessionId.collectAsState() val messages by viewModel.messages.collectAsState() + var showClearDialog by remember { mutableStateOf(false) } - // 筛选当前会话的消息 val currentMessages = messages.filter { it.sessionId == currentSessionId } + // 清除确认对话框 + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { Text("清除所有记录") }, + text = { Text("确定要清除所有对话记录吗?此操作不可撤销。") }, + confirmButton = { + TextButton( + onClick = { + viewModel.clearAllSessions() + showClearDialog = false + scope.launch { drawerState.close() } + } + ) { Text("确定", color = Color.Red) } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { Text("取消") } + } + ) + } + ModalNavigationDrawer( drawerState = drawerState, drawerContent = { - ModalDrawerSheet(modifier = Modifier.fillMaxWidth(0.7f)) { // 抽屉宽度为 70% + ModalDrawerSheet(modifier = Modifier.fillMaxWidth(0.7f)) { Column(Modifier.fillMaxSize().padding(16.dp)) { Button( onClick = { @@ -52,8 +75,8 @@ fun ChatScaffold( Spacer(Modifier.height(16.dp)) - LazyColumn { - items(sessions) { session -> + LazyColumn(modifier = Modifier.weight(1f)) { + items(sessions, key = { it.id }) { session -> val isSelected = session.id == currentSessionId Surface( onClick = { @@ -74,6 +97,17 @@ fun ChatScaffold( } } } + + // 底部清除按钮 + OutlinedButton( + onClick = { showClearDialog = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red) + ) { + Icon(Icons.Default.Delete, contentDescription = null, tint = Color.Red) + Spacer(Modifier.width(8.dp)) + Text("清除所有记录") + } } } } @@ -82,7 +116,9 @@ fun ChatScaffold( messages = currentMessages, onSendMessage = { viewModel.sendMessage(it) }, onOpenDrawer = { scope.launch { drawerState.open() } }, - onClose = onClose + onClose = onClose, + onDrag = onDrag, + onCancelStreaming = { viewModel.cancelActiveStreaming() } ) } } diff --git a/app/src/main/java/com/stand/standapp/ui/chat/repo/AiChatRepository.kt b/app/src/main/java/com/stand/standapp/ui/chat/repo/AiChatRepository.kt index e97f385..5ec8509 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/repo/AiChatRepository.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/repo/AiChatRepository.kt @@ -1,56 +1,87 @@ package com.stand.standapp.ui.chat.repo +import com.stand.standapp.AppConfig +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import okhttp3.sse.EventSource -import okhttp3.sse.EventSourceListener -import okhttp3.sse.EventSources import timber.log.Timber +import java.io.BufferedReader +import java.io.InputStreamReader import java.util.concurrent.TimeUnit class AiChatRepository { - // 根据后端实际情况修改,模拟器访问本地后端用 10.0.2.2,后端 context-path 是 /api 加上 controller 的 /chat/stream - private val backendUrl = "http://10.0.2.2:8080/api/chat/stream" - private val client = OkHttpClient.Builder() - .readTimeout(0, TimeUnit.MILLISECONDS) // SSE 需要关闭读取超时 + .readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流 .build() - fun streamChat(message: String, token: String): Flow = callbackFlow { - val json = """{"message": "$message"}""" + fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow = callbackFlow { + // 1. 从 AppConfig 中动态读取当前服务器基准地址,完美适配真实网关 + val serverUrl = AppConfig.getServerUrl(context) + val requestUrl = "$serverUrl/api/chat/stream" + + val jsonRequestBody = if (historyJson.isNotBlank()) { + """{"message": "$message", "history": $historyJson}""" + } else { + """{"message": "$message"}""" + } + val request = Request.Builder() - .url(backendUrl) - .post(json.toRequestBody("application/json".toMediaType())) + .url(requestUrl) + .post(jsonRequestBody.toRequestBody("application/json".toMediaType())) .header("Authorization", "Bearer $token") .header("Accept", "text/event-stream") .build() - val listener = object : EventSourceListener() { - override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) { - // 收到后端推送的数据块 - trySend(data) - } + val call = client.newCall(request) - override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { - Timber.e(t, "SSE Connection failed") - close(t) // 结束流 - } + // 2. 在 IO 线程池中执行网络流式读取,杜绝 EventSource 找不到依赖的编译期痛点 + val job = CoroutineScope(Dispatchers.IO).launch { + try { + call.execute().use { response -> + if (!response.isSuccessful) { + trySend("[请求失败: 状态码 ${response.code}]") + close() + return@launch + } - override fun onClosed(eventSource: EventSource) { - close() // 正常结束 + val body = response.body + if (body == null) { + trySend("[空响应]") + close() + return@launch + } + + // 3. 逐行读取后端流式推送字符,手动解析 data 属性 + BufferedReader(InputStreamReader(body.byteStream(), Charsets.UTF_8)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + val curLine = line ?: continue + if (curLine.startsWith("data:")) { + val chunk = curLine.substring(5).trim() + trySend(chunk) + } + } + } + } + close() + } catch (e: Exception) { + Timber.e(e, "SSE Stream error") + trySend("\n[连接异常: ${e.message}]") + close(e) } } - val eventSource = EventSources.createFactory(client).newEventSource(request, listener) - awaitClose { - eventSource.cancel() + call.cancel() + job.cancel() } } } diff --git a/img_1.png b/img_1.png new file mode 100644 index 0000000..19bff00 Binary files /dev/null and b/img_1.png differ