ChatVM.kt 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. package com.nova.brain.glass.viewmodel
  2. import android.os.Handler
  3. import android.os.Looper
  4. import androidx.lifecycle.MutableLiveData
  5. import com.nova.brain.glass.model.ChatItem
  6. import com.nova.brain.glass.model.ChatModel
  7. import com.nova.brain.glass.model.ChatModel1
  8. import com.nova.brain.glass.model.ChatModel2
  9. import com.nova.brain.glass.model.data.ChatData
  10. import com.nova.brain.glass.model.data.TopicData
  11. import com.nova.brain.glass.repository.Service
  12. import com.xuqm.base.common.GsonImplHelp
  13. import com.xuqm.base.common.LogHelper
  14. import com.xuqm.base.di.manager.HttpManager
  15. import com.xuqm.base.viewmodel.BaseListViewModel
  16. import com.xuqm.base.viewmodel.callback.Response
  17. import io.reactivex.android.schedulers.AndroidSchedulers
  18. import io.reactivex.disposables.Disposable
  19. import io.reactivex.schedulers.Schedulers
  20. import org.json.JSONObject
  21. import retrofit2.HttpException
  22. import java.util.UUID
  23. class ChatVM : BaseListViewModel<ChatItem>() {
  24. companion object {
  25. const val SPACER_ID = -1
  26. private const val DEFAULT_TOPIC_ID = 14478
  27. }
  28. val result = MutableLiveData<String>()
  29. val loading = MutableLiveData<Boolean>()
  30. /** 末尾始终保留一个占位 item(SPACER_ID),高度 = RecyclerView 高度,用于支持最新 item 滚到顶部 */
  31. val chatItems: MutableList<ChatItem> = mutableListOf(ChatItem(SPACER_ID, "", ""))
  32. private var currentTask: Disposable? = null
  33. private var itemIdCounter = 0
  34. private var dataSourceReady = false
  35. private val mainHandler = Handler(Looper.getMainLooper())
  36. /** 进入页面时由 prepareTopic 设置,后续所有 demoPostSse 复用 */
  37. private var currentTopicId: Int = DEFAULT_TOPIC_ID
  38. /** 最后一个真实 item 的索引(占位 item 之前) */
  39. private val lastRealIndex get() = chatItems.size - 2
  40. // "思考中" 六点轮询动画
  41. private var dotsRunnable: Runnable? = null
  42. private var dotsCount = 0
  43. private fun startThinkingAnimation() {
  44. stopThinkingAnimation()
  45. dotsCount = 1
  46. dotsRunnable = object : Runnable {
  47. override fun run() {
  48. val idx = lastRealIndex
  49. if (idx >= 0) {
  50. chatItems[idx].content = "思考中" + "·".repeat(dotsCount)
  51. notifyItem(idx)
  52. }
  53. dotsCount = dotsCount % 6 + 1
  54. mainHandler.postDelayed(this, 300)
  55. }
  56. }
  57. mainHandler.post(dotsRunnable!!)
  58. }
  59. private fun stopThinkingAnimation() {
  60. dotsRunnable?.let { mainHandler.removeCallbacks(it) }
  61. dotsRunnable = null
  62. }
  63. override fun loadData(page: Int, onResponse: Response<ChatItem>) {
  64. dataSourceReady = true
  65. if (page == 0) {
  66. onResponse.onResponse(ArrayList(chatItems))
  67. } else {
  68. onResponse.onResponse(ArrayList())
  69. }
  70. }
  71. /**
  72. * 进入 Chat 页面时调用一次:请求 tbtopic 接口设置本次会话的 topicId,
  73. * 成功(code==0)使用返回值,否则沿用默认值 14478,然后发起第一次 SSE。
  74. */
  75. fun prepareTopic(question: String) {
  76. HttpManager.getApi(Service::class.java)
  77. .tbtopic(TopicData(topicName = question))
  78. .subscribeOn(Schedulers.io())
  79. .observeOn(AndroidSchedulers.mainThread())
  80. .subscribe({ model ->
  81. currentTopicId = if (model.code == 0) model.data else DEFAULT_TOPIC_ID
  82. demoPostSse(question)
  83. }, { _ ->
  84. currentTopicId = DEFAULT_TOPIC_ID
  85. demoPostSse(question)
  86. }).also { add(it) }
  87. }
  88. fun demoPostSse(question: String) {
  89. currentTask?.dispose()
  90. currentTask = null
  91. stopThinkingAnimation()
  92. val itemId = itemIdCounter++
  93. // 插入到占位 item 之前,保证占位 item 始终在末尾
  94. chatItems.add(chatItems.size - 1, ChatItem(itemId, question, ""))
  95. if (dataSourceReady) invalidate()
  96. result.postValue(UUID.randomUUID().toString())
  97. loading.postValue(true)
  98. currentTask = HttpManager.getApi(Service::class.java).chat(ChatData(question, currentTopicId))
  99. .subscribeOn(Schedulers.io())
  100. .subscribe({ body ->
  101. var content = ""
  102. var currentType = "" // 追踪当前阶段:reason / string
  103. body.charStream().buffered().use { reader ->
  104. try {
  105. var line: String?
  106. while (reader.readLine().also { line = it } != null) {
  107. val l = line!!
  108. if (l.isNotEmpty()) {
  109. if (l.trimStart().startsWith("<")) {
  110. // HTML 响应(隧道断开),移除占位项并在主线程用相同问题重试
  111. mainHandler.post {
  112. val ri = lastRealIndex
  113. if (ri >= 0) chatItems.removeAt(ri)
  114. demoPostSse(question)
  115. }
  116. loading.postValue(false)
  117. return@use
  118. }
  119. // 只处理 data:{...} 格式,其他行(event/id/注释等)跳过
  120. if (!l.startsWith("data:{")) continue
  121. val json = l.removePrefix("data:").trim()
  122. val model = GsonImplHelp.get().toObject(json, ChatModel::class.java)
  123. result.postValue(UUID.randomUUID().toString())
  124. if (model.type == null) {
  125. loading.postValue(false)
  126. val msg = model.msg ?: json
  127. val lastIndex = lastRealIndex
  128. if (lastIndex >= 0) {
  129. chatItems[lastIndex].content = msg
  130. notifyItem(lastIndex)
  131. }
  132. result.postValue(msg)
  133. return@use
  134. }
  135. if (model.role != "assistant") continue
  136. when (model.type) {
  137. "reason" -> {
  138. if (currentType != "reason") {
  139. // 第一个 reason 帧:启动六点轮询动画
  140. currentType = "reason"
  141. startThinkingAnimation()
  142. }
  143. // 内容由动画在主线程自行写入,此处跳过 notifyItem
  144. continue
  145. }
  146. "string" -> {
  147. if (currentType != "string") {
  148. // 第一条 string:停止动画,清空 reason 阶段内容
  149. stopThinkingAnimation()
  150. content = ""
  151. currentType = "string"
  152. }
  153. val m = GsonImplHelp.get().toObject(json, ChatModel1::class.java)
  154. content += m.data
  155. }
  156. else -> continue
  157. }
  158. val lastIndex = lastRealIndex
  159. if (lastIndex >= 0) {
  160. chatItems[lastIndex].content = content
  161. notifyItem(lastIndex)
  162. }
  163. result.postValue(content)
  164. }
  165. }
  166. } catch (e: Exception) {
  167. LogHelper.e(">>>>11", e)
  168. stopThinkingAnimation()
  169. loading.postValue(false)
  170. val errMsg = "AI反馈异常: ${e.message}"
  171. val lastIndex = lastRealIndex
  172. if (lastIndex >= 0) {
  173. chatItems[lastIndex].content = errMsg
  174. notifyItem(lastIndex)
  175. }
  176. result.postValue(errMsg)
  177. }
  178. }
  179. stopThinkingAnimation()
  180. loading.postValue(false)
  181. }, { e ->
  182. LogHelper.e(">>>>22", e)
  183. stopThinkingAnimation()
  184. loading.postValue(false)
  185. val errMsg = if (e is HttpException) {
  186. runCatching {
  187. val body = e.response()?.errorBody()?.string() ?: ""
  188. JSONObject(body).optString("msg", e.message())
  189. }.getOrDefault(e.message())
  190. } else {
  191. "AI反馈异常: ${e.message}"
  192. }
  193. val lastIndex = lastRealIndex
  194. if (lastIndex >= 0) {
  195. chatItems[lastIndex].content = errMsg
  196. notifyItem(lastIndex)
  197. }
  198. result.postValue(errMsg)
  199. })
  200. currentTask?.also { add(it) }
  201. }
  202. }