Compare commits
No commits in common. "dbb430b088613286fd7aa77f379eef0363c174b6" and "b4f61c4b75977119afad82846f56837fb34af5a2" have entirely different histories.
dbb430b088
...
b4f61c4b75
|
|
@ -67,13 +67,6 @@ dependencies {
|
|||
implementation("org.mozilla.geckoview:geckoview-omni:115.0.20230710165010")
|
||||
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")
|
||||
implementation("io.noties.markwon:linkify:4.6.2") // 👈 链接自动识别
|
||||
implementation("io.noties.markwon:ext-strikethrough:4.6.2") // 👈 删除线支持
|
||||
implementation("io.noties.markwon:ext-tasklist:4.6.2") // 👈 任务列表支持
|
||||
implementation("com.github.getActivity:XXPermissions:18.2")
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
|
|
@ -84,7 +77,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ object AppConfig {
|
|||
private const val KEY_REMEMBER_LOGIN = "remember_login"
|
||||
private const val KEY_USERNAME = "saved_username"
|
||||
private const val KEY_PASSWORD = "saved_password"
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
|
||||
// 默认配置
|
||||
const val DEFAULT_SERVER_URL = "https://template.gyouzhe.com"
|
||||
|
|
@ -69,12 +68,4 @@ object AppConfig {
|
|||
fun setSavedPassword(context: Context, password: String) {
|
||||
getPrefs(context).edit().putString(KEY_PASSWORD, password).apply()
|
||||
}
|
||||
|
||||
fun getAccessToken(context: Context): String {
|
||||
return getPrefs(context).getString(KEY_ACCESS_TOKEN, "") ?: ""
|
||||
}
|
||||
|
||||
fun setAccessToken(context: Context, token: String) {
|
||||
getPrefs(context).edit().putString(KEY_ACCESS_TOKEN, token).apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,7 +155,6 @@ class LoginActivity : AppCompatActivity() {
|
|||
val data = json.optJSONObject("data")
|
||||
val loginData = data?.toString() ?: "{}"
|
||||
val accessToken = data?.optString("access_token", "") ?: ""
|
||||
AppConfig.setAccessToken(this@LoginActivity, accessToken)
|
||||
|
||||
Toast.makeText(applicationContext, "登录成功", Toast.LENGTH_SHORT).show()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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
|
||||
|
|
@ -191,20 +190,6 @@ 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,260 +0,0 @@
|
|||
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
|
||||
import com.stand.standapp.ui.chat.model.ChatMessage
|
||||
import com.stand.standapp.ui.chat.model.ChatSession
|
||||
import com.stand.standapp.ui.chat.repo.AiChatRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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) {
|
||||
private val repository = AiChatRepository()
|
||||
|
||||
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
|
||||
|
||||
private val _currentSessionId = MutableStateFlow<String?>(null)
|
||||
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
|
||||
|
||||
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>()
|
||||
val maxMessages = 200 // 👈 限制最多加载200条消息,避免大数据解析卡顿
|
||||
val startIndex = (messagesArray.length() - maxMessages).coerceAtLeast(0)
|
||||
for (i in startIndex 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()
|
||||
// 👈 限制最多保留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)
|
||||
|
||||
// 2. 添加用户消息
|
||||
val userMsg = ChatMessage(sessionId = sessionId, role = ChatMessage.Role.USER, content = content)
|
||||
|
||||
// 3. 准备 AI 空消息,处于 streaming 状态
|
||||
val aiMsgId = UUID.randomUUID().toString()
|
||||
val aiMsg = ChatMessage(id = aiMsgId, sessionId = sessionId, role = ChatMessage.Role.AI, content = "", isStreaming = true)
|
||||
|
||||
_messages.value = _messages.value + userMsg + aiMsg
|
||||
saveCacheToLocal() // 👈 用户发出消息,存入本地历史记录
|
||||
|
||||
// 4. 发起网络请求,流式追加内容
|
||||
activeChatJob = viewModelScope.launch {
|
||||
val token = AppConfig.getAccessToken(getApplication())
|
||||
|
||||
// 提取当前会话(除了刚发出的这条和尚未生成的AI回复之外的)历史对话上下文
|
||||
// 👈 限制最多取最近10轮对话(20条消息),避免token超限
|
||||
val currentSessionMsgs = _messages.value
|
||||
.filter { it.sessionId == sessionId && it.id != userMsg.id && it.id != aiMsgId && !it.isStreaming }
|
||||
.takeLast(6)
|
||||
|
||||
// 👈 使用 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendAiMessageChunk(messageId: String, chunk: String) {
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(content = msg.content + chunk)
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeAiMessage(messageId: String) {
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(isStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
saveCacheToLocal() // 👈 AI 回复完毕后,状态变更为非 streaming 并归档存盘
|
||||
}
|
||||
|
||||
private fun updateSessionTitleIfFirstMessage(sessionId: String, content: String) {
|
||||
val isFirst = _messages.value.none { it.sessionId == sessionId }
|
||||
if (isFirst) {
|
||||
val title = if (content.length > 10) content.take(10) + "..." else content
|
||||
_sessions.value = _sessions.value.map {
|
||||
if (it.id == sessionId) it.copy(title = title) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
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.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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 com.stand.standapp.ui.chat.components.ChatScaffold
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun FloatingChatWidget() {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||
val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
|
||||
val maxWidth = (configuration.screenWidthDp * 0.45f).dp
|
||||
val maxHeight = (configuration.screenHeightDp * 0.75f).dp
|
||||
val cardWidthPx = with(density) { maxWidth.toPx() }
|
||||
val cardHeightPx = with(density) { maxHeight.toPx() }
|
||||
val fabSizePx = with(density) { 56.dp.toPx() }
|
||||
val paddingPx = with(density) { 16.dp.toPx() }
|
||||
val navBarPx = with(density) { 32.dp.toPx() }
|
||||
|
||||
// 👈 FAB偏移量,全屏幕可拖动
|
||||
var fabOffsetX by remember { mutableStateOf(0f) }
|
||||
var fabOffsetY by remember { mutableStateOf(0f) }
|
||||
|
||||
// 👈 对话框额外偏移(拖动对话框产生)
|
||||
var cardDragX by remember { mutableStateOf(0f) }
|
||||
var cardDragY by remember { mutableStateOf(0f) }
|
||||
|
||||
if (isExpanded) {
|
||||
BackHandler {
|
||||
isExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// FAB - 全屏幕可拖动
|
||||
if (!isExpanded) {
|
||||
FloatingActionButton(
|
||||
onClick = { isExpanded = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.offset {
|
||||
IntOffset(
|
||||
fabOffsetX.roundToInt()
|
||||
.coerceIn(-(screenWidthPx - fabSizePx - paddingPx * 2).toInt(), 0),
|
||||
fabOffsetY.roundToInt()
|
||||
.coerceIn(-(screenHeightPx - fabSizePx - paddingPx * 2 - navBarPx).toInt(), 0)
|
||||
)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
// 对话框 - 展开后必须在界面内
|
||||
if (isExpanded) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
visible = true
|
||||
}
|
||||
|
||||
// 👈 用 FAB 位置计算对话框位置,但强制钳制在屏幕内
|
||||
// FAB右下角为基准:对话框应该出现在 FAB 附近
|
||||
val fabRight = screenWidthPx - paddingPx * 2 + fabOffsetX // FAB右边缘
|
||||
val fabBottom = screenHeightPx - paddingPx * 2 - navBarPx + fabOffsetY // FAB下边缘
|
||||
|
||||
// 对话框优先放在 FAB 左上方,如果放不下就贴边
|
||||
val rawX = fabRight - cardWidthPx - paddingPx // FAB左边
|
||||
val rawY = fabBottom - cardHeightPx - paddingPx // FAB上边
|
||||
|
||||
// 👈 关键:钳制到屏幕内
|
||||
val clampedX = rawX.coerceIn(0f, screenWidthPx - cardWidthPx)
|
||||
val clampedY = rawY.coerceIn(0f, screenHeightPx - cardHeightPx)
|
||||
|
||||
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(
|
||||
(clampedX + cardDragX).roundToInt()
|
||||
.coerceIn(0, (screenWidthPx - cardWidthPx).toInt()),
|
||||
(clampedY + cardDragY).roundToInt()
|
||||
.coerceIn(0, (screenHeightPx - cardHeightPx).toInt())
|
||||
)
|
||||
}
|
||||
.width(maxWidth)
|
||||
.height(maxHeight)
|
||||
.shadow(8.dp, RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
) {
|
||||
ChatScaffold(
|
||||
onClose = {
|
||||
visible = false
|
||||
isExpanded = false
|
||||
// 👈 关闭时把FAB偏移同步(对话框拖动带动FAB)
|
||||
fabOffsetX += cardDragX
|
||||
fabOffsetY += cardDragY
|
||||
cardDragX = 0f
|
||||
cardDragY = 0f
|
||||
},
|
||||
onDrag = { dx, dy ->
|
||||
cardDragX += dx
|
||||
cardDragY += dy
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
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.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.*
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatArea(
|
||||
messages: List<ChatMessage>,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onDrag: (Float, Float) -> Unit = { _, _ -> },
|
||||
onCancelStreaming: () -> Unit = {}
|
||||
) {
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.scrollToItem(messages.size - 1, Int.MAX_VALUE)
|
||||
}
|
||||
}
|
||||
|
||||
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, Int.MAX_VALUE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
|
||||
TopAppBar(
|
||||
title = { Text("AI 助手", style = MaterialTheme.typography.titleMedium) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onClose) { Text("关闭") }
|
||||
},
|
||||
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(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(messages, key = { it.id }) { 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
|
||||
) {
|
||||
if (isUser) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { ctx ->
|
||||
SafeSelectableTextView(ctx).apply {
|
||||
setTextColor(android.graphics.Color.WHITE)
|
||||
textSize = 14f
|
||||
setTextIsSelectable(true)
|
||||
setPadding(0, 0, 0, 0)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.text = msg.content
|
||||
},
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
} else {
|
||||
val contentWithCursor = msg.content + if (msg.isStreaming) " █" else ""
|
||||
MarkwonRenderer(
|
||||
markdown = contentWithCursor,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("输入你想问的问题...") },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
enabled = !isAnyStreaming
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isAnyStreaming) {
|
||||
onCancelStreaming()
|
||||
} else if (inputText.isNotBlank()) {
|
||||
onSendMessage(inputText)
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
modifier = Modifier.background(
|
||||
if (isAnyStreaming) Color.Red else Color(0xFF10B981),
|
||||
RoundedCornerShape(50)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
if (isAnyStreaming) Icons.Default.Close else Icons.Default.Send,
|
||||
contentDescription = if (isAnyStreaming) "停止" else "发送",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkwonRenderer(markdown: String, modifier: Modifier) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { ctx ->
|
||||
val textView = SafeSelectableTextView(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))
|
||||
.usePlugin(io.noties.markwon.html.HtmlPlugin.create())
|
||||
.usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create())
|
||||
.usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.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
|
||||
)
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
package com.stand.standapp.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.stand.standapp.ui.chat.ChatViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScaffold(
|
||||
viewModel: ChatViewModel = viewModel(),
|
||||
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,
|
||||
gesturesEnabled = false,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(240.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
// 👈 顶部关闭按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("历史对话", style = MaterialTheme.typography.titleSmall)
|
||||
TextButton(onClick = { scope.launch { drawerState.close() } }) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.createNewSession()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("新建话题", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(sessions, key = { it.id }) { session ->
|
||||
val isSelected = session.id == currentSessionId
|
||||
Surface(
|
||||
onClick = {
|
||||
viewModel.switchSession(session.id)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = session.title,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部清除按钮
|
||||
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("清除所有记录")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
ChatArea(
|
||||
messages = currentMessages,
|
||||
onSendMessage = { viewModel.sendMessage(it) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onClose = onClose,
|
||||
onDrag = onDrag,
|
||||
onCancelStreaming = { viewModel.cancelActiveStreaming() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.stand.standapp.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.TextView
|
||||
|
||||
/**
|
||||
* 安全的可选择文本 TextView,捕获 setTextIsSelectable 导致的 IndexOutOfBoundsException 崩溃。
|
||||
* 问题根源:当文本更新时用户正在拖动选择手柄,resetDragAcceleratorState 尝试设置无效范围 (-1, -1)。
|
||||
*/
|
||||
class SafeSelectableTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : TextView(context, attrs, defStyleAttr) {
|
||||
|
||||
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
|
||||
return try {
|
||||
super.onTouchEvent(event)
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
// 吞掉异常,避免崩溃
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package com.stand.standapp.ui.chat.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class ChatSession(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val title: String = "新对话",
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val sessionId: String,
|
||||
val role: Role,
|
||||
val content: String,
|
||||
val isStreaming: Boolean = false
|
||||
) {
|
||||
enum class Role {
|
||||
USER, AI
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
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 timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AiChatRepository {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // 禁用读取超时以适配 SSE 流
|
||||
.build()
|
||||
|
||||
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(requestUrl)
|
||||
.post(jsonRequestBody.toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
trySend("[空响应]")
|
||||
close()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 3. 逐行读取后端流式推送字符,手动解析 data 属性
|
||||
// 👈 添加行缓冲,确保完整的markdown行被发送
|
||||
BufferedReader(InputStreamReader(body.byteStream(), Charsets.UTF_8)).use { reader ->
|
||||
var line: String?
|
||||
var lastcontent = "";
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
val curLine = line ?: continue
|
||||
if (curLine.startsWith("data:")) {
|
||||
val content = curLine.substring(5)
|
||||
// Timber.d("内容111111111:"+content)
|
||||
if (content.contains("|") && lastcontent.contains("|")){
|
||||
trySend("\n")
|
||||
// Timber.d("1发送11111111111:换行")
|
||||
trySend(content)
|
||||
// Timber.d("2发送11111111111:"+content)
|
||||
}else if (content.length==0) {
|
||||
// 空data行表示换行
|
||||
trySend("\n")
|
||||
// Timber.d("3发送11111111111:换行")
|
||||
}else {
|
||||
trySend(content)
|
||||
// Timber.d("4发送11111111111:"+content)
|
||||
}
|
||||
lastcontent = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
close()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "SSE Stream error")
|
||||
trySend("\n[连接异常: ${e.message}]")
|
||||
close(e)
|
||||
}
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
call.cancel()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue