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.google.android.material:material:1.12.0")
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("com.squareup.okhttp3:okhttp-sse: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("com.github.getActivity:XXPermissions:18.2")
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
|
@ -78,6 +81,7 @@ dependencies {
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") // 👈 新增这一行以支持 Compose 中的 viewModel()
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import android.widget.FrameLayout
|
||||||
import org.mozilla.geckoview.GeckoResult
|
import org.mozilla.geckoview.GeckoResult
|
||||||
import org.mozilla.geckoview.GeckoRuntime
|
import org.mozilla.geckoview.GeckoRuntime
|
||||||
import org.mozilla.geckoview.GeckoSession
|
import org.mozilla.geckoview.GeckoSession
|
||||||
|
|
@ -190,6 +191,20 @@ class MainActivity : AppCompatActivity() {
|
||||||
// 启动时检查更新
|
// 启动时检查更新
|
||||||
UpdateManager(this).checkUpdate()
|
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) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("MainActivity").e(e, "Fatal Crash")
|
Timber.tag("MainActivity").e(e, "Fatal Crash")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.stand.standapp.ui.chat
|
package com.stand.standapp.ui.chat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.stand.standapp.AppConfig
|
import com.stand.standapp.AppConfig
|
||||||
|
|
@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
@ -27,22 +30,155 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
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 {
|
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() {
|
fun createNewSession() {
|
||||||
val newSession = ChatSession()
|
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
|
_currentSessionId.value = newSession.id
|
||||||
|
saveCacheToLocal()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun switchSession(sessionId: String) {
|
fun switchSession(sessionId: String) {
|
||||||
_currentSessionId.value = sessionId
|
_currentSessionId.value = sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearAllSessions() {
|
||||||
|
_sessions.value = emptyList()
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_currentSessionId.value = null
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
createNewSession()
|
||||||
|
}
|
||||||
|
|
||||||
fun sendMessage(content: String) {
|
fun sendMessage(content: String) {
|
||||||
|
if (_isLoading.value) return // 👈 拦截提问:上一个回答正在流式写时,绝对不允许重复提交!
|
||||||
|
|
||||||
val sessionId = _currentSessionId.value ?: return
|
val sessionId = _currentSessionId.value ?: return
|
||||||
|
_isLoading.value = true // 👈 锁定发送状态
|
||||||
|
|
||||||
// 1. 如果是新会话,更新标题
|
// 1. 如果是新会话,更新标题
|
||||||
updateSessionTitleIfFirstMessage(sessionId, content)
|
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)
|
val aiMsg = ChatMessage(id = aiMsgId, sessionId = sessionId, role = ChatMessage.Role.AI, content = "", isStreaming = true)
|
||||||
|
|
||||||
_messages.value = _messages.value + userMsg + aiMsg
|
_messages.value = _messages.value + userMsg + aiMsg
|
||||||
|
saveCacheToLocal() // 👈 用户发出消息,存入本地历史记录
|
||||||
|
|
||||||
// 4. 发起网络请求,流式追加内容
|
// 4. 发起网络请求,流式追加内容
|
||||||
viewModelScope.launch {
|
activeChatJob = viewModelScope.launch {
|
||||||
val token = AppConfig.getAccessToken(getApplication())
|
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 ->
|
.catch { e ->
|
||||||
appendAiMessageChunk(aiMsgId, "\n[请求异常: ${e.message}]")
|
appendAiMessageChunk(aiMsgId, "\n[请求异常: ${e.message}]")
|
||||||
finalizeAiMessage(aiMsgId)
|
finalizeAiMessage(aiMsgId)
|
||||||
|
_isLoading.value = false // 发生异常释放锁
|
||||||
}
|
}
|
||||||
.onCompletion {
|
.onCompletion {
|
||||||
finalizeAiMessage(aiMsgId)
|
finalizeAiMessage(aiMsgId)
|
||||||
|
_isLoading.value = false // 传输完成释放锁
|
||||||
}
|
}
|
||||||
.collect { chunk ->
|
.collect { chunk ->
|
||||||
appendAiMessageChunk(aiMsgId, chunk)
|
appendAiMessageChunk(aiMsgId, chunk)
|
||||||
|
|
@ -87,6 +244,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
msg.copy(isStreaming = false)
|
msg.copy(isStreaming = false)
|
||||||
} else msg
|
} else msg
|
||||||
}
|
}
|
||||||
|
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {
|
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package com.stand.standapp.ui.chat
|
package com.stand.standapp.ui.chat
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -12,59 +15,123 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
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.unit.dp
|
||||||
import androidx.compose.ui.window.Popup
|
|
||||||
import androidx.compose.ui.window.PopupProperties
|
|
||||||
import com.stand.standapp.ui.chat.components.ChatScaffold
|
import com.stand.standapp.ui.chat.components.ChatScaffold
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FloatingChatWidget() {
|
fun FloatingChatWidget() {
|
||||||
var isExpanded by remember { mutableStateOf(false) }
|
var isExpanded by remember { mutableStateOf(false) }
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
|
|
||||||
|
// 1. 宽度缩小到屏幕的 45% (高品质窄窗设计)
|
||||||
|
val maxWidth = (configuration.screenWidthDp * 0.45f).dp
|
||||||
val maxHeight = (configuration.screenHeightDp * 0.75f).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) {
|
if (isExpanded) {
|
||||||
BackHandler {
|
BackHandler {
|
||||||
isExpanded = false
|
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) {
|
if (!isExpanded) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { isExpanded = true },
|
onClick = { isExpanded = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(16.dp)
|
.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)
|
containerColor = Color(0xFF10B981)
|
||||||
) {
|
) {
|
||||||
Text("AI", color = Color.White, style = MaterialTheme.typography.titleMedium)
|
Text("AI", color = Color.White, style = MaterialTheme.typography.titleMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹出的对话卡片
|
// 弹出的对话卡片(以纯浮动图层方式渲染,完全不用 Popup,完美响应键盘且 100% 不遮挡空白区域点击)
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
Popup(
|
var visible by remember { mutableStateOf(false) }
|
||||||
alignment = Alignment.BottomEnd,
|
LaunchedEffect(Unit) {
|
||||||
properties = PopupProperties(focusable = true)
|
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(
|
ChatScaffold(
|
||||||
modifier = Modifier
|
onClose = {
|
||||||
.padding(16.dp)
|
visible = false // 触发退场动画
|
||||||
.padding(bottom = 32.dp)
|
isExpanded = false
|
||||||
.widthIn(max = 340.dp) // 限定卡片宽度
|
},
|
||||||
.heightIn(max = maxHeight) // 限定卡片高度
|
onDrag = { dx, dy -> // 响应来自 ChatArea 标题栏的手势拖拽
|
||||||
.shadow(8.dp, RoundedCornerShape(16.dp))
|
cardOffsetX += dx
|
||||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp))
|
cardOffsetY += dy
|
||||||
.clip(RoundedCornerShape(16.dp))
|
}
|
||||||
) {
|
)
|
||||||
ChatScaffold(
|
|
||||||
onClose = { isExpanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
package com.stand.standapp.ui.chat.components
|
package com.stand.standapp.ui.chat.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
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.Menu
|
||||||
import androidx.compose.material.icons.filled.Send
|
import androidx.compose.material.icons.filled.Send
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
|
@ -13,6 +17,7 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.stand.standapp.ui.chat.model.ChatMessage
|
import com.stand.standapp.ui.chat.model.ChatMessage
|
||||||
|
|
||||||
|
|
@ -22,12 +27,44 @@ fun ChatArea(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
onSendMessage: (String) -> Unit,
|
onSendMessage: (String) -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onClose: () -> Unit
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onDrag: (Float, Float) -> Unit = { _, _ -> }, // 新增拖动回调,传递给顶层卡片进行位移
|
||||||
|
onCancelStreaming: () -> Unit = {} // 👈 新增手动中断回调
|
||||||
) {
|
) {
|
||||||
var inputText by remember { mutableStateOf("") }
|
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)) {
|
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
|
||||||
// 顶部栏
|
// 顶部标题栏,通过手势侦测支持整卡拖拽!
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("AI 助手", style = MaterialTheme.typography.titleMedium) },
|
title = { Text("AI 助手", style = MaterialTheme.typography.titleMedium) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
|
@ -38,31 +75,54 @@ fun ChatArea(
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = onClose) { Text("关闭") }
|
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) // 拦截拖拽手势并触发回调位移
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 消息列表
|
// 消息列表(外层包裹 SelectionContainer 从而支持长按选择和自由复制!)
|
||||||
LazyColumn(
|
SelectionContainer(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp),
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(vertical = 12.dp),
|
state = listState, // 绑定滚动状态
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
|
||||||
) {
|
contentPadding = PaddingValues(vertical = 12.dp),
|
||||||
items(messages) { msg ->
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
val isUser = msg.role == ChatMessage.Role.USER
|
) {
|
||||||
Box(
|
items(messages, key = { it.id }) { msg -> // 👈 为每个消息绑定唯一 key,提升打字机重绘性能,拒绝任何卡死!
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val isUser = msg.role == ChatMessage.Role.USER
|
||||||
contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
|
Box(
|
||||||
) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Surface(
|
contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB),
|
|
||||||
contentColor = if (isUser) Color.White else Color.Black
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Surface(
|
||||||
text = msg.content + if (msg.isStreaming) " █" else "",
|
shape = RoundedCornerShape(12.dp),
|
||||||
modifier = Modifier.padding(12.dp),
|
color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.navigationBarsPadding() // 处理底部内边距
|
.navigationBarsPadding()
|
||||||
.imePadding(), // 键盘避让
|
.imePadding(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|
@ -82,20 +142,72 @@ fun ChatArea(
|
||||||
onValueChange = { inputText = it },
|
onValueChange = { inputText = it },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text("输入你想问的问题...") },
|
placeholder = { Text("输入你想问的问题...") },
|
||||||
shape = RoundedCornerShape(20.dp)
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
enabled = !isAnyStreaming // streaming时禁用输入
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
// 👈 合并发送/停止按钮:streaming时显示停止,否则显示发送
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (inputText.isNotBlank()) {
|
if (isAnyStreaming) {
|
||||||
|
onCancelStreaming()
|
||||||
|
} else if (inputText.isNotBlank()) {
|
||||||
onSendMessage(inputText)
|
onSendMessage(inputText)
|
||||||
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
|
@ -22,21 +23,43 @@ import kotlinx.coroutines.launch
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScaffold(
|
fun ChatScaffold(
|
||||||
viewModel: ChatViewModel = viewModel(),
|
viewModel: ChatViewModel = viewModel(),
|
||||||
onClose: () -> Unit
|
onClose: () -> Unit,
|
||||||
|
onDrag: (Float, Float) -> Unit = { _, _ -> }
|
||||||
) {
|
) {
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sessions by viewModel.sessions.collectAsState()
|
val sessions by viewModel.sessions.collectAsState()
|
||||||
val currentSessionId by viewModel.currentSessionId.collectAsState()
|
val currentSessionId by viewModel.currentSessionId.collectAsState()
|
||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
var showClearDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 筛选当前会话的消息
|
|
||||||
val currentMessages = messages.filter { it.sessionId == currentSessionId }
|
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(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
ModalDrawerSheet(modifier = Modifier.fillMaxWidth(0.7f)) { // 抽屉宽度为 70%
|
ModalDrawerSheet(modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -52,8 +75,8 @@ fun ChatScaffold(
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
items(sessions) { session ->
|
items(sessions, key = { it.id }) { session ->
|
||||||
val isSelected = session.id == currentSessionId
|
val isSelected = session.id == currentSessionId
|
||||||
Surface(
|
Surface(
|
||||||
onClick = {
|
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,
|
messages = currentMessages,
|
||||||
onSendMessage = { viewModel.sendMessage(it) },
|
onSendMessage = { viewModel.sendMessage(it) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
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
|
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.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
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 timber.log.Timber
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class AiChatRepository {
|
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()
|
private val client = OkHttpClient.Builder()
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS) // SSE 需要关闭读取超时
|
.readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun streamChat(message: String, token: String): Flow<String> = callbackFlow {
|
fun streamChat(context: Context, message: String, token: String, historyJson: String): Flow<String> = callbackFlow {
|
||||||
val json = """{"message": "$message"}"""
|
// 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()
|
val request = Request.Builder()
|
||||||
.url(backendUrl)
|
.url(requestUrl)
|
||||||
.post(json.toRequestBody("application/json".toMediaType()))
|
.post(jsonRequestBody.toRequestBody("application/json".toMediaType()))
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val listener = object : EventSourceListener() {
|
val call = client.newCall(request)
|
||||||
override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
|
|
||||||
// 收到后端推送的数据块
|
|
||||||
trySend(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
|
// 2. 在 IO 线程池中执行网络流式读取,杜绝 EventSource 找不到依赖的编译期痛点
|
||||||
Timber.e(t, "SSE Connection failed")
|
val job = CoroutineScope(Dispatchers.IO).launch {
|
||||||
close(t) // 结束流
|
try {
|
||||||
}
|
call.execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
trySend("[请求失败: 状态码 ${response.code}]")
|
||||||
|
close()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
override fun onClosed(eventSource: EventSource) {
|
val body = response.body
|
||||||
close() // 正常结束
|
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 {
|
awaitClose {
|
||||||
eventSource.cancel()
|
call.cancel()
|
||||||
|
job.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue