This commit is contained in:
parent
6d12291941
commit
99c691378e
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<FrameLayout>(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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
// 👈 声明一个正在加载的状态,保证上一个问题没结束前无法再次发送提问!
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _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<ChatSession>()
|
||||
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<ChatMessage>()
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ChatMessage>,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = callbackFlow {
|
||||
val json = """{"message": "$message"}"""
|
||||
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue