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() { companion object { const val SPACER_ID = -1 private const val DEFAULT_TOPIC_ID = 14478 } val result = MutableLiveData() val loading = MutableLiveData() /** 末尾始终保留一个占位 item(SPACER_ID),高度 = RecyclerView 高度,用于支持最新 item 滚到顶部 */ val chatItems: MutableList = 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) { 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) 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) } } }