Prechádzať zdrojové kódy

feat(chat): 添加语音识别对话功能和聊天界面优化

- 在 AsrHelper 中添加语音识别监听对话框显示
- 实现聊天界面 RecyclerView 底部占位项以支持滚动到顶部
- 添加 WelcomeActivity 点击触发语音识别功能
- 实现 ChatVM 中思考动画和消息处理逻辑
- 集成 IntentRecognizeHelper 语音识别回调处理
- 优化聊天消息列表滚动和内容更新机制
徐勤民 16 hodín pred
rodič
commit
2757854f53

+ 33 - 0
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()

+ 22 - 5
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<ChatItem, ChatVM, Activity
     }
 
     private fun scrollToBottom() {
-        val lastIndex = (recyclerView.adapter?.itemCount ?: 1) - 1
-        if (lastIndex < 0) return
+        // 跳过末尾占位 item,滚动到最后一个真实 item
+        val lastRealIndex = (recyclerView.adapter?.itemCount ?: 2) - 2
+        if (lastRealIndex < 0) return
         val lm = recyclerView.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager ?: return
-        // 将最新 item 顶部对齐到 RecyclerView 顶部
-        lm.scrollToPositionWithOffset(lastIndex, 0)
+        lm.scrollToPositionWithOffset(lastRealIndex, 0)
     }
 
     override fun adapter() = object : CommonPagedAdapter<ChatItem>(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<TextView>(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<TextView>(R.id.content)
             markwon.setMarkdown(tv, liveItem.content)

+ 21 - 0
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<ActivityWelcomeBinding>() {
         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()
     }

+ 57 - 11
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<ChatItem>() {
+
+    companion object {
+        const val SPACER_ID = -1
+    }
+
     val result = MutableLiveData<String>()
     val loading = MutableLiveData<Boolean>()
-    val chatItems: MutableList<ChatItem> = mutableListOf()
+    /** 末尾始终保留一个占位 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())
 
+    /** 最后一个真实 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) {
@@ -42,9 +77,11 @@ class ChatVM : BaseListViewModel<ChatItem>() {
     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<ChatItem>() {
                                 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<ChatItem>() {
                                 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<ChatItem>() {
                                 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<ChatItem>() {
                                     }
                                     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<ChatItem>() {
                         }
                     } 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<ChatItem>() {
                         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<ChatItem>() {
                 } else {
                     "AI反馈异常: ${e.message}"
                 }
-                val lastIndex = chatItems.size - 1
+                val lastIndex = lastRealIndex
                 if (lastIndex >= 0) {
                     chatItems[lastIndex].content = errMsg
                     notifyItem(lastIndex)