ai state machine init
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "automatic"
|
||||||
|
}
|
||||||
@@ -113,7 +113,85 @@ https://www.modelscope.cn/datasets/shaoxuan/WIDER_FACE/files
|
|||||||
8. 人脸识别模型是insightface的r18模型,转成了rknn格式,并且使用了 lfw 的数据集进行了校准,下载地址:
|
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秒 → 回到[空闲状态]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(() -> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package com.digitalperson.interaction
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.digitalperson.config.AppConfig
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
enum class InteractionState {
|
||||||
|
IDLE,
|
||||||
|
MEMORY,
|
||||||
|
GREETING,
|
||||||
|
WAITING_REPLY,
|
||||||
|
DIALOGUE,
|
||||||
|
PROACTIVE,
|
||||||
|
FAREWELL,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LlmRoute {
|
||||||
|
CLOUD,
|
||||||
|
LOCAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractionActionHandler {
|
||||||
|
fun onStateChanged(state: InteractionState)
|
||||||
|
fun playMotion(motionName: String)
|
||||||
|
fun appendText(text: String)
|
||||||
|
fun speak(text: String)
|
||||||
|
fun requestCloudReply(userText: String)
|
||||||
|
fun requestLocalThought(prompt: String, onResult: (String) -> Unit)
|
||||||
|
fun onRememberUser(faceIdentityId: String, name: String?)
|
||||||
|
fun saveThought(thought: String)
|
||||||
|
fun loadLatestThought(): String?
|
||||||
|
fun addToChatHistory(role: String, content: String)
|
||||||
|
fun addAssistantMessageToCloudHistory(content: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DigitalHumanInteractionController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val handler: InteractionActionHandler,
|
||||||
|
) {
|
||||||
|
private val TAG: String = "DigitalHumanInteraction"
|
||||||
|
private var state: InteractionState = InteractionState.IDLE
|
||||||
|
private var facePresent: Boolean = false
|
||||||
|
private var recognizedName: String? = null
|
||||||
|
private var proactiveRound = 0
|
||||||
|
private var hasPendingUserReply = false
|
||||||
|
|
||||||
|
private var faceStableJob: Job? = null
|
||||||
|
private var waitReplyJob: Job? = null
|
||||||
|
private var proactiveJob: Job? = null
|
||||||
|
private var memoryJob: Job? = null
|
||||||
|
private var farewellJob: Job? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
transitionTo(InteractionState.IDLE)
|
||||||
|
scheduleMemoryMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFacePresenceChanged(present: Boolean, faceIdentityId: String?, recognized: String?) {
|
||||||
|
Log.d(TAG, "onFacePresenceChanged: present=$present, faceIdentityId=$faceIdentityId, recognized=$recognized, state=$state")
|
||||||
|
|
||||||
|
facePresent = present
|
||||||
|
if (!faceIdentityId.isNullOrBlank()) {
|
||||||
|
handler.onRememberUser(faceIdentityId, recognized)
|
||||||
|
}
|
||||||
|
if (!recognized.isNullOrBlank()) {
|
||||||
|
recognizedName = recognized
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一延迟处理
|
||||||
|
faceStableJob?.cancel()
|
||||||
|
faceStableJob = scope.launch {
|
||||||
|
delay(AppConfig.Face.STABLE_MS)
|
||||||
|
|
||||||
|
if (present) {
|
||||||
|
// 人脸出现后的处理
|
||||||
|
if (facePresent && (state == InteractionState.IDLE || state == InteractionState.MEMORY)) {
|
||||||
|
enterGreeting()
|
||||||
|
} else if (state == InteractionState.FAREWELL) {
|
||||||
|
enterGreeting()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 人脸消失后的处理
|
||||||
|
if (state != InteractionState.IDLE && state != InteractionState.MEMORY && state != InteractionState.FAREWELL) {
|
||||||
|
enterFarewell()
|
||||||
|
} else {
|
||||||
|
scheduleMemoryMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUserAsrText(text: String) {
|
||||||
|
val userText = text.trim()
|
||||||
|
if (userText.isBlank()) return
|
||||||
|
|
||||||
|
if (userText.contains("你在想什么")) {
|
||||||
|
val thought = handler.loadLatestThought()
|
||||||
|
if (!thought.isNullOrBlank()) {
|
||||||
|
handler.speak("我刚才在想:$thought")
|
||||||
|
handler.appendText("\n[回忆] $thought\n")
|
||||||
|
transitionTo(InteractionState.DIALOGUE)
|
||||||
|
handler.playMotion("haru_g_m15.motion3.json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userText.contains("再见")) {
|
||||||
|
enterFarewell()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingUserReply = true
|
||||||
|
when (state) {
|
||||||
|
InteractionState.WAITING_REPLY, InteractionState.PROACTIVE, InteractionState.GREETING, InteractionState.DIALOGUE -> {
|
||||||
|
transitionTo(InteractionState.DIALOGUE)
|
||||||
|
requestDialogueReply(userText)
|
||||||
|
}
|
||||||
|
InteractionState.MEMORY, InteractionState.IDLE -> {
|
||||||
|
transitionTo(InteractionState.DIALOGUE)
|
||||||
|
requestDialogueReply(userText)
|
||||||
|
}
|
||||||
|
InteractionState.FAREWELL -> {
|
||||||
|
enterGreeting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDialogueResponseFinished() {
|
||||||
|
if (!facePresent) {
|
||||||
|
enterFarewell()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transitionTo(InteractionState.WAITING_REPLY)
|
||||||
|
handler.playMotion("haru_g_m17.motion3.json")
|
||||||
|
scheduleWaitingReplyTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterGreeting() {
|
||||||
|
transitionTo(InteractionState.GREETING)
|
||||||
|
val greet = if (!recognizedName.isNullOrBlank()) {
|
||||||
|
handler.playMotion("haru_g_m22.motion3.json")
|
||||||
|
"你好,$recognizedName,很高兴再次见到你。"
|
||||||
|
} else {
|
||||||
|
handler.playMotion("haru_g_m01.motion3.json")
|
||||||
|
"你好,很高兴见到你。"
|
||||||
|
}
|
||||||
|
handler.speak(greet)
|
||||||
|
handler.appendText("\n[问候] $greet\n")
|
||||||
|
transitionTo(InteractionState.WAITING_REPLY)
|
||||||
|
handler.playMotion("haru_g_m17.motion3.json")
|
||||||
|
scheduleWaitingReplyTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleWaitingReplyTimeout() {
|
||||||
|
waitReplyJob?.cancel()
|
||||||
|
waitReplyJob = scope.launch {
|
||||||
|
hasPendingUserReply = false
|
||||||
|
delay(10_000)
|
||||||
|
if (state != InteractionState.WAITING_REPLY || hasPendingUserReply) return@launch
|
||||||
|
if (facePresent) {
|
||||||
|
enterProactive()
|
||||||
|
} else {
|
||||||
|
enterFarewell()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterProactive() {
|
||||||
|
transitionTo(InteractionState.PROACTIVE)
|
||||||
|
proactiveRound = 0
|
||||||
|
askProactiveTopic()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun askProactiveTopic() {
|
||||||
|
proactiveJob?.cancel()
|
||||||
|
val topics = listOf(
|
||||||
|
"我出一道数学题考考你吧?1+6等于多少?",
|
||||||
|
"我们上完厕所应该干什么呀?",
|
||||||
|
"你喜欢什么颜色呀?",
|
||||||
|
)
|
||||||
|
val idx = proactiveRound.coerceIn(0, topics.lastIndex)
|
||||||
|
val topic = topics[idx]
|
||||||
|
handler.playMotion(if (proactiveRound == 0) "haru_g_m15.motion3.json" else "haru_g_m22.motion3.json")
|
||||||
|
handler.speak(topic)
|
||||||
|
handler.appendText("\n[主动引导] $topic\n")
|
||||||
|
// 将引导内容添加到对话历史中
|
||||||
|
handler.addToChatHistory("助手", topic)
|
||||||
|
// 将引导内容添加到云对话历史中
|
||||||
|
handler.addAssistantMessageToCloudHistory(topic)
|
||||||
|
|
||||||
|
proactiveJob = scope.launch {
|
||||||
|
hasPendingUserReply = false
|
||||||
|
delay(20_000)
|
||||||
|
if (state != InteractionState.PROACTIVE || hasPendingUserReply) return@launch
|
||||||
|
if (!facePresent) {
|
||||||
|
enterFarewell()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
proactiveRound += 1
|
||||||
|
if (proactiveRound < 3) {
|
||||||
|
askProactiveTopic()
|
||||||
|
} else {
|
||||||
|
transitionTo(InteractionState.WAITING_REPLY)
|
||||||
|
handler.playMotion("haru_g_m17.motion3.json")
|
||||||
|
scheduleWaitingReplyTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterFarewell() {
|
||||||
|
transitionTo(InteractionState.FAREWELL)
|
||||||
|
handler.playMotion("haru_g_idle.motion3.json")
|
||||||
|
handler.speak("再见咯")
|
||||||
|
farewellJob?.cancel()
|
||||||
|
farewellJob = scope.launch {
|
||||||
|
delay(3_000)
|
||||||
|
transitionTo(InteractionState.IDLE)
|
||||||
|
handler.playMotion("haru_g_idle.motion3.json")
|
||||||
|
scheduleMemoryMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleMemoryMode() {
|
||||||
|
memoryJob?.cancel()
|
||||||
|
if (facePresent) return
|
||||||
|
memoryJob = scope.launch {
|
||||||
|
delay(30_000)
|
||||||
|
if (facePresent || state != InteractionState.IDLE) return@launch
|
||||||
|
transitionTo(InteractionState.MEMORY)
|
||||||
|
handler.playMotion("haru_g_m15.motion3.json")
|
||||||
|
val prompt = "请基于最近互动写一句简短内心想法,口吻温和自然。"
|
||||||
|
handler.requestLocalThought(prompt) { thought ->
|
||||||
|
val finalThought = thought.trim().ifBlank { "我在想,下次见面要聊点有趣的新话题。" }
|
||||||
|
handler.saveThought(finalThought)
|
||||||
|
handler.appendText("\n[回忆] $finalThought\n")
|
||||||
|
if (!facePresent && state == InteractionState.MEMORY) {
|
||||||
|
transitionTo(InteractionState.IDLE)
|
||||||
|
handler.playMotion("haru_g_idle.motion3.json")
|
||||||
|
scheduleMemoryMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestDialogueReply(userText: String) {
|
||||||
|
waitReplyJob?.cancel()
|
||||||
|
proactiveJob?.cancel()
|
||||||
|
// 按产品要求:用户对话统一走云端LLM
|
||||||
|
handler.requestCloudReply(userText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transitionTo(newState: InteractionState) {
|
||||||
|
if (state == newState) return
|
||||||
|
state = newState
|
||||||
|
handler.onStateChanged(newState)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
faceStableJob?.cancel()
|
||||||
|
waitReplyJob?.cancel()
|
||||||
|
proactiveJob?.cancel()
|
||||||
|
memoryJob?.cancel()
|
||||||
|
farewellJob?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.digitalperson.interaction
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
|
|
||||||
|
data class UserMemory(
|
||||||
|
val userId: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val lastSeenAt: Long,
|
||||||
|
val age: String?,
|
||||||
|
val gender: String?,
|
||||||
|
val hobbies: String?,
|
||||||
|
val preferences: String?,
|
||||||
|
val lastTopics: String?,
|
||||||
|
val lastThought: String?,
|
||||||
|
val profileSummary: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
class UserMemoryStore(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
||||||
|
private val memoryCache = LinkedHashMap<String, UserMemory>()
|
||||||
|
@Volatile private var latestThoughtCache: String? = null
|
||||||
|
|
||||||
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_memory (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT,
|
||||||
|
last_seen_at INTEGER NOT NULL,
|
||||||
|
age TEXT,
|
||||||
|
gender TEXT,
|
||||||
|
hobbies TEXT,
|
||||||
|
preferences TEXT,
|
||||||
|
last_topics TEXT,
|
||||||
|
last_thought TEXT,
|
||||||
|
profile_summary TEXT
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
db.execSQL("DROP TABLE IF EXISTS user_memory")
|
||||||
|
onCreate(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upsertUserSeen(userId: String, displayName: String?) {
|
||||||
|
val existing = memoryCache[userId] ?: getMemory(userId)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val mergedName = displayName?.takeIf { it.isNotBlank() } ?: existing?.displayName
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put("user_id", userId)
|
||||||
|
put("display_name", mergedName)
|
||||||
|
put("last_seen_at", now)
|
||||||
|
put("age", existing?.age)
|
||||||
|
put("gender", existing?.gender)
|
||||||
|
put("hobbies", existing?.hobbies)
|
||||||
|
put("preferences", existing?.preferences)
|
||||||
|
put("last_topics", existing?.lastTopics)
|
||||||
|
put("last_thought", existing?.lastThought)
|
||||||
|
put("profile_summary", existing?.profileSummary)
|
||||||
|
}
|
||||||
|
writableDatabase.insertWithOnConflict("user_memory", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||||
|
memoryCache[userId] = UserMemory(
|
||||||
|
userId = userId,
|
||||||
|
displayName = mergedName,
|
||||||
|
lastSeenAt = now,
|
||||||
|
age = existing?.age,
|
||||||
|
gender = existing?.gender,
|
||||||
|
hobbies = existing?.hobbies,
|
||||||
|
preferences = existing?.preferences,
|
||||||
|
lastTopics = existing?.lastTopics,
|
||||||
|
lastThought = existing?.lastThought,
|
||||||
|
profileSummary = existing?.profileSummary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDisplayName(userId: String, displayName: String?) {
|
||||||
|
if (displayName.isNullOrBlank()) return
|
||||||
|
upsertUserSeen(userId, displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateThought(userId: String, thought: String) {
|
||||||
|
upsertUserSeen(userId, null)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put("last_thought", thought)
|
||||||
|
put("last_seen_at", now)
|
||||||
|
}
|
||||||
|
writableDatabase.update("user_memory", values, "user_id=?", arrayOf(userId))
|
||||||
|
latestThoughtCache = thought
|
||||||
|
val cached = memoryCache[userId]
|
||||||
|
memoryCache[userId] = UserMemory(
|
||||||
|
userId = userId,
|
||||||
|
displayName = cached?.displayName,
|
||||||
|
lastSeenAt = now,
|
||||||
|
age = cached?.age,
|
||||||
|
gender = cached?.gender,
|
||||||
|
hobbies = cached?.hobbies,
|
||||||
|
preferences = cached?.preferences,
|
||||||
|
lastTopics = cached?.lastTopics,
|
||||||
|
lastThought = thought,
|
||||||
|
profileSummary = cached?.profileSummary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProfile(userId: String, age: String?, gender: String?, hobbies: String?, summary: String?) {
|
||||||
|
upsertUserSeen(userId, null)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
if (age != null) put("age", age)
|
||||||
|
if (gender != null) put("gender", gender)
|
||||||
|
if (hobbies != null) put("hobbies", hobbies)
|
||||||
|
if (summary != null) put("profile_summary", summary)
|
||||||
|
put("last_seen_at", now)
|
||||||
|
}
|
||||||
|
writableDatabase.update("user_memory", values, "user_id=?", arrayOf(userId))
|
||||||
|
val cached = memoryCache[userId]
|
||||||
|
memoryCache[userId] = UserMemory(
|
||||||
|
userId = userId,
|
||||||
|
displayName = cached?.displayName,
|
||||||
|
lastSeenAt = now,
|
||||||
|
age = age ?: cached?.age,
|
||||||
|
gender = gender ?: cached?.gender,
|
||||||
|
hobbies = hobbies ?: cached?.hobbies,
|
||||||
|
preferences = cached?.preferences,
|
||||||
|
lastTopics = cached?.lastTopics,
|
||||||
|
lastThought = cached?.lastThought,
|
||||||
|
profileSummary = summary ?: cached?.profileSummary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMemory(userId: String): UserMemory? {
|
||||||
|
memoryCache[userId]?.let { return it }
|
||||||
|
readableDatabase.rawQuery(
|
||||||
|
"SELECT user_id, display_name, last_seen_at, age, gender, hobbies, preferences, last_topics, last_thought, profile_summary FROM user_memory WHERE user_id=?",
|
||||||
|
arrayOf(userId)
|
||||||
|
).use { c ->
|
||||||
|
if (!c.moveToFirst()) return null
|
||||||
|
val memory = UserMemory(
|
||||||
|
userId = c.getString(0),
|
||||||
|
displayName = c.getString(1),
|
||||||
|
lastSeenAt = c.getLong(2),
|
||||||
|
age = c.getString(3),
|
||||||
|
gender = c.getString(4),
|
||||||
|
hobbies = c.getString(5),
|
||||||
|
preferences = c.getString(6),
|
||||||
|
lastTopics = c.getString(7),
|
||||||
|
lastThought = c.getString(8),
|
||||||
|
profileSummary = c.getString(9),
|
||||||
|
)
|
||||||
|
memoryCache[userId] = memory
|
||||||
|
if (!memory.lastThought.isNullOrBlank()) {
|
||||||
|
latestThoughtCache = memory.lastThought
|
||||||
|
}
|
||||||
|
return memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLatestThought(): String? {
|
||||||
|
latestThoughtCache?.let { return it }
|
||||||
|
readableDatabase.rawQuery(
|
||||||
|
"SELECT last_thought FROM user_memory WHERE last_thought IS NOT NULL AND last_thought != '' ORDER BY last_seen_at DESC LIMIT 1",
|
||||||
|
null
|
||||||
|
).use { c ->
|
||||||
|
if (!c.moveToFirst()) return null
|
||||||
|
return c.getString(0).also { latestThoughtCache = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DB_NAME = "digital_human_memory.db"
|
||||||
|
private const val DB_VERSION = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user