| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- package com.nova.brain.glass.viewmodel
- import android.os.Handler
- import android.os.Looper
- import androidx.lifecycle.MutableLiveData
- import com.nova.brain.glass.model.ChatItem
- import com.nova.brain.glass.model.ChatModel
- import com.nova.brain.glass.model.ChatModel1
- import com.nova.brain.glass.model.ChatModel2
- import com.nova.brain.glass.model.data.ChatData
- import com.nova.brain.glass.model.data.TopicData
- import com.nova.brain.glass.repository.Service
- import com.xuqm.base.common.GsonImplHelp
- import com.xuqm.base.common.LogHelper
- import com.xuqm.base.di.manager.HttpManager
- import com.xuqm.base.viewmodel.BaseListViewModel
- import com.xuqm.base.viewmodel.callback.Response
- import io.reactivex.android.schedulers.AndroidSchedulers
- import io.reactivex.disposables.Disposable
- import io.reactivex.schedulers.Schedulers
- import org.json.JSONObject
- import retrofit2.HttpException
- import java.util.UUID
- class ChatVM : BaseListViewModel<ChatItem>() {
- companion object {
- const val SPACER_ID = -1
- private const val DEFAULT_TOPIC_ID = 14478
- }
- val result = MutableLiveData<String>()
- val loading = MutableLiveData<Boolean>()
- /** 末尾始终保留一个占位 item(SPACER_ID),高度 = RecyclerView 高度,用于支持最新 item 滚到顶部 */
- val chatItems: MutableList<ChatItem> = mutableListOf(ChatItem(SPACER_ID, "", ""))
- private var currentTask: Disposable? = null
- private var itemIdCounter = 0
- private var dataSourceReady = false
- private val mainHandler = Handler(Looper.getMainLooper())
- /** 进入页面时由 prepareTopic 设置,后续所有 demoPostSse 复用 */
- private var currentTopicId: Int = DEFAULT_TOPIC_ID
- /** 最后一个真实 item 的索引(占位 item 之前) */
- private val lastRealIndex get() = chatItems.size - 2
- // "思考中" 六点轮询动画
- private var dotsRunnable: Runnable? = null
- private var dotsCount = 0
- private fun startThinkingAnimation() {
- stopThinkingAnimation()
- dotsCount = 1
- dotsRunnable = object : Runnable {
- override fun run() {
- val idx = lastRealIndex
- if (idx >= 0) {
- chatItems[idx].content = "思考中" + "·".repeat(dotsCount)
- notifyItem(idx)
- }
- dotsCount = dotsCount % 6 + 1
- mainHandler.postDelayed(this, 300)
- }
- }
- mainHandler.post(dotsRunnable!!)
- }
- private fun stopThinkingAnimation() {
- dotsRunnable?.let { mainHandler.removeCallbacks(it) }
- dotsRunnable = null
- }
- override fun loadData(page: Int, onResponse: Response<ChatItem>) {
- dataSourceReady = true
- if (page == 0) {
- onResponse.onResponse(ArrayList(chatItems))
- } else {
- onResponse.onResponse(ArrayList())
- }
- }
- /**
- * 进入 Chat 页面时调用一次:请求 tbtopic 接口设置本次会话的 topicId,
- * 成功(code==0)使用返回值,否则沿用默认值 14478,然后发起第一次 SSE。
- */
- fun prepareTopic(question: String) {
- HttpManager.getApi(Service::class.java)
- .tbtopic(TopicData(topicName = question))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe({ model ->
- currentTopicId = if (model.code == 0) model.data else DEFAULT_TOPIC_ID
- demoPostSse(question)
- }, { _ ->
- currentTopicId = DEFAULT_TOPIC_ID
- demoPostSse(question)
- }).also { add(it) }
- }
- fun demoPostSse(question: String) {
- currentTask?.dispose()
- currentTask = null
- stopThinkingAnimation()
- val itemId = itemIdCounter++
- // 插入到占位 item 之前,保证占位 item 始终在末尾
- chatItems.add(chatItems.size - 1, ChatItem(itemId, question, ""))
- if (dataSourceReady) invalidate()
- result.postValue(UUID.randomUUID().toString())
- loading.postValue(true)
- currentTask = HttpManager.getApi(Service::class.java).chat(ChatData(question, currentTopicId))
- .subscribeOn(Schedulers.io())
- .subscribe({ body ->
- var content = ""
- var currentType = "" // 追踪当前阶段:reason / string
- body.charStream().buffered().use { reader ->
- try {
- var line: String?
- while (reader.readLine().also { line = it } != null) {
- val l = line!!
- if (l.isNotEmpty()) {
- if (l.trimStart().startsWith("<")) {
- // HTML 响应(隧道断开),移除占位项并在主线程用相同问题重试
- mainHandler.post {
- val ri = lastRealIndex
- if (ri >= 0) chatItems.removeAt(ri)
- demoPostSse(question)
- }
- loading.postValue(false)
- return@use
- }
- // 只处理 data:{...} 格式,其他行(event/id/注释等)跳过
- if (!l.startsWith("data:{")) continue
- val json = l.removePrefix("data:").trim()
- val model = GsonImplHelp.get().toObject(json, ChatModel::class.java)
- result.postValue(UUID.randomUUID().toString())
- if (model.type == null) {
- loading.postValue(false)
- val msg = model.msg ?: json
- val lastIndex = lastRealIndex
- if (lastIndex >= 0) {
- chatItems[lastIndex].content = msg
- notifyItem(lastIndex)
- }
- result.postValue(msg)
- return@use
- }
- if (model.role != "assistant") continue
- when (model.type) {
- "reason" -> {
- if (currentType != "reason") {
- // 第一个 reason 帧:启动六点轮询动画
- currentType = "reason"
- startThinkingAnimation()
- }
- // 内容由动画在主线程自行写入,此处跳过 notifyItem
- continue
- }
- "string" -> {
- if (currentType != "string") {
- // 第一条 string:停止动画,清空 reason 阶段内容
- stopThinkingAnimation()
- content = ""
- currentType = "string"
- }
- val m = GsonImplHelp.get().toObject(json, ChatModel1::class.java)
- content += m.data
- }
- else -> continue
- }
- val lastIndex = lastRealIndex
- if (lastIndex >= 0) {
- chatItems[lastIndex].content = content
- notifyItem(lastIndex)
- }
- result.postValue(content)
- }
- }
- } catch (e: Exception) {
- LogHelper.e(">>>>11", e)
- stopThinkingAnimation()
- loading.postValue(false)
- val errMsg = "AI反馈异常: ${e.message}"
- val lastIndex = lastRealIndex
- if (lastIndex >= 0) {
- chatItems[lastIndex].content = errMsg
- notifyItem(lastIndex)
- }
- result.postValue(errMsg)
- }
- }
- stopThinkingAnimation()
- loading.postValue(false)
- }, { e ->
- LogHelper.e(">>>>22", e)
- stopThinkingAnimation()
- loading.postValue(false)
- val errMsg = if (e is HttpException) {
- runCatching {
- val body = e.response()?.errorBody()?.string() ?: ""
- JSONObject(body).optString("msg", e.message())
- }.getOrDefault(e.message())
- } else {
- "AI反馈异常: ${e.message}"
- }
- val lastIndex = lastRealIndex
- if (lastIndex >= 0) {
- chatItems[lastIndex].content = errMsg
- notifyItem(lastIndex)
- }
- result.postValue(errMsg)
- })
- currentTask?.also { add(it) }
- }
- }
|