ai state machine init
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@@ -113,7 +113,85 @@ https://www.modelscope.cn/datasets/shaoxuan/WIDER_FACE/files
|
||||
8. 人脸识别模型是insightface的r18模型,转成了rknn格式,并且使用了 lfw 的数据集进行了校准,下载地址:
|
||||
https://tianchi.aliyun.com/dataset/93864
|
||||
|
||||
9.
|
||||
9. 本地LLM,用的是 rkllm 模型,由于内存限制,只能用较小的模型,如 Qwen3-0.6B-rk3588-w8a8.rkllm。
|
||||
|
||||
10. 数字人交互设计说明书(优化版)
|
||||
核心原则
|
||||
* 尊重用户:给用户足够的反应时间,不抢话,不催促。
|
||||
* 主动但不打扰:用户沉默时适度引导,但不强行交互;数字人拥有独立的内心活动,即便无人交互也保持“生命感”。
|
||||
* 个性化:记住每个用户的偏好、名字、对话历史,并据此调整问候和回应。
|
||||
* 动作同步:Live2D人物的表情与动作与当前状态、情绪、内心活动相匹配。
|
||||
* 持续内心活动:数字人即使在没有用户时也会进行“回忆”或“思考”,并通过文字展现,用户可随时打断并询问想法。
|
||||
|
||||
状态定义
|
||||
[空闲状态]:无人,数字人无内心活动(省电模式)。
|
||||
[回忆状态]:无人,数字人正在根据记忆产生内心想法,通过表情表现。
|
||||
[问候状态]:检测到用户,主动打招呼。
|
||||
[等待回复状态]:问候后或对话间隙,等待用户回复。
|
||||
[对话状态]:正在与用户交流,根据情绪调整动作。
|
||||
[主动引导状态]:用户沉默但仍在画面中,数字人主动开启新话题。
|
||||
[告别状态]:用户离开画面,数字人告别。
|
||||
[空闲状态]
|
||||
动作 : haru_g_idle (待机动作)
|
||||
↓
|
||||
检测到人脸 → 等待1秒(让人脸稳定)
|
||||
↓
|
||||
[问候状态]
|
||||
动作 :
|
||||
- 认识用户: haru_g_m22 (高兴动作)
|
||||
- 不认识: haru_g_m01(中性动作)
|
||||
AI问候(根据是否认识个性化)
|
||||
启动20秒计时器
|
||||
↓
|
||||
[等待回复状态]
|
||||
动作 : haru_g_m17
|
||||
↓
|
||||
├─ 如果用户在20秒内回复 → 进入[对话状态]
|
||||
│ 动作 :根据对话情绪动态调整
|
||||
│ - 开心: haru_g_m22 / haru_g_m21 / haru_g_m18 / haru_g_m09 / haru_g_m08
|
||||
│ - 悲伤: haru_g_m25 / haru_g_m24 / haru_g_m05 / haru_g_m16
|
||||
│ - 惊讶: haru_g_m26 / haru_g_m12
|
||||
│ - 愤怒: haru_g_m04 / haru_g_m11 / haru_g_m04
|
||||
│ - 平静: haru_g_m15 / haru_g_m07 / haru_g_m06 / haru_g_m02 / haru_g_m01
|
||||
│ AI根据内容回应,持续直到用户说“再见”
|
||||
│ ↓
|
||||
│ (用户离开)→ 进入[告别状态]
|
||||
│
|
||||
└─ 如果20秒内无回复
|
||||
↓
|
||||
检查用户是否还在画面
|
||||
↓
|
||||
├─ 如果还在 → [主动引导状态]
|
||||
│ 动作 : haru_g_m15 / haru_g_m07 (中性动作)
|
||||
│ AI开启轻松话题(如“我出一道数学题考考你吧?1+6等于多少?”)
|
||||
│ 等待10秒
|
||||
│ ↓
|
||||
│ ├─ 回复 → 进入[对话状态]
|
||||
│ └─ 没回复 → 换话题,如,我们上完厕所应该干什么呀?最多重复3次
|
||||
│ 动作 : haru_g_m22 / haru_g_m18
|
||||
│ (重复尝试)→ 再次等待10秒
|
||||
│ (3次后仍无回复)→ 回到[等待回复状态]
|
||||
│
|
||||
└─ 如果已离开 → [告别状态]
|
||||
动作 : haru_g_idle (告别后待机)
|
||||
↓
|
||||
3秒后 → 回到[空闲状态]
|
||||
|
||||
[空闲状态] (长时间无人)
|
||||
↓
|
||||
如果持续30秒无人 → 进入[回忆状态]
|
||||
动作 : haru_g_m15
|
||||
回顾之前的聊天记录,生成想法,不发声,把想法显示在屏幕上
|
||||
↓
|
||||
如果检测到人脸 → 立即中断回忆,进入[问候状态]
|
||||
|
||||
|
||||
[回忆状态] 被用户询问“你在想什么?”
|
||||
↓
|
||||
AI说出最近的想法(如“我刚才在想,上次你说你喜欢蓝色...”)
|
||||
↓
|
||||
进入[问候状态]或直接进入[对话状态](根据用户后续反应)
|
||||
|
||||
[告别状态] (用户离开后)
|
||||
动作 : 简短告别,用语音,→ 等待3秒 → 回到[空闲状态]
|
||||
|
||||
|
||||
@@ -27,10 +27,15 @@ import com.digitalperson.face.ImageProxyBitmapConverter
|
||||
import com.digitalperson.metrics.TraceManager
|
||||
import com.digitalperson.metrics.TraceSession
|
||||
import com.digitalperson.tts.TtsController
|
||||
import com.digitalperson.interaction.DigitalHumanInteractionController
|
||||
import com.digitalperson.interaction.InteractionActionHandler
|
||||
import com.digitalperson.interaction.InteractionState
|
||||
import com.digitalperson.interaction.UserMemoryStore
|
||||
import com.digitalperson.llm.LLMManager
|
||||
import com.digitalperson.llm.LLMManagerCallback
|
||||
import com.digitalperson.util.FileHelper
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -85,9 +90,18 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
private lateinit var cameraPreviewView: PreviewView
|
||||
private lateinit var faceOverlayView: FaceOverlayView
|
||||
private lateinit var faceDetectionPipeline: FaceDetectionPipeline
|
||||
private lateinit var interactionController: DigitalHumanInteractionController
|
||||
private lateinit var userMemoryStore: UserMemoryStore
|
||||
private var facePipelineReady: Boolean = false
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private lateinit var cameraAnalyzerExecutor: ExecutorService
|
||||
private var activeUserId: String = "guest"
|
||||
private var pendingLocalThoughtCallback: ((String) -> Unit)? = null
|
||||
private var pendingLocalProfileCallback: ((String) -> Unit)? = null
|
||||
private var localThoughtSilentMode: Boolean = false
|
||||
private val recentConversationLines = ArrayList<String>()
|
||||
private var recentConversationDirty: Boolean = false
|
||||
private var lastFacePresent: Boolean = false
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
@@ -143,15 +157,73 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
cameraPreviewView.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
faceOverlayView = findViewById(R.id.face_overlay)
|
||||
cameraAnalyzerExecutor = Executors.newSingleThreadExecutor()
|
||||
userMemoryStore = UserMemoryStore(applicationContext)
|
||||
interactionController = DigitalHumanInteractionController(
|
||||
scope = ioScope,
|
||||
handler = object : InteractionActionHandler {
|
||||
override fun onStateChanged(state: InteractionState) {
|
||||
runOnUiThread {
|
||||
uiManager.appendToUi("\n[State] $state\n")
|
||||
}
|
||||
if (state == InteractionState.IDLE) {
|
||||
analyzeUserProfileInIdleIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
override fun playMotion(motionName: String) {
|
||||
playInteractionMotion(motionName)
|
||||
}
|
||||
|
||||
override fun appendText(text: String) {
|
||||
uiManager.appendToUi(text)
|
||||
}
|
||||
|
||||
override fun speak(text: String) {
|
||||
ttsController.enqueueSegment(text)
|
||||
ttsController.enqueueEnd()
|
||||
}
|
||||
|
||||
override fun requestCloudReply(userText: String) {
|
||||
llmInFlight = true
|
||||
Log.i(TAG_LLM, "Routing dialogue to CLOUD")
|
||||
cloudApiManager.callLLM(buildCloudPromptWithUserProfile(userText))
|
||||
}
|
||||
|
||||
override fun requestLocalThought(prompt: String, onResult: (String) -> Unit) {
|
||||
this@Live2DChatActivity.requestLocalThought(prompt, onResult)
|
||||
}
|
||||
|
||||
override fun onRememberUser(faceIdentityId: String, name: String?) {
|
||||
activeUserId = faceIdentityId
|
||||
userMemoryStore.upsertUserSeen(activeUserId, name)
|
||||
}
|
||||
|
||||
override fun saveThought(thought: String) {
|
||||
userMemoryStore.upsertUserSeen(activeUserId, null)
|
||||
userMemoryStore.updateThought(activeUserId, thought)
|
||||
}
|
||||
|
||||
override fun loadLatestThought(): String? = userMemoryStore.getLatestThought()
|
||||
|
||||
override fun addToChatHistory(role: String, content: String) {
|
||||
appendConversationLine(role, content)
|
||||
}
|
||||
|
||||
override fun addAssistantMessageToCloudHistory(content: String) {
|
||||
cloudApiManager.addAssistantMessage(content)
|
||||
}
|
||||
}
|
||||
)
|
||||
faceDetectionPipeline = FaceDetectionPipeline(
|
||||
context = applicationContext,
|
||||
onResult = { result ->
|
||||
faceOverlayView.updateResult(result)
|
||||
},
|
||||
onGreeting = { greeting ->
|
||||
uiManager.appendToUi("\n[Face] $greeting\n")
|
||||
ttsController.enqueueSegment(greeting)
|
||||
ttsController.enqueueEnd()
|
||||
onPresenceChanged = { present, faceIdentityId, recognizedName ->
|
||||
if (present == lastFacePresent) return@FaceDetectionPipeline
|
||||
lastFacePresent = present
|
||||
Log.d(TAG_ACTIVITY, "present=$present, faceIdentityId=$faceIdentityId, recognized=$recognizedName")
|
||||
interactionController.onFacePresenceChanged(present, faceIdentityId, recognizedName)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -200,13 +272,12 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
|
||||
// 设置 LLM 模式开关
|
||||
uiManager.setLLMSwitchListener { isChecked ->
|
||||
// 交互状态机固定路由:用户对话走云端,回忆走本地。此开关仅作为本地LLM可用性提示。
|
||||
useLocalLLM = isChecked
|
||||
Log.i(TAG_LLM, "LLM mode switched: useLocalLLM=$useLocalLLM")
|
||||
uiManager.showToast("LLM模式已切换到${if (isChecked) "本地" else "云端"}")
|
||||
// 重新初始化 LLM
|
||||
initLLM()
|
||||
uiManager.showToast("状态机路由已固定:对话云端,回忆本地")
|
||||
}
|
||||
// 默认不显示 LLM 开关,等模型下载完成后再显示
|
||||
uiManager.showLLMSwitch(false)
|
||||
|
||||
if (AppConfig.USE_HOLD_TO_SPEAK) {
|
||||
uiManager.setButtonsEnabled(recordEnabled = false)
|
||||
@@ -226,8 +297,9 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
vadManager = VadManager(this)
|
||||
vadManager.setCallback(createVadCallback())
|
||||
|
||||
// 初始化 LLM 管理器
|
||||
// 初始化本地 LLM(用于 memory 状态)
|
||||
initLLM()
|
||||
interactionController.start()
|
||||
|
||||
// 检查是否需要下载模型
|
||||
if (!FileHelper.isLocalLLMAvailable(this)) {
|
||||
@@ -259,8 +331,8 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
if (FileHelper.isLocalLLMAvailable(this)) {
|
||||
Log.i(AppConfig.TAG, "Local LLM is available, enabling local LLM switch")
|
||||
// 显示本地 LLM 开关,并同步状态
|
||||
uiManager.showLLMSwitch(true)
|
||||
uiManager.setLLMSwitchChecked(useLocalLLM)
|
||||
uiManager.showLLMSwitch(false)
|
||||
initLLM()
|
||||
}
|
||||
} else {
|
||||
Log.e(AppConfig.TAG, "Failed to download model files: $message")
|
||||
@@ -275,8 +347,7 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
// 模型已存在,直接初始化其他组件
|
||||
initializeOtherComponents()
|
||||
// 显示本地 LLM 开关,并同步状态
|
||||
uiManager.showLLMSwitch(true)
|
||||
uiManager.setLLMSwitchChecked(useLocalLLM)
|
||||
uiManager.showLLMSwitch(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +419,7 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
runOnUiThread {
|
||||
uiManager.appendToUi("\n\n[ASR] ${text}\n")
|
||||
}
|
||||
appendConversationLine("用户", text)
|
||||
currentTrace?.markRecordingDone()
|
||||
currentTrace?.markLlmResponseReceived()
|
||||
}
|
||||
@@ -361,17 +433,8 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
override fun isLlmInFlight(): Boolean = llmInFlight
|
||||
|
||||
override fun onLlmCalled(text: String) {
|
||||
llmInFlight = true
|
||||
Log.d(AppConfig.TAG, "Calling LLM with text: $text")
|
||||
if (useLocalLLM) {
|
||||
Log.i(TAG_LLM, "Routing to LOCAL LLM")
|
||||
// 使用本地 LLM 生成回复
|
||||
generateResponse(text)
|
||||
} else {
|
||||
Log.i(TAG_LLM, "Routing to CLOUD LLM")
|
||||
// 使用云端 LLM 生成回复
|
||||
cloudApiManager.callLLM(text)
|
||||
}
|
||||
Log.d(AppConfig.TAG, "Forward ASR text to interaction controller: $text")
|
||||
interactionController.onUserAsrText(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +453,7 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
override fun onLLMResponseReceived(response: String) {
|
||||
currentTrace?.markLlmDone()
|
||||
llmInFlight = false
|
||||
appendConversationLine("助手", response)
|
||||
|
||||
if (enableStreaming) {
|
||||
for (seg in segmenter.flush()) {
|
||||
@@ -411,6 +475,7 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
ttsController.enqueueSegment(filteredText)
|
||||
ttsController.enqueueEnd()
|
||||
}
|
||||
interactionController.onDialogueResponseFinished()
|
||||
}
|
||||
|
||||
override fun onLLMStreamingChunkReceived(chunk: String) {
|
||||
@@ -442,6 +507,7 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
override fun onError(errorMessage: String) {
|
||||
llmInFlight = false
|
||||
uiManager.showToast(errorMessage, Toast.LENGTH_LONG)
|
||||
interactionController.onDialogueResponseFinished()
|
||||
onStopClicked(userInitiated = false)
|
||||
}
|
||||
}
|
||||
@@ -479,6 +545,7 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
try { interactionController.stop() } catch (_: Throwable) {}
|
||||
stopCameraPreviewAndDetection()
|
||||
onStopClicked(userInitiated = false)
|
||||
ioScope.cancel()
|
||||
@@ -490,6 +557,7 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
try { cameraAnalyzerExecutor.shutdown() } catch (_: Throwable) {}
|
||||
try { ttsController.release() } catch (_: Throwable) {}
|
||||
try { llmManager?.destroy() } catch (_: Throwable) {}
|
||||
try { userMemoryStore.close() } catch (_: Throwable) {}
|
||||
try { uiManager.release() } catch (_: Throwable) {}
|
||||
try { audioProcessor.release() } catch (_: Throwable) {}
|
||||
}
|
||||
@@ -758,84 +826,157 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
Log.d(AppConfig.TAG, "processSamplesLoop stopped")
|
||||
}
|
||||
|
||||
private fun playInteractionMotion(motionName: String) {
|
||||
when (motionName) {
|
||||
"haru_g_m22.motion3.json" -> uiManager.setMood("高兴")
|
||||
"haru_g_m01.motion3.json", "haru_g_m17.motion3.json" -> uiManager.setMood("中性")
|
||||
"haru_g_m15.motion3.json" -> uiManager.setMood("关心")
|
||||
"haru_g_idle.motion3.json" -> uiManager.setMood("平和")
|
||||
else -> uiManager.setMood("中性")
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendConversationLine(role: String, text: String) {
|
||||
val line = "$role: ${text.trim()}"
|
||||
if (line.length <= 4) return
|
||||
recentConversationLines.add(line)
|
||||
if (recentConversationLines.size > 12) {
|
||||
recentConversationLines.removeAt(0)
|
||||
}
|
||||
recentConversationDirty = true
|
||||
}
|
||||
|
||||
private fun buildCloudPromptWithUserProfile(userText: String): String {
|
||||
val profile = userMemoryStore.getMemory(activeUserId) ?: return userText
|
||||
val profileParts = ArrayList<String>()
|
||||
profile.displayName?.takeIf { it.isNotBlank() }?.let { profileParts.add("姓名:$it") }
|
||||
profile.age?.takeIf { it.isNotBlank() }?.let { profileParts.add("年龄:$it") }
|
||||
profile.gender?.takeIf { it.isNotBlank() }?.let { profileParts.add("性别:$it") }
|
||||
profile.hobbies?.takeIf { it.isNotBlank() }?.let { profileParts.add("爱好:$it") }
|
||||
profile.profileSummary?.takeIf { it.isNotBlank() }?.let { profileParts.add("画像:$it") }
|
||||
if (profileParts.isEmpty()) return userText
|
||||
return buildString {
|
||||
append("[用户画像]\n")
|
||||
append(profileParts.joinToString(";"))
|
||||
append("\n[/用户画像]\n")
|
||||
append(userText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun analyzeUserProfileInIdleIfNeeded() {
|
||||
if (!recentConversationDirty || !activeUserId.startsWith("face_")) return
|
||||
if (recentConversationLines.isEmpty()) return
|
||||
val dialogue = recentConversationLines.joinToString("\n")
|
||||
requestLocalProfileExtraction(dialogue) { raw ->
|
||||
try {
|
||||
val json = parseFirstJsonObject(raw)
|
||||
val name = json.optString("name", "").trim().ifBlank { null }
|
||||
val age = json.optString("age", "").trim().ifBlank { null }
|
||||
val gender = json.optString("gender", "").trim().ifBlank { null }
|
||||
val hobbies = json.optString("hobbies", "").trim().ifBlank { null }
|
||||
val summary = json.optString("summary", "").trim().ifBlank { null }
|
||||
if (name != null) {
|
||||
userMemoryStore.updateDisplayName(activeUserId, name)
|
||||
}
|
||||
userMemoryStore.updateProfile(activeUserId, age, gender, hobbies, summary)
|
||||
recentConversationDirty = false
|
||||
runOnUiThread {
|
||||
uiManager.appendToUi("\n[Memory] 已更新用户画像: $activeUserId\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG_LLM, "Profile parse failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLocalProfileExtraction(dialogue: String, onResult: (String) -> Unit) {
|
||||
try {
|
||||
val local = llmManager
|
||||
if (local == null) {
|
||||
onResult("{}")
|
||||
return
|
||||
}
|
||||
localThoughtSilentMode = true
|
||||
pendingLocalProfileCallback = onResult
|
||||
Log.i(TAG_LLM, "Routing profile extraction to LOCAL")
|
||||
local.generateResponseWithSystem(
|
||||
"你是信息抽取器。仅输出JSON对象,不要其他文字。字段为name,age,gender,hobbies,summary。",
|
||||
"请从以下对话提取用户信息,未知填空字符串:\n$dialogue"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingLocalProfileCallback = null
|
||||
localThoughtSilentMode = false
|
||||
Log.e(TAG_LLM, "requestLocalProfileExtraction failed: ${e.message}", e)
|
||||
onResult("{}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFirstJsonObject(text: String): JSONObject {
|
||||
val raw = text.trim()
|
||||
val start = raw.indexOf('{')
|
||||
val end = raw.lastIndexOf('}')
|
||||
if (start >= 0 && end > start) {
|
||||
return JSONObject(raw.substring(start, end + 1))
|
||||
}
|
||||
return JSONObject(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 LLM 管理器
|
||||
* 初始化本地 LLM(仅用于回忆状态)
|
||||
*/
|
||||
private fun initLLM() {
|
||||
try {
|
||||
Log.i(TAG_LLM, "initLLM called, useLocalLLM=$useLocalLLM")
|
||||
Log.i(TAG_LLM, "initLLM called for memory-local model")
|
||||
llmManager?.destroy()
|
||||
llmManager = null
|
||||
if (useLocalLLM) {
|
||||
// // 本地 LLM 初始化前,先暂停/释放重模块
|
||||
// Log.i(AppConfig.TAG, "Pausing camera and releasing face detection before LLM initialization")
|
||||
// stopCameraPreviewAndDetection()
|
||||
// try {
|
||||
// faceDetectionPipeline.release()
|
||||
// Log.i(AppConfig.TAG, "Face detection pipeline released")
|
||||
// } catch (e: Exception) {
|
||||
// Log.w(AppConfig.TAG, "Failed to release face detection pipeline: ${e.message}")
|
||||
// }
|
||||
|
||||
// // 释放 VAD 管理器
|
||||
// try {
|
||||
// vadManager.release()
|
||||
// Log.i(AppConfig.TAG, "VAD manager released")
|
||||
// } catch (e: Exception) {
|
||||
// Log.w(AppConfig.TAG, "Failed to release VAD manager: ${e.message}")
|
||||
// }
|
||||
|
||||
val modelPath = FileHelper.getLLMModelPath(applicationContext)
|
||||
if (!File(modelPath).exists()) {
|
||||
throw IllegalStateException("RKLLM model file missing: $modelPath")
|
||||
}
|
||||
Log.i(AppConfig.TAG, "Initializing LLM with model path: $modelPath")
|
||||
Log.i(AppConfig.TAG, "Initializing local memory LLM with model path: $modelPath")
|
||||
val localLlmResponseBuffer = StringBuilder()
|
||||
llmManager = LLMManager(modelPath, object : LLMManagerCallback {
|
||||
override fun onThinking(msg: String, finished: Boolean) {
|
||||
// 处理思考过程
|
||||
Log.d(TAG_LLM, "LOCAL onThinking finished=$finished msg=${msg.take(60)}")
|
||||
runOnUiThread {
|
||||
if (!finished && enableStreaming) {
|
||||
uiManager.appendToUi("\n[LLM] 思考中: $msg\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResult(msg: String, finished: Boolean) {
|
||||
// 处理生成结果
|
||||
Log.d(TAG_LLM, "LOCAL onResult finished=$finished len=${msg.length}")
|
||||
runOnUiThread {
|
||||
if (!finished) {
|
||||
localLlmResponseBuffer.append(msg)
|
||||
if (enableStreaming) {
|
||||
if (enableStreaming && !localThoughtSilentMode) {
|
||||
uiManager.appendToUi(msg)
|
||||
}
|
||||
} else {
|
||||
return@runOnUiThread
|
||||
}
|
||||
val finalText = localLlmResponseBuffer.toString().trim()
|
||||
localLlmResponseBuffer.setLength(0)
|
||||
if (!enableStreaming && finalText.isNotEmpty()) {
|
||||
uiManager.appendToUi("$finalText\n")
|
||||
val profileCallback = pendingLocalProfileCallback
|
||||
pendingLocalProfileCallback = null
|
||||
if (profileCallback != null) {
|
||||
profileCallback(finalText)
|
||||
localThoughtSilentMode = false
|
||||
return@runOnUiThread
|
||||
}
|
||||
uiManager.appendToUi("\n\n[LLM] 生成完成\n")
|
||||
llmInFlight = false
|
||||
if (finalText.isNotEmpty()) {
|
||||
val callback = pendingLocalThoughtCallback
|
||||
pendingLocalThoughtCallback = null
|
||||
if (callback != null) {
|
||||
callback(finalText)
|
||||
localThoughtSilentMode = false
|
||||
return@runOnUiThread
|
||||
}
|
||||
if (!localThoughtSilentMode && finalText.isNotEmpty()) {
|
||||
uiManager.appendToUi("$finalText\n")
|
||||
ttsController.enqueueSegment(finalText)
|
||||
ttsController.enqueueEnd()
|
||||
} else {
|
||||
Log.w(TAG_LLM, "LOCAL final text is empty, skip TTS enqueue")
|
||||
}
|
||||
}
|
||||
localThoughtSilentMode = false
|
||||
}
|
||||
}
|
||||
})
|
||||
Log.i(AppConfig.TAG, "LLM initialized successfully")
|
||||
Log.i(TAG_LLM, "LOCAL LLM initialized")
|
||||
} else {
|
||||
// 使用云端 LLM,不需要初始化本地 LLM
|
||||
Log.i(AppConfig.TAG, "Using cloud LLM, skipping local LLM initialization")
|
||||
Log.i(TAG_LLM, "CLOUD mode active")
|
||||
}
|
||||
Log.i(TAG_LLM, "LOCAL memory LLM initialized")
|
||||
useLocalLLM = true
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to initialize LLM: ${e.message}", e)
|
||||
Log.e(TAG_LLM, "LOCAL init failed: ${e.message}", e)
|
||||
@@ -849,35 +990,27 @@ class Live2DChatActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 LLM 生成回复
|
||||
* 回忆状态调用本地 LLM,仅用于 memory/what-are-you-thinking
|
||||
*/
|
||||
private fun generateResponse(userInput: String) {
|
||||
private fun requestLocalThought(prompt: String, onResult: (String) -> Unit) {
|
||||
try {
|
||||
if (useLocalLLM) {
|
||||
val systemPrompt = "你是一个友好的数字人助手,回答要简洁明了。"
|
||||
Log.d(AppConfig.TAG, "Generating response for: $userInput")
|
||||
val local = llmManager
|
||||
if (local == null) {
|
||||
Log.e(TAG_LLM, "LOCAL LLM manager is null, fallback to CLOUD")
|
||||
cloudApiManager.callLLM(userInput)
|
||||
onResult("我在想,下次见面可以聊聊今天的新鲜事。")
|
||||
return
|
||||
}
|
||||
Log.i(TAG_LLM, "LOCAL generateResponseWithSystem")
|
||||
local.generateResponseWithSystem(systemPrompt, userInput)
|
||||
} else {
|
||||
// 使用云端 LLM
|
||||
Log.d(AppConfig.TAG, "Using cloud LLM for response: $userInput")
|
||||
Log.i(TAG_LLM, "CLOUD callLLM")
|
||||
// 调用云端 LLM
|
||||
cloudApiManager.callLLM(userInput)
|
||||
}
|
||||
localThoughtSilentMode = true
|
||||
pendingLocalThoughtCallback = onResult
|
||||
Log.i(TAG_LLM, "Routing memory thought to LOCAL")
|
||||
local.generateResponseWithSystem(
|
||||
"你是数字人内心独白模块,输出一句简短温和的想法。",
|
||||
prompt
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to generate response: ${e.message}", e)
|
||||
Log.e(TAG_LLM, "generateResponse failed: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
uiManager.appendToUi("\n\n[Error] LLM 生成失败: ${e.message}\n")
|
||||
llmInFlight = false
|
||||
}
|
||||
Log.e(TAG_LLM, "requestLocalThought failed: ${e.message}", e)
|
||||
pendingLocalThoughtCallback = null
|
||||
localThoughtSilentMode = false
|
||||
onResult("我在想,下次见面可以聊聊今天的新鲜事。")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,13 @@ public class CloudApiManager {
|
||||
mConversationHistory = new JSONArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加助手消息到对话历史
|
||||
*/
|
||||
public void addAssistantMessage(String content) {
|
||||
addMessageToHistory("assistant", content);
|
||||
}
|
||||
|
||||
public void callTTS(String text, File outputFile) {
|
||||
if (mListener != null) {
|
||||
mMainHandler.post(() -> {
|
||||
|
||||
@@ -31,7 +31,7 @@ data class FaceDetectionResult(
|
||||
class FaceDetectionPipeline(
|
||||
private val context: Context,
|
||||
private val onResult: (FaceDetectionResult) -> Unit,
|
||||
private val onGreeting: (String) -> Unit,
|
||||
private val onPresenceChanged: (present: Boolean, faceIdentityId: String?, recognizedName: String?) -> Unit,
|
||||
) {
|
||||
private val engine = RetinaFaceEngineRKNN()
|
||||
private val recognizer = FaceRecognizer(context)
|
||||
@@ -41,8 +41,9 @@ class FaceDetectionPipeline(
|
||||
private var trackFace: FaceBox? = null
|
||||
private var trackId: Long = 0
|
||||
private var trackStableSinceMs: Long = 0
|
||||
private var greetedTrackId: Long = -1
|
||||
private var lastGreetMs: Long = 0
|
||||
private var analyzedTrackId: Long = -1
|
||||
private var lastFaceIdentityId: String? = null
|
||||
private var lastRecognizedName: String? = null
|
||||
|
||||
fun initialize(): Boolean {
|
||||
val detectorOk = engine.initialize(context)
|
||||
@@ -98,8 +99,9 @@ class FaceDetectionPipeline(
|
||||
// )
|
||||
// }
|
||||
|
||||
maybeRecognizeAndGreet(bitmap, filteredFaces)
|
||||
maybeRecognize(bitmap, filteredFaces)
|
||||
withContext(Dispatchers.Main) {
|
||||
onPresenceChanged(filteredFaces.isNotEmpty(), lastFaceIdentityId, lastRecognizedName)
|
||||
onResult(FaceDetectionResult(width, height, filteredFaces))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
@@ -111,11 +113,14 @@ class FaceDetectionPipeline(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun maybeRecognizeAndGreet(bitmap: Bitmap, faces: List<FaceBox>) {
|
||||
private fun maybeRecognize(bitmap: Bitmap, faces: List<FaceBox>) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (faces.isEmpty()) {
|
||||
trackFace = null
|
||||
trackStableSinceMs = 0
|
||||
analyzedTrackId = -1
|
||||
lastFaceIdentityId = null
|
||||
lastRecognizedName = null
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,62 +128,30 @@ class FaceDetectionPipeline(
|
||||
val prev = trackFace
|
||||
if (prev == null || iou(prev, primary) < AppConfig.Face.TRACK_IOU_THRESHOLD) {
|
||||
trackId += 1
|
||||
greetedTrackId = -1
|
||||
trackStableSinceMs = now
|
||||
analyzedTrackId = -1
|
||||
lastFaceIdentityId = null
|
||||
lastRecognizedName = null
|
||||
}
|
||||
trackFace = primary
|
||||
|
||||
val stableMs = now - trackStableSinceMs
|
||||
val frontal = isFrontal(primary, bitmap.width, bitmap.height)
|
||||
val coolingDown = (now - lastGreetMs) < AppConfig.FaceRecognition.GREETING_COOLDOWN_MS
|
||||
if (stableMs < AppConfig.Face.STABLE_MS || !frontal || greetedTrackId == trackId || coolingDown) {
|
||||
if (stableMs < AppConfig.Face.STABLE_MS || !frontal) {
|
||||
return
|
||||
}
|
||||
if (analyzedTrackId == trackId) {
|
||||
return
|
||||
}
|
||||
|
||||
val match = recognizer.identify(bitmap, primary)
|
||||
|
||||
Log.d(AppConfig.TAG, "[Face] Recognition result: matchedName=${match.matchedName}, similarity=${match.similarity}")
|
||||
|
||||
// 检查是否需要保存新人脸
|
||||
if (match.matchedName.isNullOrBlank()) {
|
||||
Log.d(AppConfig.TAG, "[Face] No match found, attempting to add new face")
|
||||
// 提取人脸特征
|
||||
val embedding = extractEmbedding(bitmap, primary)
|
||||
Log.d(AppConfig.TAG, "[Face] Extracted embedding size: ${embedding.size}")
|
||||
if (embedding.isNotEmpty()) {
|
||||
// 尝试添加新人脸
|
||||
val added = recognizer.addNewFace(embedding)
|
||||
Log.d(AppConfig.TAG, "[Face] Add new face result: $added")
|
||||
if (added) {
|
||||
Log.i(AppConfig.TAG, "[Face] New face added to database")
|
||||
} else {
|
||||
Log.i(AppConfig.TAG, "[Face] Face already exists in database (similar face found)")
|
||||
}
|
||||
} else {
|
||||
Log.w(AppConfig.TAG, "[Face] Failed to extract embedding")
|
||||
}
|
||||
} else {
|
||||
Log.d(AppConfig.TAG, "[Face] Matched existing face: ${match.matchedName}")
|
||||
}
|
||||
|
||||
val greeting = if (!match.matchedName.isNullOrBlank()) {
|
||||
"你好,${match.matchedName}!"
|
||||
} else {
|
||||
"你好,很高兴见到你。"
|
||||
}
|
||||
greetedTrackId = trackId
|
||||
lastGreetMs = now
|
||||
val match = recognizer.resolveIdentity(bitmap, primary)
|
||||
analyzedTrackId = trackId
|
||||
lastFaceIdentityId = match.matchedId?.let { "face_$it" }
|
||||
lastRecognizedName = match.matchedName
|
||||
Log.i(
|
||||
AppConfig.TAG,
|
||||
"[Face] greeting track=$trackId stable=${stableMs}ms frontal=$frontal matched=${match.matchedName} score=${match.similarity}"
|
||||
"[Face] stable track=$trackId faceId=${lastFaceIdentityId} matched=${match.matchedName} score=${match.similarity}"
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
onGreeting(greeting)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractEmbedding(bitmap: Bitmap, face: FaceBox): FloatArray {
|
||||
return recognizer.extractEmbedding(bitmap, face)
|
||||
}
|
||||
|
||||
private fun isFrontal(face: FaceBox, frameW: Int, frameH: Int): Boolean {
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.nio.ByteOrder
|
||||
|
||||
data class FaceProfile(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val name: String?,
|
||||
val embedding: FloatArray,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS face_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
embedding BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
@@ -55,9 +55,8 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
|
||||
return list
|
||||
}
|
||||
|
||||
fun upsertProfile(name: String, embedding: FloatArray) {
|
||||
// 确保名字不为null,使用空字符串作为默认值
|
||||
val safeName = name.takeIf { it.isNotBlank() } ?: ""
|
||||
fun insertProfile(name: String?, embedding: FloatArray): Long {
|
||||
val safeName = name?.takeIf { it.isNotBlank() }
|
||||
val values = ContentValues().apply {
|
||||
put("name", safeName)
|
||||
put("embedding", floatArrayToBlob(embedding))
|
||||
@@ -67,9 +66,10 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
|
||||
"face_profiles",
|
||||
null,
|
||||
values,
|
||||
SQLiteDatabase.CONFLICT_REPLACE
|
||||
SQLiteDatabase.CONFLICT_NONE
|
||||
)
|
||||
Log.i(AppConfig.TAG, "[FaceFeatureStore] upsertProfile name='$safeName' rowId=$rowId dim=${embedding.size}")
|
||||
Log.i(AppConfig.TAG, "[FaceFeatureStore] insertProfile name='$safeName' rowId=$rowId dim=${embedding.size}")
|
||||
return rowId
|
||||
}
|
||||
|
||||
private fun floatArrayToBlob(values: FloatArray): ByteArray {
|
||||
@@ -88,6 +88,6 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
|
||||
|
||||
companion object {
|
||||
private const val DB_NAME = "face_feature.db"
|
||||
private const val DB_VERSION = 1
|
||||
private const val DB_VERSION = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.digitalperson.engine.ArcFaceEngineRKNN
|
||||
import kotlin.math.sqrt
|
||||
|
||||
data class FaceRecognitionResult(
|
||||
val matchedId: Long?,
|
||||
val matchedName: String?,
|
||||
val similarity: Float,
|
||||
val embeddingDim: Int,
|
||||
@@ -40,10 +41,11 @@ class FaceRecognizer(context: Context) {
|
||||
}
|
||||
|
||||
fun identify(bitmap: Bitmap, face: FaceBox): FaceRecognitionResult {
|
||||
if (!initialized) return FaceRecognitionResult(null, 0f, 0)
|
||||
if (!initialized) return FaceRecognitionResult(null, null, 0f, 0)
|
||||
val embedding = extractEmbedding(bitmap, face)
|
||||
if (embedding.isEmpty()) return FaceRecognitionResult(null, 0f, 0)
|
||||
if (embedding.isEmpty()) return FaceRecognitionResult(null, null, 0f, 0)
|
||||
|
||||
var bestId: Long? = null
|
||||
var bestName: String? = null
|
||||
var bestScore = -1f
|
||||
for (p in cache) {
|
||||
@@ -51,13 +53,14 @@ class FaceRecognizer(context: Context) {
|
||||
val score = cosineSimilarity(embedding, p.embedding)
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestId = p.id
|
||||
bestName = p.name
|
||||
}
|
||||
}
|
||||
if (bestScore >= AppConfig.FaceRecognition.SIMILARITY_THRESHOLD) {
|
||||
return FaceRecognitionResult(bestName, bestScore, embedding.size)
|
||||
return FaceRecognitionResult(bestId, bestName, bestScore, embedding.size)
|
||||
}
|
||||
return FaceRecognitionResult(null, bestScore, embedding.size)
|
||||
return FaceRecognitionResult(null, null, bestScore, embedding.size)
|
||||
}
|
||||
|
||||
fun extractEmbedding(bitmap: Bitmap, face: FaceBox): FloatArray {
|
||||
@@ -65,38 +68,28 @@ class FaceRecognizer(context: Context) {
|
||||
return engine.extractEmbedding(bitmap, face.left, face.top, face.right, face.bottom)
|
||||
}
|
||||
|
||||
fun addOrUpdateProfile(name: String?, embedding: FloatArray) {
|
||||
private fun addProfile(name: String?, embedding: FloatArray): Long {
|
||||
val normalized = normalize(embedding)
|
||||
store.upsertProfile(name ?: "", normalized)
|
||||
// 移除旧的记录(如果存在)
|
||||
if (name != null) {
|
||||
cache.removeAll { it.name == name }
|
||||
val rowId = store.insertProfile(name, normalized)
|
||||
if (rowId > 0) {
|
||||
cache.add(FaceProfile(id = rowId, name = name, embedding = normalized))
|
||||
}
|
||||
cache.add(FaceProfile(id = -1L, name = name ?: "", embedding = normalized))
|
||||
return rowId
|
||||
}
|
||||
|
||||
fun addNewFace(embedding: FloatArray): Boolean {
|
||||
Log.d(AppConfig.TAG, "[FaceRecognizer] addNewFace: embedding size=${embedding.size}, cache size=${cache.size}")
|
||||
|
||||
// 检查是否已经存在相似的人脸
|
||||
for (p in cache) {
|
||||
if (p.embedding.size != embedding.size) {
|
||||
Log.d(AppConfig.TAG, "[FaceRecognizer] Skipping profile with different embedding size: ${p.embedding.size}")
|
||||
continue
|
||||
}
|
||||
val score = cosineSimilarity(embedding, p.embedding)
|
||||
Log.d(AppConfig.TAG, "[FaceRecognizer] Comparing with profile '${p.name}': similarity=$score, threshold=${AppConfig.FaceRecognition.SIMILARITY_THRESHOLD}")
|
||||
if (score >= AppConfig.FaceRecognition.SIMILARITY_THRESHOLD) {
|
||||
// 已经存在相似的人脸,不需要添加
|
||||
Log.i(AppConfig.TAG, "[FaceRecognizer] Similar face found: ${p.name} with similarity=$score, not adding new face")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新人脸,名字为null
|
||||
Log.i(AppConfig.TAG, "[FaceRecognizer] No similar face found, adding new face")
|
||||
addOrUpdateProfile(null, embedding)
|
||||
return true
|
||||
fun resolveIdentity(bitmap: Bitmap, face: FaceBox): FaceRecognitionResult {
|
||||
val match = identify(bitmap, face)
|
||||
if (match.matchedId != null) return match
|
||||
val embedding = extractEmbedding(bitmap, face)
|
||||
if (embedding.isEmpty()) return match
|
||||
val newId = addProfile(name = null, embedding = embedding)
|
||||
if (newId <= 0L) return match
|
||||
return FaceRecognitionResult(
|
||||
matchedId = newId,
|
||||
matchedName = null,
|
||||
similarity = match.similarity,
|
||||
embeddingDim = embedding.size
|
||||
)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.digitalperson.interaction
|
||||
|
||||
import android.util.Log
|
||||
import com.digitalperson.config.AppConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class InteractionState {
|
||||
IDLE,
|
||||
MEMORY,
|
||||
GREETING,
|
||||
WAITING_REPLY,
|
||||
DIALOGUE,
|
||||
PROACTIVE,
|
||||
FAREWELL,
|
||||
}
|
||||
|
||||
enum class LlmRoute {
|
||||
CLOUD,
|
||||
LOCAL,
|
||||
}
|
||||
|
||||
interface InteractionActionHandler {
|
||||
fun onStateChanged(state: InteractionState)
|
||||
fun playMotion(motionName: String)
|
||||
fun appendText(text: String)
|
||||
fun speak(text: String)
|
||||
fun requestCloudReply(userText: String)
|
||||
fun requestLocalThought(prompt: String, onResult: (String) -> Unit)
|
||||
fun onRememberUser(faceIdentityId: String, name: String?)
|
||||
fun saveThought(thought: String)
|
||||
fun loadLatestThought(): String?
|
||||
fun addToChatHistory(role: String, content: String)
|
||||
fun addAssistantMessageToCloudHistory(content: String)
|
||||
}
|
||||
|
||||
class DigitalHumanInteractionController(
|
||||
private val scope: CoroutineScope,
|
||||
private val handler: InteractionActionHandler,
|
||||
) {
|
||||
private val TAG: String = "DigitalHumanInteraction"
|
||||
private var state: InteractionState = InteractionState.IDLE
|
||||
private var facePresent: Boolean = false
|
||||
private var recognizedName: String? = null
|
||||
private var proactiveRound = 0
|
||||
private var hasPendingUserReply = false
|
||||
|
||||
private var faceStableJob: Job? = null
|
||||
private var waitReplyJob: Job? = null
|
||||
private var proactiveJob: Job? = null
|
||||
private var memoryJob: Job? = null
|
||||
private var farewellJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
transitionTo(InteractionState.IDLE)
|
||||
scheduleMemoryMode()
|
||||
}
|
||||
|
||||
fun onFacePresenceChanged(present: Boolean, faceIdentityId: String?, recognized: String?) {
|
||||
Log.d(TAG, "onFacePresenceChanged: present=$present, faceIdentityId=$faceIdentityId, recognized=$recognized, state=$state")
|
||||
|
||||
facePresent = present
|
||||
if (!faceIdentityId.isNullOrBlank()) {
|
||||
handler.onRememberUser(faceIdentityId, recognized)
|
||||
}
|
||||
if (!recognized.isNullOrBlank()) {
|
||||
recognizedName = recognized
|
||||
}
|
||||
|
||||
// 统一延迟处理
|
||||
faceStableJob?.cancel()
|
||||
faceStableJob = scope.launch {
|
||||
delay(AppConfig.Face.STABLE_MS)
|
||||
|
||||
if (present) {
|
||||
// 人脸出现后的处理
|
||||
if (facePresent && (state == InteractionState.IDLE || state == InteractionState.MEMORY)) {
|
||||
enterGreeting()
|
||||
} else if (state == InteractionState.FAREWELL) {
|
||||
enterGreeting()
|
||||
}
|
||||
} else {
|
||||
// 人脸消失后的处理
|
||||
if (state != InteractionState.IDLE && state != InteractionState.MEMORY && state != InteractionState.FAREWELL) {
|
||||
enterFarewell()
|
||||
} else {
|
||||
scheduleMemoryMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onUserAsrText(text: String) {
|
||||
val userText = text.trim()
|
||||
if (userText.isBlank()) return
|
||||
|
||||
if (userText.contains("你在想什么")) {
|
||||
val thought = handler.loadLatestThought()
|
||||
if (!thought.isNullOrBlank()) {
|
||||
handler.speak("我刚才在想:$thought")
|
||||
handler.appendText("\n[回忆] $thought\n")
|
||||
transitionTo(InteractionState.DIALOGUE)
|
||||
handler.playMotion("haru_g_m15.motion3.json")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (userText.contains("再见")) {
|
||||
enterFarewell()
|
||||
return
|
||||
}
|
||||
|
||||
hasPendingUserReply = true
|
||||
when (state) {
|
||||
InteractionState.WAITING_REPLY, InteractionState.PROACTIVE, InteractionState.GREETING, InteractionState.DIALOGUE -> {
|
||||
transitionTo(InteractionState.DIALOGUE)
|
||||
requestDialogueReply(userText)
|
||||
}
|
||||
InteractionState.MEMORY, InteractionState.IDLE -> {
|
||||
transitionTo(InteractionState.DIALOGUE)
|
||||
requestDialogueReply(userText)
|
||||
}
|
||||
InteractionState.FAREWELL -> {
|
||||
enterGreeting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDialogueResponseFinished() {
|
||||
if (!facePresent) {
|
||||
enterFarewell()
|
||||
return
|
||||
}
|
||||
transitionTo(InteractionState.WAITING_REPLY)
|
||||
handler.playMotion("haru_g_m17.motion3.json")
|
||||
scheduleWaitingReplyTimeout()
|
||||
}
|
||||
|
||||
private fun enterGreeting() {
|
||||
transitionTo(InteractionState.GREETING)
|
||||
val greet = if (!recognizedName.isNullOrBlank()) {
|
||||
handler.playMotion("haru_g_m22.motion3.json")
|
||||
"你好,$recognizedName,很高兴再次见到你。"
|
||||
} else {
|
||||
handler.playMotion("haru_g_m01.motion3.json")
|
||||
"你好,很高兴见到你。"
|
||||
}
|
||||
handler.speak(greet)
|
||||
handler.appendText("\n[问候] $greet\n")
|
||||
transitionTo(InteractionState.WAITING_REPLY)
|
||||
handler.playMotion("haru_g_m17.motion3.json")
|
||||
scheduleWaitingReplyTimeout()
|
||||
}
|
||||
|
||||
private fun scheduleWaitingReplyTimeout() {
|
||||
waitReplyJob?.cancel()
|
||||
waitReplyJob = scope.launch {
|
||||
hasPendingUserReply = false
|
||||
delay(10_000)
|
||||
if (state != InteractionState.WAITING_REPLY || hasPendingUserReply) return@launch
|
||||
if (facePresent) {
|
||||
enterProactive()
|
||||
} else {
|
||||
enterFarewell()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enterProactive() {
|
||||
transitionTo(InteractionState.PROACTIVE)
|
||||
proactiveRound = 0
|
||||
askProactiveTopic()
|
||||
}
|
||||
|
||||
private fun askProactiveTopic() {
|
||||
proactiveJob?.cancel()
|
||||
val topics = listOf(
|
||||
"我出一道数学题考考你吧?1+6等于多少?",
|
||||
"我们上完厕所应该干什么呀?",
|
||||
"你喜欢什么颜色呀?",
|
||||
)
|
||||
val idx = proactiveRound.coerceIn(0, topics.lastIndex)
|
||||
val topic = topics[idx]
|
||||
handler.playMotion(if (proactiveRound == 0) "haru_g_m15.motion3.json" else "haru_g_m22.motion3.json")
|
||||
handler.speak(topic)
|
||||
handler.appendText("\n[主动引导] $topic\n")
|
||||
// 将引导内容添加到对话历史中
|
||||
handler.addToChatHistory("助手", topic)
|
||||
// 将引导内容添加到云对话历史中
|
||||
handler.addAssistantMessageToCloudHistory(topic)
|
||||
|
||||
proactiveJob = scope.launch {
|
||||
hasPendingUserReply = false
|
||||
delay(20_000)
|
||||
if (state != InteractionState.PROACTIVE || hasPendingUserReply) return@launch
|
||||
if (!facePresent) {
|
||||
enterFarewell()
|
||||
return@launch
|
||||
}
|
||||
proactiveRound += 1
|
||||
if (proactiveRound < 3) {
|
||||
askProactiveTopic()
|
||||
} else {
|
||||
transitionTo(InteractionState.WAITING_REPLY)
|
||||
handler.playMotion("haru_g_m17.motion3.json")
|
||||
scheduleWaitingReplyTimeout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enterFarewell() {
|
||||
transitionTo(InteractionState.FAREWELL)
|
||||
handler.playMotion("haru_g_idle.motion3.json")
|
||||
handler.speak("再见咯")
|
||||
farewellJob?.cancel()
|
||||
farewellJob = scope.launch {
|
||||
delay(3_000)
|
||||
transitionTo(InteractionState.IDLE)
|
||||
handler.playMotion("haru_g_idle.motion3.json")
|
||||
scheduleMemoryMode()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleMemoryMode() {
|
||||
memoryJob?.cancel()
|
||||
if (facePresent) return
|
||||
memoryJob = scope.launch {
|
||||
delay(30_000)
|
||||
if (facePresent || state != InteractionState.IDLE) return@launch
|
||||
transitionTo(InteractionState.MEMORY)
|
||||
handler.playMotion("haru_g_m15.motion3.json")
|
||||
val prompt = "请基于最近互动写一句简短内心想法,口吻温和自然。"
|
||||
handler.requestLocalThought(prompt) { thought ->
|
||||
val finalThought = thought.trim().ifBlank { "我在想,下次见面要聊点有趣的新话题。" }
|
||||
handler.saveThought(finalThought)
|
||||
handler.appendText("\n[回忆] $finalThought\n")
|
||||
if (!facePresent && state == InteractionState.MEMORY) {
|
||||
transitionTo(InteractionState.IDLE)
|
||||
handler.playMotion("haru_g_idle.motion3.json")
|
||||
scheduleMemoryMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestDialogueReply(userText: String) {
|
||||
waitReplyJob?.cancel()
|
||||
proactiveJob?.cancel()
|
||||
// 按产品要求:用户对话统一走云端LLM
|
||||
handler.requestCloudReply(userText)
|
||||
}
|
||||
|
||||
private fun transitionTo(newState: InteractionState) {
|
||||
if (state == newState) return
|
||||
state = newState
|
||||
handler.onStateChanged(newState)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
faceStableJob?.cancel()
|
||||
waitReplyJob?.cancel()
|
||||
proactiveJob?.cancel()
|
||||
memoryJob?.cancel()
|
||||
farewellJob?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.digitalperson.interaction
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
|
||||
data class UserMemory(
|
||||
val userId: String,
|
||||
val displayName: String?,
|
||||
val lastSeenAt: Long,
|
||||
val age: String?,
|
||||
val gender: String?,
|
||||
val hobbies: String?,
|
||||
val preferences: String?,
|
||||
val lastTopics: String?,
|
||||
val lastThought: String?,
|
||||
val profileSummary: String?,
|
||||
)
|
||||
|
||||
class UserMemoryStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
||||
private val memoryCache = LinkedHashMap<String, UserMemory>()
|
||||
@Volatile private var latestThoughtCache: String? = null
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_memory (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
age TEXT,
|
||||
gender TEXT,
|
||||
hobbies TEXT,
|
||||
preferences TEXT,
|
||||
last_topics TEXT,
|
||||
last_thought TEXT,
|
||||
profile_summary TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("DROP TABLE IF EXISTS user_memory")
|
||||
onCreate(db)
|
||||
}
|
||||
|
||||
fun upsertUserSeen(userId: String, displayName: String?) {
|
||||
val existing = memoryCache[userId] ?: getMemory(userId)
|
||||
val now = System.currentTimeMillis()
|
||||
val mergedName = displayName?.takeIf { it.isNotBlank() } ?: existing?.displayName
|
||||
val values = ContentValues().apply {
|
||||
put("user_id", userId)
|
||||
put("display_name", mergedName)
|
||||
put("last_seen_at", now)
|
||||
put("age", existing?.age)
|
||||
put("gender", existing?.gender)
|
||||
put("hobbies", existing?.hobbies)
|
||||
put("preferences", existing?.preferences)
|
||||
put("last_topics", existing?.lastTopics)
|
||||
put("last_thought", existing?.lastThought)
|
||||
put("profile_summary", existing?.profileSummary)
|
||||
}
|
||||
writableDatabase.insertWithOnConflict("user_memory", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
memoryCache[userId] = UserMemory(
|
||||
userId = userId,
|
||||
displayName = mergedName,
|
||||
lastSeenAt = now,
|
||||
age = existing?.age,
|
||||
gender = existing?.gender,
|
||||
hobbies = existing?.hobbies,
|
||||
preferences = existing?.preferences,
|
||||
lastTopics = existing?.lastTopics,
|
||||
lastThought = existing?.lastThought,
|
||||
profileSummary = existing?.profileSummary,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateDisplayName(userId: String, displayName: String?) {
|
||||
if (displayName.isNullOrBlank()) return
|
||||
upsertUserSeen(userId, displayName)
|
||||
}
|
||||
|
||||
fun updateThought(userId: String, thought: String) {
|
||||
upsertUserSeen(userId, null)
|
||||
val now = System.currentTimeMillis()
|
||||
val values = ContentValues().apply {
|
||||
put("last_thought", thought)
|
||||
put("last_seen_at", now)
|
||||
}
|
||||
writableDatabase.update("user_memory", values, "user_id=?", arrayOf(userId))
|
||||
latestThoughtCache = thought
|
||||
val cached = memoryCache[userId]
|
||||
memoryCache[userId] = UserMemory(
|
||||
userId = userId,
|
||||
displayName = cached?.displayName,
|
||||
lastSeenAt = now,
|
||||
age = cached?.age,
|
||||
gender = cached?.gender,
|
||||
hobbies = cached?.hobbies,
|
||||
preferences = cached?.preferences,
|
||||
lastTopics = cached?.lastTopics,
|
||||
lastThought = thought,
|
||||
profileSummary = cached?.profileSummary,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateProfile(userId: String, age: String?, gender: String?, hobbies: String?, summary: String?) {
|
||||
upsertUserSeen(userId, null)
|
||||
val now = System.currentTimeMillis()
|
||||
val values = ContentValues().apply {
|
||||
if (age != null) put("age", age)
|
||||
if (gender != null) put("gender", gender)
|
||||
if (hobbies != null) put("hobbies", hobbies)
|
||||
if (summary != null) put("profile_summary", summary)
|
||||
put("last_seen_at", now)
|
||||
}
|
||||
writableDatabase.update("user_memory", values, "user_id=?", arrayOf(userId))
|
||||
val cached = memoryCache[userId]
|
||||
memoryCache[userId] = UserMemory(
|
||||
userId = userId,
|
||||
displayName = cached?.displayName,
|
||||
lastSeenAt = now,
|
||||
age = age ?: cached?.age,
|
||||
gender = gender ?: cached?.gender,
|
||||
hobbies = hobbies ?: cached?.hobbies,
|
||||
preferences = cached?.preferences,
|
||||
lastTopics = cached?.lastTopics,
|
||||
lastThought = cached?.lastThought,
|
||||
profileSummary = summary ?: cached?.profileSummary,
|
||||
)
|
||||
}
|
||||
|
||||
fun getMemory(userId: String): UserMemory? {
|
||||
memoryCache[userId]?.let { return it }
|
||||
readableDatabase.rawQuery(
|
||||
"SELECT user_id, display_name, last_seen_at, age, gender, hobbies, preferences, last_topics, last_thought, profile_summary FROM user_memory WHERE user_id=?",
|
||||
arrayOf(userId)
|
||||
).use { c ->
|
||||
if (!c.moveToFirst()) return null
|
||||
val memory = UserMemory(
|
||||
userId = c.getString(0),
|
||||
displayName = c.getString(1),
|
||||
lastSeenAt = c.getLong(2),
|
||||
age = c.getString(3),
|
||||
gender = c.getString(4),
|
||||
hobbies = c.getString(5),
|
||||
preferences = c.getString(6),
|
||||
lastTopics = c.getString(7),
|
||||
lastThought = c.getString(8),
|
||||
profileSummary = c.getString(9),
|
||||
)
|
||||
memoryCache[userId] = memory
|
||||
if (!memory.lastThought.isNullOrBlank()) {
|
||||
latestThoughtCache = memory.lastThought
|
||||
}
|
||||
return memory
|
||||
}
|
||||
}
|
||||
|
||||
fun getLatestThought(): String? {
|
||||
latestThoughtCache?.let { return it }
|
||||
readableDatabase.rawQuery(
|
||||
"SELECT last_thought FROM user_memory WHERE last_thought IS NOT NULL AND last_thought != '' ORDER BY last_seen_at DESC LIMIT 1",
|
||||
null
|
||||
).use { c ->
|
||||
if (!c.moveToFirst()) return null
|
||||
return c.getString(0).also { latestThoughtCache = it }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DB_NAME = "digital_human_memory.db"
|
||||
private const val DB_VERSION = 2
|
||||
}
|
||||
}
|
||||
@@ -98,20 +98,26 @@ class Live2DUiManager(private val activity: Activity) {
|
||||
}
|
||||
|
||||
fun appendToUi(s: String) {
|
||||
activity.runOnUiThread {
|
||||
lastUiText += s
|
||||
textView?.text = lastUiText
|
||||
scrollView?.post { scrollView?.fullScroll(ScrollView.FOCUS_DOWN) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearText() {
|
||||
activity.runOnUiThread {
|
||||
lastUiText = ""
|
||||
textView?.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun setText(text: String) {
|
||||
activity.runOnUiThread {
|
||||
lastUiText = text
|
||||
textView?.text = text
|
||||
}
|
||||
}
|
||||
|
||||
fun setButtonsEnabled(startEnabled: Boolean = false, stopEnabled: Boolean = false, recordEnabled: Boolean = true) {
|
||||
startButton?.isEnabled = startEnabled
|
||||
|
||||
Reference in New Issue
Block a user