Commit 1f08686ec09cdde48678fde1c5353f7bbabbb61b

Authored by yibo
1 parent 76b3421b

feat(lifelog): 统一「设置」tab — 集中所有可调参数

新增第 6 个 tab「设置」(SettingsScreen.kt),把散落各处的配置收进一页:
- 纪要:会话间隔 / 自动跑周期(改完即重排 WorkManager) / 会话最短语音 / 后端模型(2B/4B/Hermes)
- 声纹:聚类阈值(0.45–0.65)
- 本地模型端点:baseUrl / 2B / 4B model 名(ChatConfig)
- 录音/电池:白名单状态 + 申请

配套把写死的参数改成可配:
- SummaryWorker:KEY_PERIOD_MIN / KEY_MIN_SPEECH_SEC,min-speech 从 prefs 读
- SummaryScheduler:周期从 prefs 读(默认 15,硬下限 15)
- SpeakerClusterer:thresholdFromPrefs(),RecorderService 的 live 聚类器 +
  SpeakersTab 的 reclusterAll 都用 prefs 阈值

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize
25 25 import androidx.compose.foundation.layout.fillMaxWidth
26 26 import androidx.compose.foundation.layout.height
27 27 import androidx.compose.foundation.layout.heightIn
  28 +import androidx.compose.foundation.layout.imePadding
28 29 import androidx.compose.foundation.layout.padding
29 30 import androidx.compose.foundation.layout.size
30 31 import androidx.compose.foundation.layout.width
... ... @@ -104,6 +105,9 @@ import kotlinx.coroutines.withContext
104 105 class MainActivity : ComponentActivity() {
105 106 override fun onCreate(savedInstanceState: Bundle?) {
106 107 super.onCreate(savedInstanceState)
  108 + // edge-to-edge:让 IME / 系统栏 insets 传到 Compose(Android 15 targetSdk35 起必须),
  109 + // 否则键盘弹出会盖住聊天内容。Scaffold contentWindowInsets 默认 systemBars,会补回状态栏/导航栏 padding。
  110 + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
107 111 setContent {
108 112 MaterialTheme {
109 113 Surface(modifier = Modifier.fillMaxSize()) {
... ... @@ -120,6 +124,7 @@ private enum class Tab(val label: String, val icon: String) {
120 124 Timeline("时间轴", "≣"),
121 125 Speakers("声纹", "👤"),
122 126 Summaries("纪要", "✎"),
  127 + Settings("设置", "⚙"),
123 128 }
124 129  
125 130 enum class SortMode(val label: String) {
... ... @@ -149,6 +154,8 @@ private fun MainScreen() {
149 154 val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList())
150 155  
151 156 Scaffold(
  157 + // imePadding:键盘弹出时整个 Scaffold(含底部 5 个 tab)抬到键盘上方,tab 仍可见可点。
  158 + modifier = Modifier.imePadding(),
152 159 bottomBar = {
153 160 NavigationBar {
154 161 Tab.entries.forEach { t ->
... ... @@ -191,6 +198,7 @@ private fun MainScreen() {
191 198 tab = Tab.Timeline
192 199 },
193 200 )
  201 + Tab.Settings -> SettingsScreen(activity)
194 202 }
195 203 }
196 204 }
... ... @@ -707,6 +715,7 @@ private fun SpeakersTab(
707 715 onFilterBySpeaker: (Long) -> Unit,
708 716 ) {
709 717 val scope = rememberCoroutineScope()
  718 + val ctx = LocalContext.current
710 719 val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList())
711 720 val countMap = remember(counts) { counts.associate { it.speakerId to it.count } }
712 721 var reclustering by remember { mutableStateOf(false) }
... ... @@ -724,7 +733,9 @@ private fun SpeakersTab(
724 733 onClick = {
725 734 reclustering = true
726 735 scope.launch {
727   - val n = withContext(Dispatchers.IO) { SpeakerClusterer.reclusterAll(repo) }
  736 + val n = withContext(Dispatchers.IO) {
  737 + SpeakerClusterer.reclusterAll(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx))
  738 + }
728 739 reclustering = false
729 740 reclusterMsg = "重新聚类完成:$n 个说话人"
730 741 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt 0 → 100644
  1 +package com.lemon.lifelog
  2 +
  3 +import android.annotation.SuppressLint
  4 +import android.content.Context
  5 +import android.content.Intent
  6 +import android.net.Uri
  7 +import android.os.PowerManager
  8 +import android.provider.Settings
  9 +import androidx.compose.foundation.layout.Arrangement
  10 +import androidx.compose.foundation.layout.Box
  11 +import androidx.compose.foundation.layout.Column
  12 +import androidx.compose.foundation.layout.Row
  13 +import androidx.compose.foundation.layout.Spacer
  14 +import androidx.compose.foundation.layout.fillMaxSize
  15 +import androidx.compose.foundation.layout.fillMaxWidth
  16 +import androidx.compose.foundation.layout.height
  17 +import androidx.compose.foundation.layout.padding
  18 +import androidx.compose.foundation.rememberScrollState
  19 +import androidx.compose.foundation.verticalScroll
  20 +import androidx.compose.material3.Button
  21 +import androidx.compose.material3.DropdownMenu
  22 +import androidx.compose.material3.DropdownMenuItem
  23 +import androidx.compose.material3.ExperimentalMaterial3Api
  24 +import androidx.compose.material3.FilterChip
  25 +import androidx.compose.material3.HorizontalDivider
  26 +import androidx.compose.material3.MaterialTheme
  27 +import androidx.compose.material3.OutlinedTextField
  28 +import androidx.compose.material3.Text
  29 +import androidx.compose.runtime.Composable
  30 +import androidx.compose.runtime.getValue
  31 +import androidx.compose.runtime.mutableStateOf
  32 +import androidx.compose.runtime.remember
  33 +import androidx.compose.runtime.setValue
  34 +import androidx.compose.ui.Alignment
  35 +import androidx.compose.ui.Modifier
  36 +import androidx.compose.ui.unit.dp
  37 +import com.lemon.lifelog.asr.SpeakerClusterer
  38 +import com.lemon.lifelog.chat.ChatConfig
  39 +import com.lemon.lifelog.summary.SummaryScheduler
  40 +import com.lemon.lifelog.summary.SummaryWorker
  41 +
  42 +/** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */
  43 +@OptIn(ExperimentalMaterial3Api::class)
  44 +@Composable
  45 +fun SettingsScreen(activity: Context) {
  46 + val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) }
  47 +
  48 + var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) }
  49 + var periodMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN)) }
  50 + var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) }
  51 + var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) }
  52 + var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) }
  53 + val backendTargets = remember { ChatConfig.targets(activity) }
  54 +
  55 + var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) }
  56 + var m2b by remember { mutableStateOf(ChatConfig.model2b(activity)) }
  57 + var m4b by remember { mutableStateOf(ChatConfig.model4b(activity)) }
  58 + var endpointMsg by remember { mutableStateOf("") }
  59 +
  60 + var batteryExempt by remember { mutableStateOf(isBatteryExempt(activity)) }
  61 +
  62 + Column(
  63 + modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
  64 + verticalArrangement = Arrangement.spacedBy(10.dp),
  65 + ) {
  66 + Text("设置", style = MaterialTheme.typography.headlineSmall)
  67 +
  68 + Text("纪要", style = MaterialTheme.typography.titleMedium)
  69 + DropdownSetting("会话间隔(多久静音算另一场)", "$gapMin 分钟",
  70 + listOf(2, 5, 10, 15, 20, 30).map { "$it 分钟" to it }) {
  71 + gapMin = it; prefs.edit().putInt(SummaryWorker.KEY_GAP_MIN, it).apply()
  72 + }
  73 + DropdownSetting("自动跑纪要周期(≥15min 硬下限)", "$periodMin 分钟",
  74 + listOf(15, 30, 60).map { "$it 分钟" to it }) {
  75 + periodMin = it
  76 + prefs.edit().putInt(SummaryWorker.KEY_PERIOD_MIN, it).apply()
  77 + SummaryScheduler.schedulePeriodic(activity) // 立即重排
  78 + }
  79 + DropdownSetting("会话最短语音(短于此跳过)", "$minSpeech 秒",
  80 + listOf(10, 30, 60, 120).map { "$it 秒" to it }) {
  81 + minSpeech = it; prefs.edit().putInt(SummaryWorker.KEY_MIN_SPEECH_SEC, it).apply()
  82 + }
  83 + DropdownSetting("纪要模型后端", backendTargets.firstOrNull { it.id == backendId }?.label ?: "本地 2B",
  84 + backendTargets.map { it.label to it.id }) {
  85 + backendId = it; prefs.edit().putString(SummaryWorker.KEY_BACKEND, it).apply()
  86 + }
  87 +
  88 + HorizontalDivider()
  89 + Text("声纹", style = MaterialTheme.typography.titleMedium)
  90 + DropdownSetting("聚类阈值(越大分越细)", "%.2f".format(threshold),
  91 + listOf(0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) {
  92 + threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply()
  93 + }
  94 + Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。",
  95 + style = MaterialTheme.typography.labelSmall)
  96 +
  97 + HorizontalDivider()
  98 + Text("本地模型端点(对话 + 纪要共用 llama-swap)", style = MaterialTheme.typography.titleMedium)
  99 + OutlinedTextField(value = localBase, onValueChange = { localBase = it },
  100 + label = { Text("baseUrl") }, singleLine = true, modifier = Modifier.fillMaxWidth())
  101 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  102 + OutlinedTextField(value = m2b, onValueChange = { m2b = it },
  103 + label = { Text("2B model 名") }, singleLine = true, modifier = Modifier.weight(1f))
  104 + OutlinedTextField(value = m4b, onValueChange = { m4b = it },
  105 + label = { Text("4B model 名") }, singleLine = true, modifier = Modifier.weight(1f))
  106 + }
  107 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  108 + Button(onClick = {
  109 + ChatConfig.save(activity, localBase, m2b, m4b)
  110 + endpointMsg = "已保存"
  111 + }) { Text("保存端点") }
  112 + if (endpointMsg.isNotEmpty()) Text(endpointMsg, style = MaterialTheme.typography.labelSmall)
  113 + }
  114 +
  115 + HorizontalDivider()
  116 + Text("录音 / 电池", style = MaterialTheme.typography.titleMedium)
  117 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  118 + Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)",
  119 + style = MaterialTheme.typography.bodySmall)
  120 + if (!batteryExempt) {
  121 + Button(onClick = {
  122 + requestBatteryExempt(activity)
  123 + batteryExempt = isBatteryExempt(activity)
  124 + }) { Text("申请豁免") }
  125 + }
  126 + }
  127 +
  128 + Spacer(Modifier.height(24.dp))
  129 + }
  130 +}
  131 +
  132 +@OptIn(ExperimentalMaterial3Api::class)
  133 +@Composable
  134 +private fun <T> DropdownSetting(
  135 + label: String,
  136 + current: String,
  137 + options: List<Pair<String, T>>,
  138 + onPick: (T) -> Unit,
  139 +) {
  140 + var open by remember { mutableStateOf(false) }
  141 + Row(
  142 + modifier = Modifier.fillMaxWidth(),
  143 + verticalAlignment = Alignment.CenterVertically,
  144 + horizontalArrangement = Arrangement.spacedBy(8.dp),
  145 + ) {
  146 + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
  147 + Box {
  148 + FilterChip(selected = true, onClick = { open = true }, label = { Text(current) })
  149 + DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
  150 + options.forEach { (lbl, value) ->
  151 + DropdownMenuItem(text = { Text(lbl) }, onClick = { onPick(value); open = false })
  152 + }
  153 + }
  154 + }
  155 + }
  156 +}
  157 +
  158 +private fun isBatteryExempt(ctx: Context): Boolean {
  159 + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
  160 + return pm.isIgnoringBatteryOptimizations(ctx.packageName)
  161 +}
  162 +
  163 +@SuppressLint("BatteryLife")
  164 +private fun requestBatteryExempt(ctx: Context) {
  165 + runCatching {
  166 + ctx.startActivity(
  167 + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
  168 + data = Uri.parse("package:${ctx.packageName}")
  169 + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
  170 + },
  171 + )
  172 + }
  173 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
... ... @@ -97,10 +97,18 @@ class SpeakerClusterer(
97 97 /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */
98 98 const val AUTO_PREFIX = "说话人"
99 99  
100   - /** CAMPPlus 余弦阈值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并
101   - * (4B 测试时「说话人4」吃了 23 段),收紧到 0.55 分得更细。再细可上 0.6。 */
  100 + /** CAMPPlus 余弦阈值默认值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并
  101 + * (4B 测试时「说话人4」吃了 23 段),0.55 分得更细。设置页可调,再细可上 0.6。 */
102 102 const val MATCH_THRESHOLD = 0.55f
103 103  
  104 + /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */
  105 + const val PREFS = "lifelog_prefs"
  106 + const val KEY_THRESHOLD = "speaker_threshold"
  107 +
  108 + fun thresholdFromPrefs(ctx: android.content.Context): Float =
  109 + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
  110 + .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD)
  111 +
104 112 /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */
105 113 const val MIN_NEW_CLUSTER_MS = 1000L
106 114  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt
... ... @@ -202,7 +202,7 @@ class RecorderService : Service() {
202 202 }
203 203  
204 204 // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles).
205   - clusterer = SpeakerClusterer(repo).also { it.load() }
  205 + clusterer = SpeakerClusterer(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(this)).also { it.load() }
206 206 // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer.
207 207 // load() is mutex-serialised vs assign() and idempotent, so reloading on every
208 208 // speaker_profiles change (incl. the clusterer's own write-throughs) is safe.
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt
... ... @@ -20,9 +20,11 @@ object SummaryScheduler {
20 20 .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // 后端都在本机
21 21 .setRequiresBatteryNotLow(true)
22 22 .build()
23   - // 15 分钟是 WorkManager periodic 的硬下限(做不到正好 10min)。会话关闭后
24   - // 最多 ~15min 出纪要。worker 自带并发互斥 + 时间预算,频繁触发也不会堆积。
25   - val request = PeriodicWorkRequestBuilder<SummaryWorker>(15, TimeUnit.MINUTES)
  23 + // 周期从设置页读(默认 15min);WorkManager periodic 硬下限 15,做不到更短。
  24 + val periodMin = ctx.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE)
  25 + .getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN)
  26 + .coerceAtLeast(15)
  27 + val request = PeriodicWorkRequestBuilder<SummaryWorker>(periodMin.toLong(), TimeUnit.MINUTES)
26 28 .setConstraints(constraints)
27 29 .build()
28 30 WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
... ... @@ -51,6 +51,14 @@ class SummaryWorker(
51 51 * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */
52 52 const val KEY_BACKEND = "summary_backend"
53 53  
  54 + /** 自动跑纪要周期(分钟)。WorkManager periodic 硬下限 15。 */
  55 + const val KEY_PERIOD_MIN = "summary_period_min"
  56 + const val DEFAULT_PERIOD_MIN = 15
  57 +
  58 + /** 会话最短语音(秒):低于此跳过不总结。 */
  59 + const val KEY_MIN_SPEECH_SEC = "summary_min_speech_sec"
  60 + const val DEFAULT_MIN_SPEECH_SEC = 30
  61 +
54 62 /** 防并发:periodic + one-shot + RETRY 可能同时触发,多个 worker 并发打同一个
55 63 * llama-server 会把上游搞 502(实测)。进程内 flag 保证同时只跑一个 worker body。 */
56 64 private val isRunning = java.util.concurrent.atomic.AtomicBoolean(false)
... ... @@ -95,6 +103,7 @@ class SummaryWorker(
95 103 val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
96 104 val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 }
97 105 ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L)
  106 + val minSpeechMs = prefs.getInt(KEY_MIN_SPEECH_SEC, DEFAULT_MIN_SPEECH_SEC) * 1000L
98 107  
99 108 // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。
100 109 val target = com.lemon.lifelog.chat.ChatConfig.targetById(
... ... @@ -127,7 +136,7 @@ class SummaryWorker(
127 136 // 未结束的会话不动(可能还会有新段并入)。
128 137 if (now - end <= gapMs) { skipped++; continue }
129 138 // 太短跳过。
130   - if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) { skipped++; continue }
  139 + if (session.sumOf { it.durationMs } < minSpeechMs) { skipped++; continue }
131 140 // 已总结过(时间范围重叠)跳过。
132 141 if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue }
133 142  
... ...