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

- 在 AsrHelper 中添加语音识别监听对话框显示
- 实现聊天界面 RecyclerView 底部占位项以支持滚动到顶部
- 添加 WelcomeActivity 点击触发语音识别功能
- 实现 ChatVM 中思考动画和消息处理逻辑
- 集成 IntentRecognizeHelper 语音识别回调处理
- 优化聊天消息列表滚动和内容更新机制
这个提交包含在:
徐勤民 2026-04-16 23:49:12 +08:00
父节点 7d47f557b1
当前提交 2757854f53
共有 4 个文件被更改,包括 133 次插入16 次删除

查看文件

@ -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()

查看文件

@ -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_contentRecyclerView 复用时可能残留占位高度)
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)

查看文件

@ -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()
}

查看文件

@ -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()
/** 末尾始终保留一个占位 itemSPACER_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)