diff --git a/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt b/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt index b5c0bbe..6da1422 100644 --- a/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt +++ b/app/src/main/java/com/nova/brain/glass/helper/AsrHelper.kt @@ -1,5 +1,8 @@ package com.nova.brain.glass.helper +import android.app.AlertDialog +import android.os.Handler +import android.os.Looper import android.util.Log import com.nova.brain.glass.BuildConfig import com.nova.brain.glass.model.RecognizeAction @@ -9,6 +12,7 @@ import com.rokid.online.speech.OnlineSpeechSdkConfig import com.rokid.online.speech.TtsClient import com.rokid.online.speech.open.AndroidPcmTtsStreamPlayer import com.rokid.online.speech.open.OpenSdkAudioSource +import com.xuqm.base.common.AppManager import com.xuqm.base.extensions.showMessage object AsrHelper : OfflineCmdListener { @@ -41,6 +45,9 @@ object AsrHelper : OfflineCmdListener { private const val WAKE_RESPONSE = "在呢,您请说" + private val mainHandler = Handler(Looper.getMainLooper()) + private var listeningDialog: AlertDialog? = null + // 拼接每次识别会话中的中间结果 private var currentPartial = "" @@ -103,10 +110,32 @@ object AsrHelper : OfflineCmdListener { .onSuccess { isMicRunning = true Log.d(TAG, "ASR startAsrWithMic()") + showListeningDialog() } .onFailure { Log.e(TAG, "ASR startAsrWithMic failed: ${it.message}") } } + private fun showListeningDialog() { + mainHandler.post { + listeningDialog?.dismiss() + val activity = runCatching { AppManager.getInstance().getActivity() }.getOrNull() + ?: return@post + if (activity.isFinishing || activity.isDestroyed) return@post + listeningDialog = AlertDialog.Builder(activity) + .setMessage("飞宝在呢,您请说") + .setCancelable(false) + .create() + .also { it.show() } + } + } + + private fun dismissListeningDialog() { + mainHandler.post { + listeningDialog?.dismiss() + listeningDialog = null + } + } + private fun setupAsrCallbacks(asrClient: AsrClient) { asrClient.setListener(object : AsrClient.Listener { override fun onOpen() { @@ -132,6 +161,7 @@ object AsrHelper : OfflineCmdListener { currentPartial += text asr?.stopAsrWithMic() Log.d(TAG, "ASR final result: $text") + dismissListeningDialog() IntentRecognizeHelper.recognize( text = text, scence = scene, @@ -152,11 +182,13 @@ object AsrHelper : OfflineCmdListener { override fun onError(code: Int, message: String) { Log.e(TAG, "ASR error code=$code msg=$message") isMicRunning = false + dismissListeningDialog() } override fun onClosed(code: Int, reason: String) { isConnected = false isMicRunning = false + dismissListeningDialog() Log.d(TAG, "ASR closed code=$code reason=$reason") } }) @@ -205,6 +237,7 @@ object AsrHelper : OfflineCmdListener { runCatching { asr?.stopAsrWithMic() } isMicRunning = false } + dismissListeningDialog() asr?.close() tts?.close() sdk?.close() diff --git a/app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt b/app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt index c1a8e2e..fc65524 100644 --- a/app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt +++ b/app/src/main/java/com/nova/brain/glass/ui/ChatActivity.kt @@ -1,6 +1,7 @@ package com.nova.brain.glass.ui import android.view.View +import android.view.ViewGroup import android.widget.TextView import com.nova.brain.glass.R import com.nova.brain.glass.databinding.ActivityChatBinding @@ -59,18 +60,34 @@ class ChatActivity : BaseListFormLayoutNormalActivity(R.layout.item_chat) { override fun convert(holder: ViewHolder, item: ChatItem, position: Int) { - holder.setVisibility(R.id.line, position != 0) val chatItems = viewModel.chatItems val liveItem = if (position < chatItems.size) chatItems[position] else item + + // 占位 item:高度撑满 RecyclerView,内容全隐藏 + if (liveItem.id == ChatVM.SPACER_ID) { + holder.setVisibility(R.id.line, false) + holder.setText(R.id.title, "") + holder.getView(R.id.content).text = "" + holder.itemView.layoutParams = holder.itemView.layoutParams.also { + it.height = recyclerView.height + } + return + } + + // 普通 item:确保高度恢复 wrap_content(RecyclerView 复用时可能残留占位高度) + holder.itemView.layoutParams = holder.itemView.layoutParams.also { + it.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + holder.setVisibility(R.id.line, position != 0) holder.setText(R.id.title, liveItem.title) val tv = holder.getView(R.id.content) markwon.setMarkdown(tv, liveItem.content) diff --git a/app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt b/app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt index 5544e02..c6ae8dc 100644 --- a/app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt +++ b/app/src/main/java/com/nova/brain/glass/ui/WelcomeActivity.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModelProvider import com.nova.brain.glass.R import com.nova.brain.glass.databinding.ActivityWelcomeBinding import com.nova.brain.glass.helper.AsrHelper +import com.nova.brain.glass.helper.IntentRecognizeHelper import com.nova.brain.glass.helper.OfflineCmdListener import com.nova.brain.glass.helper.OfflineCmdServiceHelper import com.nova.brain.glass.viewmodel.WelcomeVM @@ -47,8 +48,28 @@ class WelcomeActivity : BaseActivity() { super.initView(savedInstanceState) vm = ViewModelProvider(this)[WelcomeVM::class.java] window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + binding.tv.setOnClickListener { + triggerRecognize() + } } + private fun triggerRecognize() { + startDotsAnim() + IntentRecognizeHelper.recognize( + text = "当前阶段,最紧急的任务是什么?", + onSuccess = { action -> + if (action.name == "goToDecisionCenter") { + startActivity( + Intent(this, ChatActivity::class.java) + .putExtra("question", action.params.question) + ) + } + }, + onComplete = { + stopDotsAnim() + } + ) + } override fun initData() { super.initData() } diff --git a/app/src/main/java/com/nova/brain/glass/viewmodel/ChatVM.kt b/app/src/main/java/com/nova/brain/glass/viewmodel/ChatVM.kt index 152e851..f6a3945 100644 --- a/app/src/main/java/com/nova/brain/glass/viewmodel/ChatVM.kt +++ b/app/src/main/java/com/nova/brain/glass/viewmodel/ChatVM.kt @@ -21,15 +21,50 @@ import retrofit2.HttpException import java.util.UUID class ChatVM : BaseListViewModel() { + + companion object { + const val SPACER_ID = -1 + } + val result = MutableLiveData() val loading = MutableLiveData() - val chatItems: MutableList = mutableListOf() + /** 末尾始终保留一个占位 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()) + /** 最后一个真实 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) { @@ -42,9 +77,11 @@ class ChatVM : BaseListViewModel() { fun demoPostSse(question: String) { currentTask?.dispose() currentTask = null + stopThinkingAnimation() val itemId = itemIdCounter++ - chatItems.add(ChatItem(itemId, question, "")) + // 插入到占位 item 之前,保证占位 item 始终在末尾 + chatItems.add(chatItems.size - 1, ChatItem(itemId, question, "")) if (dataSourceReady) invalidate() result.postValue(UUID.randomUUID().toString()) loading.postValue(true) @@ -63,7 +100,8 @@ class ChatVM : BaseListViewModel() { if (l.trimStart().startsWith("<")) { // HTML 响应(隧道断开),移除占位项并在主线程用相同问题重试 mainHandler.post { - if (chatItems.isNotEmpty()) chatItems.removeAt(chatItems.size - 1) + val ri = lastRealIndex + if (ri >= 0) chatItems.removeAt(ri) demoPostSse(question) } loading.postValue(false) @@ -76,7 +114,7 @@ class ChatVM : BaseListViewModel() { if (model.type == null) { loading.postValue(false) val msg = model.msg ?: json - val lastIndex = chatItems.size - 1 + val lastIndex = lastRealIndex if (lastIndex >= 0) { chatItems[lastIndex].content = msg notifyItem(lastIndex) @@ -87,13 +125,18 @@ class ChatVM : BaseListViewModel() { if (model.role != "assistant") continue when (model.type) { "reason" -> { - val m = GsonImplHelp.get().toObject(json, ChatModel2::class.java) - content += m.data.content - currentType = "reason" + if (currentType != "reason") { + // 第一个 reason 帧:启动六点轮询动画 + currentType = "reason" + startThinkingAnimation() + } + // 内容由动画在主线程自行写入,此处跳过 notifyItem + continue } "string" -> { if (currentType != "string") { - // 第一条 string:清除 reason 阶段的内容,重新开始 + // 第一条 string:停止动画,清空 reason 阶段内容 + stopThinkingAnimation() content = "" currentType = "string" } @@ -102,7 +145,7 @@ class ChatVM : BaseListViewModel() { } else -> continue } - val lastIndex = chatItems.size - 1 + val lastIndex = lastRealIndex if (lastIndex >= 0) { chatItems[lastIndex].content = content notifyItem(lastIndex) @@ -112,9 +155,10 @@ class ChatVM : BaseListViewModel() { } } catch (e: Exception) { LogHelper.e(">>>>11", e) + stopThinkingAnimation() loading.postValue(false) val errMsg = "AI反馈异常: ${e.message}" - val lastIndex = chatItems.size - 1 + val lastIndex = lastRealIndex if (lastIndex >= 0) { chatItems[lastIndex].content = errMsg notifyItem(lastIndex) @@ -122,9 +166,11 @@ class ChatVM : BaseListViewModel() { result.postValue(errMsg) } } + stopThinkingAnimation() loading.postValue(false) }, { e -> LogHelper.e(">>>>22", e) + stopThinkingAnimation() loading.postValue(false) val errMsg = if (e is HttpException) { runCatching { @@ -134,7 +180,7 @@ class ChatVM : BaseListViewModel() { } else { "AI反馈异常: ${e.message}" } - val lastIndex = chatItems.size - 1 + val lastIndex = lastRealIndex if (lastIndex >= 0) { chatItems[lastIndex].content = errMsg notifyItem(lastIndex)