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("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)
|
||||||
|
|
@ -84,7 +77,6 @@ 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,7 +10,6 @@ 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"
|
||||||
|
|
@ -69,12 +68,4 @@ 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,7 +155,6 @@ 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,7 +4,6 @@ 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
|
||||||
|
|
@ -191,20 +190,6 @@ class MainActivity : AppCompatActivity() {
|
||||||
// 启动时检查更新
|
// 启动时检查更新
|
||||||
UpdateManager(this).checkUpdate()
|
UpdateManager(this).checkUpdate()
|
||||||
|
|
||||||
// 👈 动态注入 Compose 容器,让 FloatingChatWidget 呈现在最上层!
|
|
||||||
val composeView = androidx.compose.ui.platform.ComposeView(this).apply {
|
|
||||||
setContent {
|
|
||||||
com.stand.standapp.ui.chat.FloatingChatWidget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
findViewById<FrameLayout>(android.R.id.content)?.addView(
|
|
||||||
composeView,
|
|
||||||
FrameLayout.LayoutParams(
|
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("MainActivity").e(e, "Fatal Crash")
|
Timber.tag("MainActivity").e(e, "Fatal Crash")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,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