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
|
Checkpoint for the standalone desktop app. Prepend a new dated section per
|
||||||
material change; keep history below.
|
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
|
## 2026-09-15 — standalone desktop, Android fully removed
|
||||||
|
|
||||||
**Verified state (live this session):**
|
**Verified state (live this session):**
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -539,6 +540,15 @@ fun DetailScreen(state: DesktopState) {
|
||||||
var recQuery by remember { mutableStateOf("") }
|
var recQuery by remember { mutableStateOf("") }
|
||||||
// Voice for the next summarize: null = Neutral (server default).
|
// Voice for the next summarize: null = Neutral (server default).
|
||||||
var sumTone by remember(detail.file.name) { mutableStateOf<String?>(null) }
|
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 playingFile by state.playingFile.collectAsState()
|
||||||
val playError by state.playError.collectAsState()
|
val playError by state.playError.collectAsState()
|
||||||
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
|
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
|
||||||
|
|
@ -697,7 +707,8 @@ fun DetailScreen(state: DesktopState) {
|
||||||
val label = when {
|
val label = when {
|
||||||
it.status == "running" && it.progress != null ->
|
it.status == "running" && it.progress != null ->
|
||||||
"Summarizing… ${it.progress}%"
|
"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"
|
else -> "Summary queued"
|
||||||
}
|
}
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium,
|
Text(label, style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|
@ -727,22 +738,66 @@ fun DetailScreen(state: DesktopState) {
|
||||||
Button({ state.transcribe() }) { Text("Transcribe") }
|
Button({ state.transcribe() }) { Text("Transcribe") }
|
||||||
}
|
}
|
||||||
detail.transcript?.let { t ->
|
detail.transcript?.let { t ->
|
||||||
Text("Transcript", style = MaterialTheme.typography.titleMedium)
|
val remoteKnownForEdit = state.remoteIdFor(detail.file) != null
|
||||||
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Text("Transcript", style = MaterialTheme.typography.titleMedium)
|
||||||
if (t.segments.isNotEmpty()) {
|
if (t.editedByUser) {
|
||||||
items(t.segments.size) { i ->
|
Spacer(Modifier.width(8.dp))
|
||||||
val s = t.segments[i]
|
Text("· edited by you",
|
||||||
Text("[${fmtTs(s.startSec)}] ${s.text}",
|
style = MaterialTheme.typography.labelMedium,
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
color = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
} else {
|
Spacer(Modifier.weight(1f))
|
||||||
item { Text(t.text) }
|
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",
|
val draft = editDraft
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (draft == null) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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 ->
|
detail.summary?.let { s ->
|
||||||
if (s.short.isNotBlank()) {
|
if (s.short.isNotBlank()) {
|
||||||
|
|
@ -833,7 +888,9 @@ fun DetailScreen(state: DesktopState) {
|
||||||
append(when {
|
append(when {
|
||||||
sStatus == "queued" -> "Queued…"
|
sStatus == "queued" -> "Queued…"
|
||||||
sProg != null && sProg > 0 -> "Summarizing… $sProg%"
|
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") }
|
sumTone?.let { append(" · $it") }
|
||||||
if (sAttempt > 0)
|
if (sAttempt > 0)
|
||||||
|
|
@ -917,6 +974,7 @@ fun SettingsScreen(state: DesktopState) {
|
||||||
val modelsError = state.modelsError.collectAsState().value
|
val modelsError = state.modelsError.collectAsState().value
|
||||||
val url by state.serverUrl.collectAsState()
|
val url by state.serverUrl.collectAsState()
|
||||||
val folder by state.folder.collectAsState()
|
val folder by state.folder.collectAsState()
|
||||||
|
val summarizer by state.summarizer.collectAsState()
|
||||||
var showPicker by remember { mutableStateOf(false) }
|
var showPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Root is scrollable: in split-screen/short windows the model cards
|
// Root is scrollable: in split-screen/short windows the model cards
|
||||||
|
|
@ -941,6 +999,33 @@ fun SettingsScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
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("Default transcription model", style = MaterialTheme.typography.titleMedium)
|
||||||
Text("Applies to future transcriptions. Each recording keeps the model it used.",
|
Text("Applies to future transcriptions. Each recording keeps the model it used.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import org.junit.Test
|
||||||
|
|
||||||
class JobProgressTest {
|
class JobProgressTest {
|
||||||
|
|
||||||
private fun job(type: String, status: String, stage: String? = null, progress: Int? = null) =
|
private fun job(type: String, status: String, stage: String? = null, progress: Int? = null,
|
||||||
JobInfo(jobType = type, status = status, stage = stage, progress = progress)
|
startedIso: String? = null) =
|
||||||
|
JobInfo(jobType = type, status = status, stage = stage, progress = progress,
|
||||||
|
startedAt = startedIso)
|
||||||
|
|
||||||
@Test fun `idle when nothing running`() {
|
@Test fun `idle when nothing running`() {
|
||||||
assertNull(DesktopState.jobProgress(emptyList()))
|
assertNull(DesktopState.jobProgress(emptyList()))
|
||||||
|
|
@ -55,4 +57,28 @@ class JobProgressTest {
|
||||||
assertEquals("summarizing… 37%", p?.label)
|
assertEquals("summarizing… 37%", p?.label)
|
||||||
assertEquals(0.37f, p!!.fraction!!, 0.0001f)
|
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. */
|
/** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */
|
||||||
val stage: String? = null,
|
val stage: String? = null,
|
||||||
val progress: Int? = 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. */
|
/** 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() },
|
error = o.optString("error", null).takeUnless { it.isNullOrBlank() },
|
||||||
stage = o.optString("stage", 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,
|
progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null,
|
||||||
|
startedAt = o.optString("started_at", null).takeUnless { it.isNullOrBlank() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue