feat(chat): 添加语音识别对话功能和聊天界面优化
- 在 AsrHelper 中添加语音识别监听对话框显示 - 实现聊天界面 RecyclerView 底部占位项以支持滚动到顶部 - 添加 WelcomeActivity 点击触发语音识别功能 - 实现 ChatVM 中思考动画和消息处理逻辑 - 集成 IntentRecognizeHelper 语音识别回调处理 - 优化聊天消息列表滚动和内容更新机制
这个提交包含在:
父节点
7d47f557b1
当前提交
2757854f53
@ -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_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)
|
||||
|
||||
@ -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()
|
||||
/** 末尾始终保留一个占位 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
|
||||
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)
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户