This commit is contained in:
844143714@qq,com 2026-05-20 11:11:50 +08:00
parent 6d12291941
commit 99c691378e
8 changed files with 513 additions and 90 deletions

View File

@ -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)

View File

@ -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")
}

View File

@ -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 {
// 👈 将数据加载移到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) {

View File

@ -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)
) {
Box(
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
.padding(16.dp)
.padding(bottom = 32.dp)
.widthIn(max = 340.dp) // 限定卡片宽度
.heightIn(max = maxHeight) // 限定卡片高度
.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))
) {
ChatScaffold(
onClose = { isExpanded = false }
onClose = {
visible = false // 触发退场动画
isExpanded = false
},
onDrag = { dx, dy -> // 响应来自 ChatArea 标题栏的手势拖拽
cardOffsetX += dx
cardOffsetY += dy
}
)
}
}
}
}
}

View File

@ -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,16 +75,29 @@ 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) // 拦截拖拽手势并触发回调位移
}
)
}
)
// 消息列表
// 消息列表(外层包裹 SelectionContainer 从而支持长按选择和自由复制!)
SelectionContainer(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp),
state = listState, // 绑定滚动状态
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
contentPadding = PaddingValues(vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(messages) { msg ->
items(messages, key = { it.id }) { msg -> // 👈 为每个消息绑定唯一 key提升打字机重绘性能拒绝任何卡死
val isUser = msg.role == ChatMessage.Role.USER
Box(
modifier = Modifier.fillMaxWidth(),
@ -58,11 +108,21 @@ fun ChatArea(
color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB),
contentColor = if (isUser) Color.White else Color.Black
) {
if (isUser) {
Text(
text = msg.content + if (msg.isStreaming) "" else "",
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
)
}

View File

@ -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() }
)
}
}

View File

@ -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)
// 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 onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
Timber.e(t, "SSE Connection failed")
close(t) // 结束流
val body = response.body
if (body == null) {
trySend("[空响应]")
close()
return@launch
}
override fun onClosed(eventSource: EventSource) {
close() // 正常结束
// 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()
}
}
}

BIN
img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB