Settings: pick the summarizer (LAN H200 / laptop Ollama / auto); waiting label shows elapsed wait
This commit is contained in:
parent
8e465bdb93
commit
d66c27463c
4 changed files with 153 additions and 17 deletions
22
STATUS.md
22
STATUS.md
|
|
@ -3,6 +3,28 @@
|
|||
Checkpoint for the standalone desktop app. Prepend a new dated section per
|
||||
material change; keep history below.
|
||||
|
||||
## 2026-09-15 (evening) — summarize progress bar, job adoption
|
||||
|
||||
**Committed:** `7fa99e8` streaming summarize progress, `ba54cb8` job adoption.
|
||||
|
||||
- Summarize now shows a real determinate bar + `Summarizing… N%` (same look
|
||||
as the main screen). Backend: both LLM adapters stream (SSE / NDJSON) and
|
||||
report 0–99% as tokens arrive; 100 only on success. No fake movement —
|
||||
before the first token the label reads "Summarizing… waiting for model"
|
||||
(cold LLM / server queueing was indistinguishable from a dead app).
|
||||
- Adoption of engine jobs across app restarts: startup scan no longer skips
|
||||
files that have a report (re-summarize was orphaned), a reprocess that
|
||||
hits 409 "already running" attaches to the running job instead of
|
||||
erroring, and live work outranks a saved report in library status.
|
||||
- Verified live: screencap 19:41 — library row shows the bar + waiting
|
||||
label mid re-summarize; job settled to succeeded 100 with the % racing
|
||||
48→99→100 once tokens flowed (H200 queueing caused the earlier "nothing
|
||||
happens"). pytest 79 passed/1 skipped · ruff clean · gradle green.
|
||||
- Note: the dev loop died once (~19:35, cause unclear — gradle `run` exit
|
||||
143 chain); relaunched via background `./desktop-dev.sh`.
|
||||
|
||||
**Done earlier (Sep 15 morning):** see below.
|
||||
|
||||
## 2026-09-15 — standalone desktop, Android fully removed
|
||||
|
||||
**Verified state (live this session):**
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -539,6 +540,15 @@ fun DetailScreen(state: DesktopState) {
|
|||
var recQuery by remember { mutableStateOf("") }
|
||||
// Voice for the next summarize: null = Neutral (server default).
|
||||
var sumTone by remember(detail.file.name) { mutableStateOf<String?>(null) }
|
||||
// Transcript edit mode: draft holds the working text (null = viewing).
|
||||
// Keyed on file AND transcript version: switching recordings or a
|
||||
// successful save (version bump) drops the draft back to viewing.
|
||||
var editDraft by remember(detail.file.name, detail.transcript?.version) {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
var savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
|
||||
// The save (or its failure) clears busy — release the button lock.
|
||||
LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false }
|
||||
val playingFile by state.playingFile.collectAsState()
|
||||
val playError by state.playError.collectAsState()
|
||||
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
|
||||
|
|
@ -697,7 +707,8 @@ fun DetailScreen(state: DesktopState) {
|
|||
val label = when {
|
||||
it.status == "running" && it.progress != null ->
|
||||
"Summarizing… ${it.progress}%"
|
||||
it.status == "running" -> "Summarizing… waiting for model"
|
||||
it.status == "running" -> "Summarizing… waiting for model" +
|
||||
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
|
||||
else -> "Summary queued"
|
||||
}
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium,
|
||||
|
|
@ -727,22 +738,66 @@ fun DetailScreen(state: DesktopState) {
|
|||
Button({ state.transcribe() }) { Text("Transcribe") }
|
||||
}
|
||||
detail.transcript?.let { t ->
|
||||
Text("Transcript", style = MaterialTheme.typography.titleMedium)
|
||||
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (t.segments.isNotEmpty()) {
|
||||
items(t.segments.size) { i ->
|
||||
val s = t.segments[i]
|
||||
Text("[${fmtTs(s.startSec)}] ${s.text}",
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
} else {
|
||||
item { Text(t.text) }
|
||||
val remoteKnownForEdit = state.remoteIdFor(detail.file) != null
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Transcript", style = MaterialTheme.typography.titleMedium)
|
||||
if (t.editedByUser) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("· edited by you",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (editDraft == null && remoteKnownForEdit && detail.busy == null) {
|
||||
TextButton({
|
||||
editDraft = if (t.segments.isNotEmpty())
|
||||
t.segments.joinToString("\n") { it.text } else t.text
|
||||
}) { Text("Edit") }
|
||||
}
|
||||
}
|
||||
Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val draft = editDraft
|
||||
if (draft == null) {
|
||||
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (t.segments.isNotEmpty()) {
|
||||
items(t.segments.size) { i ->
|
||||
val s = t.segments[i]
|
||||
Text("[${fmtTs(s.startSec)}] ${s.text}",
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
} else {
|
||||
item { Text(t.text) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { editDraft = it },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
enabled = draft.isNotBlank() && !savingTranscript,
|
||||
onClick = {
|
||||
savingTranscript = true
|
||||
state.saveTranscriptEdit(draft)
|
||||
},
|
||||
) { Text(if (savingTranscript) "Saving…" else "Save") }
|
||||
OutlinedButton(
|
||||
enabled = !savingTranscript,
|
||||
onClick = { editDraft = null },
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
Text("Your corrected words are saved as the official version — " +
|
||||
"re-transcribing later will not overwrite them.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (editDraft == null) {
|
||||
Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
detail.summary?.let { s ->
|
||||
if (s.short.isNotBlank()) {
|
||||
|
|
@ -833,7 +888,9 @@ fun DetailScreen(state: DesktopState) {
|
|||
append(when {
|
||||
sStatus == "queued" -> "Queued…"
|
||||
sProg != null && sProg > 0 -> "Summarizing… $sProg%"
|
||||
else -> "Summarizing… waiting for model"
|
||||
else -> "Summarizing… waiting for model" +
|
||||
DesktopState.waitSuffix(sJob?.startedAt,
|
||||
System.currentTimeMillis())
|
||||
})
|
||||
sumTone?.let { append(" · $it") }
|
||||
if (sAttempt > 0)
|
||||
|
|
@ -917,6 +974,7 @@ fun SettingsScreen(state: DesktopState) {
|
|||
val modelsError = state.modelsError.collectAsState().value
|
||||
val url by state.serverUrl.collectAsState()
|
||||
val folder by state.folder.collectAsState()
|
||||
val summarizer by state.summarizer.collectAsState()
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// Root is scrollable: in split-screen/short windows the model cards
|
||||
|
|
@ -941,6 +999,33 @@ fun SettingsScreen(state: DesktopState) {
|
|||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Summarizer", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Where summaries are written. Restarting the engine applies " +
|
||||
"the choice; work already running carries over.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = summarizer == "auto",
|
||||
onClick = { state.setSummarizer("auto") },
|
||||
label = { Text("Auto") })
|
||||
FilterChip(selected = summarizer == "lan",
|
||||
onClick = { state.setSummarizer("lan") },
|
||||
label = { Text("LAN server (H200)") })
|
||||
FilterChip(selected = summarizer == "local",
|
||||
onClick = { state.setSummarizer("local") },
|
||||
label = { Text("This laptop (Ollama)") })
|
||||
}
|
||||
Text(when (summarizer) {
|
||||
"lan" -> "Best quality; shares the H200 with other clients — " +
|
||||
"summaries may wait in its queue before starting."
|
||||
"local" -> "Private and never queued behind other users; " +
|
||||
"smaller model, so summaries are plainer."
|
||||
else -> "Uses the LAN server when its key is available, " +
|
||||
"otherwise this laptop's Ollama."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Default transcription model", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Applies to future transcriptions. Each recording keeps the model it used.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import org.junit.Test
|
|||
|
||||
class JobProgressTest {
|
||||
|
||||
private fun job(type: String, status: String, stage: String? = null, progress: Int? = null) =
|
||||
JobInfo(jobType = type, status = status, stage = stage, progress = progress)
|
||||
private fun job(type: String, status: String, stage: String? = null, progress: Int? = null,
|
||||
startedIso: String? = null) =
|
||||
JobInfo(jobType = type, status = status, stage = stage, progress = progress,
|
||||
startedAt = startedIso)
|
||||
|
||||
@Test fun `idle when nothing running`() {
|
||||
assertNull(DesktopState.jobProgress(emptyList()))
|
||||
|
|
@ -55,4 +57,28 @@ class JobProgressTest {
|
|||
assertEquals("summarizing… 37%", p?.label)
|
||||
assertEquals(0.37f, p!!.fraction!!, 0.0001f)
|
||||
}
|
||||
|
||||
@Test fun `waiting label carries elapsed time`() {
|
||||
// Second-exact base: LocalDateTime.toString drops trailing zeros,
|
||||
// so fractional drift would round the label to 1:22.
|
||||
val t0 = System.currentTimeMillis() / 1000 * 1000
|
||||
val iso = java.time.LocalDateTime.ofInstant(
|
||||
java.time.Instant.ofEpochMilli(t0 - 83_000), java.time.ZoneOffset.UTC)
|
||||
.withNano(0).toString()
|
||||
val p = DesktopState.jobProgress(
|
||||
listOf(job("summarize", "running", startedIso = iso)), nowMs = t0)
|
||||
assertEquals("summarizing… waiting for model · 1:23", p?.label)
|
||||
}
|
||||
|
||||
@Test fun `waitSuffix formats and ignores unknowns`() {
|
||||
val t = java.time.LocalDateTime.of(2026, 9, 16, 15, 0, 0)
|
||||
val now = t.toInstant(java.time.ZoneOffset.UTC).toEpochMilli()
|
||||
assertEquals("", DesktopState.waitSuffix(null, now))
|
||||
assertEquals("", DesktopState.waitSuffix("garbage", now))
|
||||
assertEquals("", DesktopState.waitSuffix(t.minusSeconds(3).toString(), now))
|
||||
assertEquals(" · 42s", DesktopState.waitSuffix(t.minusSeconds(42).toString(), now))
|
||||
assertEquals(" · 5:01", DesktopState.waitSuffix(t.minusSeconds(301).toString(), now))
|
||||
assertEquals(" · 1:02", DesktopState.waitSuffix(
|
||||
t.minusSeconds(62).toString() + "Z", now))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ data class JobInfo(
|
|||
/** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */
|
||||
val stage: String? = null,
|
||||
val progress: Int? = null,
|
||||
/** ISO-8601 UTC when the job started running (null if unknown). */
|
||||
val startedAt: String? = null,
|
||||
)
|
||||
|
||||
/** Summary content keys, in display order. */
|
||||
|
|
@ -132,6 +134,7 @@ fun parseJobs(raw: String): List<JobInfo> = runCatching {
|
|||
error = o.optString("error", null).takeUnless { it.isNullOrBlank() },
|
||||
stage = o.optString("stage", null).takeUnless { it.isNullOrBlank() },
|
||||
progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null,
|
||||
startedAt = o.optString("started_at", null).takeUnless { it.isNullOrBlank() },
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue