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 的数据集进行了校准,下载地址:
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.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")
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")
val modelPath = FileHelper.getLLMModelPath(applicationContext)
if (!File(modelPath).exists()) {
throw IllegalStateException("RKLLM model file missing: $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)}")
}
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) {
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)
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)
val local = llmManager
if (local == null) {
onResult("我在想,下次见面可以聊聊今天的新鲜事。")
return
}
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("我在想,下次见面可以聊聊今天的新鲜事。")
}
}
}

View File

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

View File

@@ -73,4 +73,21 @@ object AppConfig {
const val SECRET_ID = "AKIDbBdyBGE5oPuIGA1iDlDYlFallaJ0YODB" // 替换为你的腾讯云SECRET_ID
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(
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 {

View File

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

View File

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

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) {
lastUiText += s
textView?.text = lastUiText
scrollView?.post { scrollView?.fullScroll(ScrollView.FOCUS_DOWN) }
activity.runOnUiThread {
lastUiText += s
textView?.text = lastUiText
scrollView?.post { scrollView?.fullScroll(ScrollView.FOCUS_DOWN) }
}
}
fun clearText() {
lastUiText = ""
textView?.text = ""
activity.runOnUiThread {
lastUiText = ""
textView?.text = ""
}
}
fun setText(text: String) {
lastUiText = text
textView?.text = text
activity.runOnUiThread {
lastUiText = text
textView?.text = text
}
}
fun setButtonsEnabled(startEnabled: Boolean = false, stopEnabled: Boolean = false, recordEnabled: Boolean = true) {

View File

@@ -83,13 +83,13 @@ object FileHelper {
// @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 {
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}")
@@ -122,7 +122,7 @@ object FileHelper {
) {
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 模型
val modelFiles = listOf(
@@ -137,19 +137,20 @@ object FileHelper {
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) {
val modelFile = File(llmDir, fileName)
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) {
totalSize += size
} else {
// 如果无法获取文件大小,使用估计值
when (fileName) {
MODEL_FILE_NAME -> totalSize += 1L * 1024 * 1024 * 1024 // 1.5B模型约1GB
else -> totalSize += 1L * 1024 * 1024 * 1024 // 1GB 默认
}
Log.i(TAG, "Using estimated size for $fileName: ${totalSize / (1024*1024)} MB")
val estimatedSize = AppConfig.LLM.MODEL_SIZE_ESTIMATE
totalSize += estimatedSize
Log.i(TAG, "Using estimated size for $fileName: ${estimatedSize / (1024*1024)} MB")
}
}
}
@@ -160,7 +161,7 @@ object FileHelper {
Log.i(TAG, "Downloading model file: $fileName")
try {
downloadFileWithProgress(
"http://192.168.1.19:5000/download/$fileName",
"$downloadUrl/$fileName",
modelFile
) { downloaded, total ->
val progress = if (totalSize > 0) {
@@ -199,8 +200,8 @@ object FileHelper {
return try {
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
connection.requestMethod = "HEAD"
connection.connectTimeout = 15000
connection.readTimeout = 15000
connection.connectTimeout = AppConfig.LLM.DOWNLOAD_CONNECT_TIMEOUT
connection.readTimeout = AppConfig.LLM.DOWNLOAD_READ_TIMEOUT
// 从响应头获取 Content-Length避免 int 溢出
val contentLengthStr = connection.getHeaderField("Content-Length")
@@ -245,8 +246,8 @@ object FileHelper {
onProgress: (Long, Long) -> Unit
) {
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 30000
connection.readTimeout = 6000000
connection.connectTimeout = AppConfig.LLM.DOWNLOAD_CONNECT_TIMEOUT
connection.readTimeout = AppConfig.LLM.DOWNLOAD_READ_TIMEOUT
// 从响应头获取 Content-Length避免 int 溢出
val contentLengthStr = connection.getHeaderField("Content-Length")
@@ -286,7 +287,7 @@ object FileHelper {
*/
@JvmStatic
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)