Compare commits

...

2 Commits

Author SHA1 Message Date
gcw_4spBpAfv
d5767156b9 ai state machine init 2026-03-05 18:50:48 +08:00
gcw_4spBpAfv
ef2bada800 config update 2026-03-05 14:41:25 +08:00
12 changed files with 888 additions and 232 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}

View File

@@ -113,7 +113,85 @@ https://www.modelscope.cn/datasets/shaoxuan/WIDER_FACE/files
8. 人脸识别模型是insightface的r18模型转成了rknn格式并且使用了 lfw 的数据集进行了校准,下载地址: 8. 人脸识别模型是insightface的r18模型转成了rknn格式并且使用了 lfw 的数据集进行了校准,下载地址:
https://tianchi.aliyun.com/dataset/93864 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秒 → 回到[空闲状态]

View File

@@ -27,10 +27,15 @@ import com.digitalperson.face.ImageProxyBitmapConverter
import com.digitalperson.metrics.TraceManager import com.digitalperson.metrics.TraceManager
import com.digitalperson.metrics.TraceSession import com.digitalperson.metrics.TraceSession
import com.digitalperson.tts.TtsController 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.LLMManager
import com.digitalperson.llm.LLMManagerCallback import com.digitalperson.llm.LLMManagerCallback
import com.digitalperson.util.FileHelper import com.digitalperson.util.FileHelper
import java.io.File import java.io.File
import org.json.JSONObject
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -85,9 +90,18 @@ class Live2DChatActivity : AppCompatActivity() {
private lateinit var cameraPreviewView: PreviewView private lateinit var cameraPreviewView: PreviewView
private lateinit var faceOverlayView: FaceOverlayView private lateinit var faceOverlayView: FaceOverlayView
private lateinit var faceDetectionPipeline: FaceDetectionPipeline private lateinit var faceDetectionPipeline: FaceDetectionPipeline
private lateinit var interactionController: DigitalHumanInteractionController
private lateinit var userMemoryStore: UserMemoryStore
private var facePipelineReady: Boolean = false private var facePipelineReady: Boolean = false
private var cameraProvider: ProcessCameraProvider? = null private var cameraProvider: ProcessCameraProvider? = null
private lateinit var cameraAnalyzerExecutor: ExecutorService 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( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
@@ -143,15 +157,73 @@ class Live2DChatActivity : AppCompatActivity() {
cameraPreviewView.implementationMode = PreviewView.ImplementationMode.COMPATIBLE cameraPreviewView.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
faceOverlayView = findViewById(R.id.face_overlay) faceOverlayView = findViewById(R.id.face_overlay)
cameraAnalyzerExecutor = Executors.newSingleThreadExecutor() 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( faceDetectionPipeline = FaceDetectionPipeline(
context = applicationContext, context = applicationContext,
onResult = { result -> onResult = { result ->
faceOverlayView.updateResult(result) faceOverlayView.updateResult(result)
}, },
onGreeting = { greeting -> onPresenceChanged = { present, faceIdentityId, recognizedName ->
uiManager.appendToUi("\n[Face] $greeting\n") if (present == lastFacePresent) return@FaceDetectionPipeline
ttsController.enqueueSegment(greeting) lastFacePresent = present
ttsController.enqueueEnd() Log.d(TAG_ACTIVITY, "present=$present, faceIdentityId=$faceIdentityId, recognized=$recognizedName")
interactionController.onFacePresenceChanged(present, faceIdentityId, recognizedName)
} }
) )
@@ -200,13 +272,12 @@ class Live2DChatActivity : AppCompatActivity() {
// 设置 LLM 模式开关 // 设置 LLM 模式开关
uiManager.setLLMSwitchListener { isChecked -> uiManager.setLLMSwitchListener { isChecked ->
// 交互状态机固定路由用户对话走云端回忆走本地。此开关仅作为本地LLM可用性提示。
useLocalLLM = isChecked useLocalLLM = isChecked
Log.i(TAG_LLM, "LLM mode switched: useLocalLLM=$useLocalLLM") uiManager.showToast("状态机路由已固定:对话云端,回忆本地")
uiManager.showToast("LLM模式已切换到${if (isChecked) "本地" else "云端"}")
// 重新初始化 LLM
initLLM()
} }
// 默认不显示 LLM 开关,等模型下载完成后再显示 // 默认不显示 LLM 开关,等模型下载完成后再显示
uiManager.showLLMSwitch(false)
if (AppConfig.USE_HOLD_TO_SPEAK) { if (AppConfig.USE_HOLD_TO_SPEAK) {
uiManager.setButtonsEnabled(recordEnabled = false) uiManager.setButtonsEnabled(recordEnabled = false)
@@ -226,8 +297,9 @@ class Live2DChatActivity : AppCompatActivity() {
vadManager = VadManager(this) vadManager = VadManager(this)
vadManager.setCallback(createVadCallback()) vadManager.setCallback(createVadCallback())
// 初始化 LLM 管理器 // 初始化本地 LLM(用于 memory 状态)
initLLM() initLLM()
interactionController.start()
// 检查是否需要下载模型 // 检查是否需要下载模型
if (!FileHelper.isLocalLLMAvailable(this)) { if (!FileHelper.isLocalLLMAvailable(this)) {
@@ -259,8 +331,8 @@ class Live2DChatActivity : AppCompatActivity() {
if (FileHelper.isLocalLLMAvailable(this)) { if (FileHelper.isLocalLLMAvailable(this)) {
Log.i(AppConfig.TAG, "Local LLM is available, enabling local LLM switch") Log.i(AppConfig.TAG, "Local LLM is available, enabling local LLM switch")
// 显示本地 LLM 开关,并同步状态 // 显示本地 LLM 开关,并同步状态
uiManager.showLLMSwitch(true) uiManager.showLLMSwitch(false)
uiManager.setLLMSwitchChecked(useLocalLLM) initLLM()
} }
} else { } else {
Log.e(AppConfig.TAG, "Failed to download model files: $message") Log.e(AppConfig.TAG, "Failed to download model files: $message")
@@ -275,8 +347,7 @@ class Live2DChatActivity : AppCompatActivity() {
// 模型已存在,直接初始化其他组件 // 模型已存在,直接初始化其他组件
initializeOtherComponents() initializeOtherComponents()
// 显示本地 LLM 开关,并同步状态 // 显示本地 LLM 开关,并同步状态
uiManager.showLLMSwitch(true) uiManager.showLLMSwitch(false)
uiManager.setLLMSwitchChecked(useLocalLLM)
} }
} }
@@ -348,6 +419,7 @@ class Live2DChatActivity : AppCompatActivity() {
runOnUiThread { runOnUiThread {
uiManager.appendToUi("\n\n[ASR] ${text}\n") uiManager.appendToUi("\n\n[ASR] ${text}\n")
} }
appendConversationLine("用户", text)
currentTrace?.markRecordingDone() currentTrace?.markRecordingDone()
currentTrace?.markLlmResponseReceived() currentTrace?.markLlmResponseReceived()
} }
@@ -361,17 +433,8 @@ class Live2DChatActivity : AppCompatActivity() {
override fun isLlmInFlight(): Boolean = llmInFlight override fun isLlmInFlight(): Boolean = llmInFlight
override fun onLlmCalled(text: String) { override fun onLlmCalled(text: String) {
llmInFlight = true Log.d(AppConfig.TAG, "Forward ASR text to interaction controller: $text")
Log.d(AppConfig.TAG, "Calling LLM with text: $text") interactionController.onUserAsrText(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)
}
} }
} }
@@ -390,6 +453,7 @@ class Live2DChatActivity : AppCompatActivity() {
override fun onLLMResponseReceived(response: String) { override fun onLLMResponseReceived(response: String) {
currentTrace?.markLlmDone() currentTrace?.markLlmDone()
llmInFlight = false llmInFlight = false
appendConversationLine("助手", response)
if (enableStreaming) { if (enableStreaming) {
for (seg in segmenter.flush()) { for (seg in segmenter.flush()) {
@@ -411,6 +475,7 @@ class Live2DChatActivity : AppCompatActivity() {
ttsController.enqueueSegment(filteredText) ttsController.enqueueSegment(filteredText)
ttsController.enqueueEnd() ttsController.enqueueEnd()
} }
interactionController.onDialogueResponseFinished()
} }
override fun onLLMStreamingChunkReceived(chunk: String) { override fun onLLMStreamingChunkReceived(chunk: String) {
@@ -442,6 +507,7 @@ class Live2DChatActivity : AppCompatActivity() {
override fun onError(errorMessage: String) { override fun onError(errorMessage: String) {
llmInFlight = false llmInFlight = false
uiManager.showToast(errorMessage, Toast.LENGTH_LONG) uiManager.showToast(errorMessage, Toast.LENGTH_LONG)
interactionController.onDialogueResponseFinished()
onStopClicked(userInitiated = false) onStopClicked(userInitiated = false)
} }
} }
@@ -479,6 +545,7 @@ class Live2DChatActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
try { interactionController.stop() } catch (_: Throwable) {}
stopCameraPreviewAndDetection() stopCameraPreviewAndDetection()
onStopClicked(userInitiated = false) onStopClicked(userInitiated = false)
ioScope.cancel() ioScope.cancel()
@@ -490,6 +557,7 @@ class Live2DChatActivity : AppCompatActivity() {
try { cameraAnalyzerExecutor.shutdown() } catch (_: Throwable) {} try { cameraAnalyzerExecutor.shutdown() } catch (_: Throwable) {}
try { ttsController.release() } catch (_: Throwable) {} try { ttsController.release() } catch (_: Throwable) {}
try { llmManager?.destroy() } catch (_: Throwable) {} try { llmManager?.destroy() } catch (_: Throwable) {}
try { userMemoryStore.close() } catch (_: Throwable) {}
try { uiManager.release() } catch (_: Throwable) {} try { uiManager.release() } catch (_: Throwable) {}
try { audioProcessor.release() } catch (_: Throwable) {} try { audioProcessor.release() } catch (_: Throwable) {}
} }
@@ -757,85 +825,158 @@ class Live2DChatActivity : AppCompatActivity() {
} }
Log.d(AppConfig.TAG, "processSamplesLoop stopped") 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() { private fun initLLM() {
try { try {
Log.i(TAG_LLM, "initLLM called, useLocalLLM=$useLocalLLM") Log.i(TAG_LLM, "initLLM called for memory-local model")
llmManager?.destroy() llmManager?.destroy()
llmManager = null llmManager = null
if (useLocalLLM) { val modelPath = FileHelper.getLLMModelPath(applicationContext)
// // 本地 LLM 初始化前,先暂停/释放重模块 if (!File(modelPath).exists()) {
// Log.i(AppConfig.TAG, "Pausing camera and releasing face detection before LLM initialization") throw IllegalStateException("RKLLM model file missing: $modelPath")
// 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")
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) {
uiManager.appendToUi(msg)
}
} else {
val finalText = localLlmResponseBuffer.toString().trim()
localLlmResponseBuffer.setLength(0)
if (!enableStreaming && finalText.isNotEmpty()) {
uiManager.appendToUi("$finalText\n")
}
uiManager.appendToUi("\n\n[LLM] 生成完成\n")
llmInFlight = false
if (finalText.isNotEmpty()) {
ttsController.enqueueSegment(finalText)
ttsController.enqueueEnd()
} else {
Log.w(TAG_LLM, "LOCAL final text is empty, skip TTS enqueue")
}
}
}
}
})
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(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)}")
}
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 && !localThoughtSilentMode) {
uiManager.appendToUi(msg)
}
return@runOnUiThread
}
val finalText = localLlmResponseBuffer.toString().trim()
localLlmResponseBuffer.setLength(0)
val profileCallback = pendingLocalProfileCallback
pendingLocalProfileCallback = null
if (profileCallback != null) {
profileCallback(finalText)
localThoughtSilentMode = false
return@runOnUiThread
}
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()
}
localThoughtSilentMode = false
}
}
})
Log.i(TAG_LLM, "LOCAL memory LLM initialized")
useLocalLLM = true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initialize LLM: ${e.message}", e) Log.e(AppConfig.TAG, "Failed to initialize LLM: ${e.message}", e)
Log.e(TAG_LLM, "LOCAL init failed: ${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 { try {
if (useLocalLLM) { val local = llmManager
val systemPrompt = "你是一个友好的数字人助手,回答要简洁明了。" if (local == null) {
Log.d(AppConfig.TAG, "Generating response for: $userInput") onResult("我在想,下次见面可以聊聊今天的新鲜事。")
val local = llmManager return
if (local == null) {
Log.e(TAG_LLM, "LOCAL LLM manager is null, fallback to CLOUD")
cloudApiManager.callLLM(userInput)
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) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to generate response: ${e.message}", e) Log.e(TAG_LLM, "requestLocalThought failed: ${e.message}", e)
Log.e(TAG_LLM, "generateResponse failed: ${e.message}", e) pendingLocalThoughtCallback = null
runOnUiThread { localThoughtSilentMode = false
uiManager.appendToUi("\n\n[Error] LLM 生成失败: ${e.message}\n") onResult("我在想,下次见面可以聊聊今天的新鲜事。")
llmInFlight = false
}
} }
} }
} }

View File

@@ -241,6 +241,13 @@ public class CloudApiManager {
mConversationHistory = new JSONArray(); mConversationHistory = new JSONArray();
} }
/**
* 添加助手消息到对话历史
*/
public void addAssistantMessage(String content) {
addMessageToHistory("assistant", content);
}
public void callTTS(String text, File outputFile) { public void callTTS(String text, File outputFile) {
if (mListener != null) { if (mListener != null) {
mMainHandler.post(() -> { mMainHandler.post(() -> {

View File

@@ -73,4 +73,21 @@ object AppConfig {
const val SECRET_ID = "AKIDbBdyBGE5oPuIGA1iDlDYlFallaJ0YODB" // 替换为你的腾讯云SECRET_ID const val SECRET_ID = "AKIDbBdyBGE5oPuIGA1iDlDYlFallaJ0YODB" // 替换为你的腾讯云SECRET_ID
const val SECRET_KEY = "32vhIl9OQIRclmLjvuleLp9LLAnFVYEp" // 替换为你的腾讯云SECRET_KEY const val SECRET_KEY = "32vhIl9OQIRclmLjvuleLp9LLAnFVYEp" // 替换为你的腾讯云SECRET_KEY
} }
object LLM {
// 模型下载服务器地址
const val DOWNLOAD_SERVER = "http://192.168.1.19:5000"
// 下载路径
const val DOWNLOAD_PATH = "/download"
// 模型文件名
const val MODEL_FILE_NAME = "Qwen3-0.6B-rk3588-w8a8.rkllm"
// 模型存储目录
const val MODEL_DIR = "llm"
// 下载连接超时(毫秒)
const val DOWNLOAD_CONNECT_TIMEOUT = 600000
// 下载读取超时(毫秒)
const val DOWNLOAD_READ_TIMEOUT = 1200000
// 模型文件大小估计(字节)
const val MODEL_SIZE_ESTIMATE = 500L * 1024 * 1024 // 500MB
}
} }

View File

@@ -31,7 +31,7 @@ data class FaceDetectionResult(
class FaceDetectionPipeline( class FaceDetectionPipeline(
private val context: Context, private val context: Context,
private val onResult: (FaceDetectionResult) -> Unit, 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 engine = RetinaFaceEngineRKNN()
private val recognizer = FaceRecognizer(context) private val recognizer = FaceRecognizer(context)
@@ -41,8 +41,9 @@ class FaceDetectionPipeline(
private var trackFace: FaceBox? = null private var trackFace: FaceBox? = null
private var trackId: Long = 0 private var trackId: Long = 0
private var trackStableSinceMs: Long = 0 private var trackStableSinceMs: Long = 0
private var greetedTrackId: Long = -1 private var analyzedTrackId: Long = -1
private var lastGreetMs: Long = 0 private var lastFaceIdentityId: String? = null
private var lastRecognizedName: String? = null
fun initialize(): Boolean { fun initialize(): Boolean {
val detectorOk = engine.initialize(context) val detectorOk = engine.initialize(context)
@@ -98,8 +99,9 @@ class FaceDetectionPipeline(
// ) // )
// } // }
maybeRecognizeAndGreet(bitmap, filteredFaces) maybeRecognize(bitmap, filteredFaces)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
onPresenceChanged(filteredFaces.isNotEmpty(), lastFaceIdentityId, lastRecognizedName)
onResult(FaceDetectionResult(width, height, filteredFaces)) onResult(FaceDetectionResult(width, height, filteredFaces))
} }
} catch (t: Throwable) { } 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() val now = System.currentTimeMillis()
if (faces.isEmpty()) { if (faces.isEmpty()) {
trackFace = null trackFace = null
trackStableSinceMs = 0 trackStableSinceMs = 0
analyzedTrackId = -1
lastFaceIdentityId = null
lastRecognizedName = null
return return
} }
@@ -123,62 +128,30 @@ class FaceDetectionPipeline(
val prev = trackFace val prev = trackFace
if (prev == null || iou(prev, primary) < AppConfig.Face.TRACK_IOU_THRESHOLD) { if (prev == null || iou(prev, primary) < AppConfig.Face.TRACK_IOU_THRESHOLD) {
trackId += 1 trackId += 1
greetedTrackId = -1
trackStableSinceMs = now trackStableSinceMs = now
analyzedTrackId = -1
lastFaceIdentityId = null
lastRecognizedName = null
} }
trackFace = primary trackFace = primary
val stableMs = now - trackStableSinceMs val stableMs = now - trackStableSinceMs
val frontal = isFrontal(primary, bitmap.width, bitmap.height) val frontal = isFrontal(primary, bitmap.width, bitmap.height)
val coolingDown = (now - lastGreetMs) < AppConfig.FaceRecognition.GREETING_COOLDOWN_MS if (stableMs < AppConfig.Face.STABLE_MS || !frontal) {
if (stableMs < AppConfig.Face.STABLE_MS || !frontal || greetedTrackId == trackId || coolingDown) { return
}
if (analyzedTrackId == trackId) {
return return
} }
val match = recognizer.identify(bitmap, primary) val match = recognizer.resolveIdentity(bitmap, primary)
analyzedTrackId = trackId
Log.d(AppConfig.TAG, "[Face] Recognition result: matchedName=${match.matchedName}, similarity=${match.similarity}") lastFaceIdentityId = match.matchedId?.let { "face_$it" }
lastRecognizedName = match.matchedName
// 检查是否需要保存新人脸
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
Log.i( Log.i(
AppConfig.TAG, 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 { private fun isFrontal(face: FaceBox, frameW: Int, frameH: Int): Boolean {

View File

@@ -11,7 +11,7 @@ import java.nio.ByteOrder
data class FaceProfile( data class FaceProfile(
val id: Long, val id: Long,
val name: String, val name: String?,
val embedding: FloatArray, val embedding: FloatArray,
) )
@@ -21,7 +21,7 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
""" """
CREATE TABLE IF NOT EXISTS face_profiles ( CREATE TABLE IF NOT EXISTS face_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT,
embedding BLOB NOT NULL, embedding BLOB NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
) )
@@ -55,9 +55,8 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
return list return list
} }
fun upsertProfile(name: String, embedding: FloatArray) { fun insertProfile(name: String?, embedding: FloatArray): Long {
// 确保名字不为null使用空字符串作为默认值 val safeName = name?.takeIf { it.isNotBlank() }
val safeName = name.takeIf { it.isNotBlank() } ?: ""
val values = ContentValues().apply { val values = ContentValues().apply {
put("name", safeName) put("name", safeName)
put("embedding", floatArrayToBlob(embedding)) put("embedding", floatArrayToBlob(embedding))
@@ -67,9 +66,10 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
"face_profiles", "face_profiles",
null, null,
values, 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 { private fun floatArrayToBlob(values: FloatArray): ByteArray {
@@ -88,6 +88,6 @@ class FaceFeatureStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, nu
companion object { companion object {
private const val DB_NAME = "face_feature.db" private const val DB_NAME = "face_feature.db"
private const val DB_VERSION = 1 private const val DB_VERSION = 2
} }
} }

View File

@@ -8,6 +8,7 @@ import com.digitalperson.engine.ArcFaceEngineRKNN
import kotlin.math.sqrt import kotlin.math.sqrt
data class FaceRecognitionResult( data class FaceRecognitionResult(
val matchedId: Long?,
val matchedName: String?, val matchedName: String?,
val similarity: Float, val similarity: Float,
val embeddingDim: Int, val embeddingDim: Int,
@@ -40,10 +41,11 @@ class FaceRecognizer(context: Context) {
} }
fun identify(bitmap: Bitmap, face: FaceBox): FaceRecognitionResult { 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) 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 bestName: String? = null
var bestScore = -1f var bestScore = -1f
for (p in cache) { for (p in cache) {
@@ -51,13 +53,14 @@ class FaceRecognizer(context: Context) {
val score = cosineSimilarity(embedding, p.embedding) val score = cosineSimilarity(embedding, p.embedding)
if (score > bestScore) { if (score > bestScore) {
bestScore = score bestScore = score
bestId = p.id
bestName = p.name bestName = p.name
} }
} }
if (bestScore >= AppConfig.FaceRecognition.SIMILARITY_THRESHOLD) { 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 { 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) 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) val normalized = normalize(embedding)
store.upsertProfile(name ?: "", normalized) val rowId = store.insertProfile(name, normalized)
// 移除旧的记录(如果存在) if (rowId > 0) {
if (name != null) { cache.add(FaceProfile(id = rowId, name = name, embedding = normalized))
cache.removeAll { it.name == name }
} }
cache.add(FaceProfile(id = -1L, name = name ?: "", embedding = normalized)) return rowId
} }
fun addNewFace(embedding: FloatArray): Boolean { fun resolveIdentity(bitmap: Bitmap, face: FaceBox): FaceRecognitionResult {
Log.d(AppConfig.TAG, "[FaceRecognizer] addNewFace: embedding size=${embedding.size}, cache size=${cache.size}") val match = identify(bitmap, face)
if (match.matchedId != null) return match
// 检查是否已经存在相似的人脸 val embedding = extractEmbedding(bitmap, face)
for (p in cache) { if (embedding.isEmpty()) return match
if (p.embedding.size != embedding.size) { val newId = addProfile(name = null, embedding = embedding)
Log.d(AppConfig.TAG, "[FaceRecognizer] Skipping profile with different embedding size: ${p.embedding.size}") if (newId <= 0L) return match
continue return FaceRecognitionResult(
} matchedId = newId,
val score = cosineSimilarity(embedding, p.embedding) matchedName = null,
Log.d(AppConfig.TAG, "[FaceRecognizer] Comparing with profile '${p.name}': similarity=$score, threshold=${AppConfig.FaceRecognition.SIMILARITY_THRESHOLD}") similarity = match.similarity,
if (score >= AppConfig.FaceRecognition.SIMILARITY_THRESHOLD) { embeddingDim = embedding.size
// 已经存在相似的人脸,不需要添加 )
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 release() { fun release() {

View File

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

View File

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

View File

@@ -98,19 +98,25 @@ class Live2DUiManager(private val activity: Activity) {
} }
fun appendToUi(s: String) { fun appendToUi(s: String) {
lastUiText += s activity.runOnUiThread {
textView?.text = lastUiText lastUiText += s
scrollView?.post { scrollView?.fullScroll(ScrollView.FOCUS_DOWN) } textView?.text = lastUiText
scrollView?.post { scrollView?.fullScroll(ScrollView.FOCUS_DOWN) }
}
} }
fun clearText() { fun clearText() {
lastUiText = "" activity.runOnUiThread {
textView?.text = "" lastUiText = ""
textView?.text = ""
}
} }
fun setText(text: String) { fun setText(text: String) {
lastUiText = text activity.runOnUiThread {
textView?.text = text lastUiText = text
textView?.text = text
}
} }
fun setButtonsEnabled(startEnabled: Boolean = false, stopEnabled: Boolean = false, recordEnabled: Boolean = true) { fun setButtonsEnabled(startEnabled: Boolean = false, stopEnabled: Boolean = false, recordEnabled: Boolean = true) {

View File

@@ -83,13 +83,13 @@ object FileHelper {
// @JvmStatic // @JvmStatic
// 当前使用的模型文件名 // 当前使用的模型文件名
private const val MODEL_FILE_NAME = "Qwen3-0.6B-rk3588-w8a8.rkllm" private val MODEL_FILE_NAME = com.digitalperson.config.AppConfig.LLM.MODEL_FILE_NAME
fun getLLMModelPath(context: Context): String { fun getLLMModelPath(context: Context): String {
Log.d(TAG, "=== getLLMModelPath START ===") Log.d(TAG, "=== getLLMModelPath START ===")
// 从应用内部存储目录加载模型 // 从应用内部存储目录加载模型
val llmDir = ensureDir(File(context.filesDir, "llm")) val llmDir = ensureDir(File(context.filesDir, AppConfig.LLM.MODEL_DIR))
Log.d(TAG, "Loading models from: ${llmDir.absolutePath}") Log.d(TAG, "Loading models from: ${llmDir.absolutePath}")
@@ -122,7 +122,7 @@ object FileHelper {
) { ) {
Log.d(TAG, "=== downloadModelFilesWithProgress START ===") Log.d(TAG, "=== downloadModelFilesWithProgress START ===")
val llmDir = ensureDir(File(context.filesDir, "llm")) val llmDir = ensureDir(File(context.filesDir, AppConfig.LLM.MODEL_DIR))
// 模型文件列表 - 使用 DeepSeek-R1-Distill-Qwen-1.5B 模型 // 模型文件列表 - 使用 DeepSeek-R1-Distill-Qwen-1.5B 模型
val modelFiles = listOf( val modelFiles = listOf(
@@ -137,19 +137,20 @@ object FileHelper {
var totalSize: Long = 0 var totalSize: Long = 0
// 首先计算总大小 // 首先计算总大小
val downloadUrl = AppConfig.LLM.DOWNLOAD_SERVER + AppConfig.LLM.DOWNLOAD_PATH
Log.i(TAG, "Using download server: ${AppConfig.LLM.DOWNLOAD_SERVER}")
for (fileName in modelFiles) { for (fileName in modelFiles) {
val modelFile = File(llmDir, fileName) val modelFile = File(llmDir, fileName)
if (!modelFile.exists() || modelFile.length() == 0L) { if (!modelFile.exists() || modelFile.length() == 0L) {
val size = getFileSizeFromServer("http://192.168.1.19:5000/download/$fileName") val size = getFileSizeFromServer("$downloadUrl/$fileName")
if (size > 0) { if (size > 0) {
totalSize += size totalSize += size
} else { } else {
// 如果无法获取文件大小,使用估计值 // 如果无法获取文件大小,使用估计值
when (fileName) { val estimatedSize = AppConfig.LLM.MODEL_SIZE_ESTIMATE
MODEL_FILE_NAME -> totalSize += 1L * 1024 * 1024 * 1024 // 1.5B模型约1GB totalSize += estimatedSize
else -> totalSize += 1L * 1024 * 1024 * 1024 // 1GB 默认 Log.i(TAG, "Using estimated size for $fileName: ${estimatedSize / (1024*1024)} MB")
}
Log.i(TAG, "Using estimated size for $fileName: ${totalSize / (1024*1024)} MB")
} }
} }
} }
@@ -160,7 +161,7 @@ object FileHelper {
Log.i(TAG, "Downloading model file: $fileName") Log.i(TAG, "Downloading model file: $fileName")
try { try {
downloadFileWithProgress( downloadFileWithProgress(
"http://192.168.1.19:5000/download/$fileName", "$downloadUrl/$fileName",
modelFile modelFile
) { downloaded, total -> ) { downloaded, total ->
val progress = if (totalSize > 0) { val progress = if (totalSize > 0) {
@@ -199,8 +200,8 @@ object FileHelper {
return try { return try {
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
connection.requestMethod = "HEAD" connection.requestMethod = "HEAD"
connection.connectTimeout = 15000 connection.connectTimeout = AppConfig.LLM.DOWNLOAD_CONNECT_TIMEOUT
connection.readTimeout = 15000 connection.readTimeout = AppConfig.LLM.DOWNLOAD_READ_TIMEOUT
// 从响应头获取 Content-Length避免 int 溢出 // 从响应头获取 Content-Length避免 int 溢出
val contentLengthStr = connection.getHeaderField("Content-Length") val contentLengthStr = connection.getHeaderField("Content-Length")
@@ -245,8 +246,8 @@ object FileHelper {
onProgress: (Long, Long) -> Unit onProgress: (Long, Long) -> Unit
) { ) {
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 30000 connection.connectTimeout = AppConfig.LLM.DOWNLOAD_CONNECT_TIMEOUT
connection.readTimeout = 6000000 connection.readTimeout = AppConfig.LLM.DOWNLOAD_READ_TIMEOUT
// 从响应头获取 Content-Length避免 int 溢出 // 从响应头获取 Content-Length避免 int 溢出
val contentLengthStr = connection.getHeaderField("Content-Length") val contentLengthStr = connection.getHeaderField("Content-Length")
@@ -286,7 +287,7 @@ object FileHelper {
*/ */
@JvmStatic @JvmStatic
fun isLocalLLMAvailable(context: Context): Boolean { fun isLocalLLMAvailable(context: Context): Boolean {
val llmDir = File(context.filesDir, "llm") val llmDir = File(context.filesDir, AppConfig.LLM.MODEL_DIR)
val rkllmFile = File(llmDir, MODEL_FILE_NAME) val rkllmFile = File(llmDir, MODEL_FILE_NAME)