Compare commits

..

14 Commits

Author SHA1 Message Date
844143714@qq,com dbb430b088 ai 2026-05-20 21:26:10 +08:00
844143714@qq,com bd5bd63f42 ai 2026-05-20 20:29:26 +08:00
844143714@qq,com 74788fd22e ai 2026-05-20 11:32:47 +08:00
844143714@qq,com 99c691378e ai 2026-05-20 11:11:50 +08:00
844143714@qq,com 6d12291941 security(chat): secure chat stream endpoint with jwt validation and token extraction 2026-05-19 22:24:29 +08:00
844143714@qq,com 05392384b6 fix(chat): align backendUrl comment with correct api context path 2026-05-19 22:20:31 +08:00
844143714@qq,com 30939f13fd refactor(chat): improve FloatingChatWidget layout safety and back button handling 2026-05-19 21:43:42 +08:00
844143714@qq,com 1cf1ffd9e8 feat(chat): implement FloatingChatWidget entry point and dialog popup 2026-05-19 21:33:27 +08:00
844143714@qq,com 338ecdfd43 refactor(chat): improve ChatScaffold button click and theme safety 2026-05-19 21:30:33 +08:00
844143714@qq,com 21750acd2c feat(chat): implement ChatScaffold container with sidebar drawer 2026-05-19 21:26:13 +08:00
844143714@qq,com e1bb993b09 feat(chat): implement ChatArea Compose UI component 2026-05-19 21:22:59 +08:00
844143714@qq,com 62ef1e2c38 feat(chat): implement ChatViewModel for state management 2026-05-19 21:18:41 +08:00
844143714@qq,com fcf63c6175 feat(chat): implement AiChatRepository for SSE streaming 2026-05-19 21:13:06 +08:00
844143714@qq,com f93c7c3e43 feat(chat): add sse dependency and chat data models 2026-05-19 21:07:49 +08:00
12 changed files with 931 additions and 0 deletions

View File

@ -67,6 +67,13 @@ 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)
@ -77,6 +84,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

@ -10,6 +10,7 @@ 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"
@ -68,4 +69,12 @@ 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()
}
}

View File

@ -155,6 +155,7 @@ 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()

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

@ -0,0 +1,260 @@
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
}
}
}
}

View File

@ -0,0 +1,147 @@
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
}
)
}
}
}
}

View File

@ -0,0 +1,202 @@
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
)
}

View File

@ -0,0 +1,140 @@
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() }
)
}
}

View File

@ -0,0 +1,25 @@
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
}
}
}

View File

@ -0,0 +1,21 @@
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
}
}

View File

@ -0,0 +1,103 @@
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()
}
}
}

BIN
img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB