Commit 1f08686ec09cdde48678fde1c5353f7bbabbb61b
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>
Showing
6 changed files
with
211 additions
and
8 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize | @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize | ||
| 25 | import androidx.compose.foundation.layout.fillMaxWidth | 25 | import androidx.compose.foundation.layout.fillMaxWidth |
| 26 | import androidx.compose.foundation.layout.height | 26 | import androidx.compose.foundation.layout.height |
| 27 | import androidx.compose.foundation.layout.heightIn | 27 | import androidx.compose.foundation.layout.heightIn |
| 28 | +import androidx.compose.foundation.layout.imePadding | ||
| 28 | import androidx.compose.foundation.layout.padding | 29 | import androidx.compose.foundation.layout.padding |
| 29 | import androidx.compose.foundation.layout.size | 30 | import androidx.compose.foundation.layout.size |
| 30 | import androidx.compose.foundation.layout.width | 31 | import androidx.compose.foundation.layout.width |
| @@ -104,6 +105,9 @@ import kotlinx.coroutines.withContext | @@ -104,6 +105,9 @@ import kotlinx.coroutines.withContext | ||
| 104 | class MainActivity : ComponentActivity() { | 105 | class MainActivity : ComponentActivity() { |
| 105 | override fun onCreate(savedInstanceState: Bundle?) { | 106 | override fun onCreate(savedInstanceState: Bundle?) { |
| 106 | super.onCreate(savedInstanceState) | 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 | setContent { | 111 | setContent { |
| 108 | MaterialTheme { | 112 | MaterialTheme { |
| 109 | Surface(modifier = Modifier.fillMaxSize()) { | 113 | Surface(modifier = Modifier.fillMaxSize()) { |
| @@ -120,6 +124,7 @@ private enum class Tab(val label: String, val icon: String) { | @@ -120,6 +124,7 @@ private enum class Tab(val label: String, val icon: String) { | ||
| 120 | Timeline("时间轴", "≣"), | 124 | Timeline("时间轴", "≣"), |
| 121 | Speakers("声纹", "👤"), | 125 | Speakers("声纹", "👤"), |
| 122 | Summaries("纪要", "✎"), | 126 | Summaries("纪要", "✎"), |
| 127 | + Settings("设置", "⚙"), | ||
| 123 | } | 128 | } |
| 124 | 129 | ||
| 125 | enum class SortMode(val label: String) { | 130 | enum class SortMode(val label: String) { |
| @@ -149,6 +154,8 @@ private fun MainScreen() { | @@ -149,6 +154,8 @@ private fun MainScreen() { | ||
| 149 | val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) | 154 | val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) |
| 150 | 155 | ||
| 151 | Scaffold( | 156 | Scaffold( |
| 157 | + // imePadding:键盘弹出时整个 Scaffold(含底部 5 个 tab)抬到键盘上方,tab 仍可见可点。 | ||
| 158 | + modifier = Modifier.imePadding(), | ||
| 152 | bottomBar = { | 159 | bottomBar = { |
| 153 | NavigationBar { | 160 | NavigationBar { |
| 154 | Tab.entries.forEach { t -> | 161 | Tab.entries.forEach { t -> |
| @@ -191,6 +198,7 @@ private fun MainScreen() { | @@ -191,6 +198,7 @@ private fun MainScreen() { | ||
| 191 | tab = Tab.Timeline | 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,6 +715,7 @@ private fun SpeakersTab( | ||
| 707 | onFilterBySpeaker: (Long) -> Unit, | 715 | onFilterBySpeaker: (Long) -> Unit, |
| 708 | ) { | 716 | ) { |
| 709 | val scope = rememberCoroutineScope() | 717 | val scope = rememberCoroutineScope() |
| 718 | + val ctx = LocalContext.current | ||
| 710 | val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) | 719 | val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) |
| 711 | val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } | 720 | val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } |
| 712 | var reclustering by remember { mutableStateOf(false) } | 721 | var reclustering by remember { mutableStateOf(false) } |
| @@ -724,7 +733,9 @@ private fun SpeakersTab( | @@ -724,7 +733,9 @@ private fun SpeakersTab( | ||
| 724 | onClick = { | 733 | onClick = { |
| 725 | reclustering = true | 734 | reclustering = true |
| 726 | scope.launch { | 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 | reclustering = false | 739 | reclustering = false |
| 729 | reclusterMsg = "重新聚类完成:$n 个说话人" | 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,10 +97,18 @@ class SpeakerClusterer( | ||
| 97 | /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */ | 97 | /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */ |
| 98 | const val AUTO_PREFIX = "说话人" | 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 | const val MATCH_THRESHOLD = 0.55f | 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 | /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ | 112 | /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ |
| 105 | const val MIN_NEW_CLUSTER_MS = 1000L | 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,7 +202,7 @@ class RecorderService : Service() { | ||
| 202 | } | 202 | } |
| 203 | 203 | ||
| 204 | // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles). | 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 | // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer. | 206 | // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer. |
| 207 | // load() is mutex-serialised vs assign() and idempotent, so reloading on every | 207 | // load() is mutex-serialised vs assign() and idempotent, so reloading on every |
| 208 | // speaker_profiles change (incl. the clusterer's own write-throughs) is safe. | 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,9 +20,11 @@ object SummaryScheduler { | ||
| 20 | .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // 后端都在本机 | 20 | .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // 后端都在本机 |
| 21 | .setRequiresBatteryNotLow(true) | 21 | .setRequiresBatteryNotLow(true) |
| 22 | .build() | 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 | .setConstraints(constraints) | 28 | .setConstraints(constraints) |
| 27 | .build() | 29 | .build() |
| 28 | WorkManager.getInstance(ctx).enqueueUniquePeriodicWork( | 30 | WorkManager.getInstance(ctx).enqueueUniquePeriodicWork( |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
| @@ -51,6 +51,14 @@ class SummaryWorker( | @@ -51,6 +51,14 @@ class SummaryWorker( | ||
| 51 | * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */ | 51 | * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */ |
| 52 | const val KEY_BACKEND = "summary_backend" | 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 | /** 防并发:periodic + one-shot + RETRY 可能同时触发,多个 worker 并发打同一个 | 62 | /** 防并发:periodic + one-shot + RETRY 可能同时触发,多个 worker 并发打同一个 |
| 55 | * llama-server 会把上游搞 502(实测)。进程内 flag 保证同时只跑一个 worker body。 */ | 63 | * llama-server 会把上游搞 502(实测)。进程内 flag 保证同时只跑一个 worker body。 */ |
| 56 | private val isRunning = java.util.concurrent.atomic.AtomicBoolean(false) | 64 | private val isRunning = java.util.concurrent.atomic.AtomicBoolean(false) |
| @@ -95,6 +103,7 @@ class SummaryWorker( | @@ -95,6 +103,7 @@ class SummaryWorker( | ||
| 95 | val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) | 103 | val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) |
| 96 | val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } | 104 | val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } |
| 97 | ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L) | 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 | // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。 | 108 | // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。 |
| 100 | val target = com.lemon.lifelog.chat.ChatConfig.targetById( | 109 | val target = com.lemon.lifelog.chat.ChatConfig.targetById( |
| @@ -127,7 +136,7 @@ class SummaryWorker( | @@ -127,7 +136,7 @@ class SummaryWorker( | ||
| 127 | // 未结束的会话不动(可能还会有新段并入)。 | 136 | // 未结束的会话不动(可能还会有新段并入)。 |
| 128 | if (now - end <= gapMs) { skipped++; continue } | 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 | if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue } | 141 | if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue } |
| 133 | 142 |