Compare commits
14 Commits
b4f61c4b75
...
dbb430b088
| Author | SHA1 | Date |
|---|---|---|
|
|
dbb430b088 | |
|
|
bd5bd63f42 | |
|
|
74788fd22e | |
|
|
99c691378e | |
|
|
6d12291941 | |
|
|
05392384b6 | |
|
|
30939f13fd | |
|
|
1cf1ffd9e8 | |
|
|
338ecdfd43 | |
|
|
21750acd2c | |
|
|
e1bb993b09 | |
|
|
62ef1e2c38 | |
|
|
fcf63c6175 | |
|
|
f93c7c3e43 |
|
|
@ -67,6 +67,13 @@ dependencies {
|
||||||
implementation("org.mozilla.geckoview:geckoview-omni:115.0.20230710165010")
|
implementation("org.mozilla.geckoview:geckoview-omni:115.0.20230710165010")
|
||||||
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("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("com.github.getActivity:XXPermissions:18.2")
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
|
@ -77,6 +84,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)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ object AppConfig {
|
||||||
private const val KEY_REMEMBER_LOGIN = "remember_login"
|
private const val KEY_REMEMBER_LOGIN = "remember_login"
|
||||||
private const val KEY_USERNAME = "saved_username"
|
private const val KEY_USERNAME = "saved_username"
|
||||||
private const val KEY_PASSWORD = "saved_password"
|
private const val KEY_PASSWORD = "saved_password"
|
||||||
|
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
const val DEFAULT_SERVER_URL = "https://template.gyouzhe.com"
|
const val DEFAULT_SERVER_URL = "https://template.gyouzhe.com"
|
||||||
|
|
@ -68,4 +69,12 @@ object AppConfig {
|
||||||
fun setSavedPassword(context: Context, password: String) {
|
fun setSavedPassword(context: Context, password: String) {
|
||||||
getPrefs(context).edit().putString(KEY_PASSWORD, password).apply()
|
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,6 +155,7 @@ class LoginActivity : AppCompatActivity() {
|
||||||
val data = json.optJSONObject("data")
|
val data = json.optJSONObject("data")
|
||||||
val loginData = data?.toString() ?: "{}"
|
val loginData = data?.toString() ?: "{}"
|
||||||
val accessToken = data?.optString("access_token", "") ?: ""
|
val accessToken = data?.optString("access_token", "") ?: ""
|
||||||
|
AppConfig.setAccessToken(this@LoginActivity, accessToken)
|
||||||
|
|
||||||
Toast.makeText(applicationContext, "登录成功", Toast.LENGTH_SHORT).show()
|
Toast.makeText(applicationContext, "登录成功", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue