Compare commits
10 commits
a3d16a6c18
...
c32fc2079c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c32fc2079c | ||
|
|
514d75fe92 | ||
|
|
15381b2c41 | ||
|
|
b541cc2314 | ||
|
|
05b84a361a | ||
|
|
b5836d0fb6 | ||
|
|
70483b4cf8 | ||
|
|
1a6acd92d6 | ||
|
|
8dc74a98a9 | ||
|
|
b5113f4abc |
8 changed files with 818 additions and 282 deletions
|
|
@ -66,6 +66,16 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
* an already-transcribed file (no live transcript in memory). */
|
||||
var reportText: String? = null,
|
||||
var error: String? = null,
|
||||
/** Emission discriminator. The whole class mutates ONE DetailUi
|
||||
* instance in place and republishes with copy(); StateFlow drops
|
||||
* any assignment that `equals()` the current value — and a copy
|
||||
* of the very object the flow already holds is ALWAYS equal
|
||||
* (same mutated fields). That silently swallowed updates, the
|
||||
* most visible one being the finished summary: the job landed,
|
||||
* the report on disk rewrote, but the open screen stayed on the
|
||||
* old voice until the user left to the library and back. Every
|
||||
* publish bumps this so no update can ever conflate away. */
|
||||
val rev: Long = 0L,
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
|
@ -912,7 +922,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
_liveProgress.value = _liveProgress.value.toMutableMap().apply {
|
||||
remove(old.name)
|
||||
}
|
||||
if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target)
|
||||
if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target, rev = System.nanoTime())
|
||||
rescan()
|
||||
return null
|
||||
}
|
||||
|
|
@ -1351,17 +1361,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
// idle "Transcribe" button for a file already working.
|
||||
if (d.remoteId == null && file.name in inFlight) {
|
||||
d.busy = _liveProgress.value[file.name]?.label ?: "queued for transcription…"
|
||||
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
||||
while (file.name in inFlight && _detail.value?.file == file) {
|
||||
delay(500)
|
||||
d.busy = _liveProgress.value[file.name]?.label ?: "uploading…"
|
||||
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
||||
}
|
||||
if (_detail.value?.file != file) return@launch
|
||||
d.busy = null
|
||||
d.remoteId = loadMapping(file)?.recordingId
|
||||
d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
|
||||
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
||||
}
|
||||
// Files that predate remote-id mapping have no sidecar: adopt
|
||||
// the server recording by exact title match so Re-summarize
|
||||
|
|
@ -1386,7 +1396,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
d.transcript = t ?: d.transcript
|
||||
d.summary = s
|
||||
saveReport(d)
|
||||
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
||||
}
|
||||
}
|
||||
// If the file is mid-pipeline (pump running, or reprocess started
|
||||
|
|
@ -1412,7 +1422,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
}
|
||||
d.jobs = jobs
|
||||
jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null }
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
val active = jobs.any { it.status == "running" || it.status == "queued" }
|
||||
sawActive = sawActive || active
|
||||
if (!active) break
|
||||
|
|
@ -1436,7 +1446,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
d.busy = null
|
||||
d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
|
||||
?: d.reportText
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
rescanStatuses()
|
||||
}
|
||||
}
|
||||
|
|
@ -1469,7 +1479,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
}
|
||||
|
||||
fun setOverride(model: String?) {
|
||||
_detail.value?.let { it.overrideModel = model; _detail.value = it.copy() }
|
||||
_detail.value?.let { it.overrideModel = model; _detail.value = it.copy(rev = System.nanoTime()) }
|
||||
}
|
||||
|
||||
fun transcribe() {
|
||||
|
|
@ -1483,7 +1493,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
if (d.file.name in inFlight || isRunning(d.file.name)) {
|
||||
d.busy = _liveProgress.value[d.file.name]?.label ?: "Already queued…"
|
||||
d.error = null
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
return
|
||||
}
|
||||
// A file with a known server recording re-runs the pipeline IN
|
||||
|
|
@ -1509,7 +1519,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
d.busy = "Uploading…"
|
||||
d.uploadProgress = 0f
|
||||
d.error = null
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
val draft = RecordingDraft(
|
||||
id = UUID.randomUUID().toString(),
|
||||
title = d.file.nameWithoutExtension,
|
||||
|
|
@ -1528,12 +1538,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
// always change something on screen, immediately.
|
||||
setLive(d.file.name, LiveProgress("uploading…", p))
|
||||
rescanStatuses()
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
}
|
||||
}.getOrElse {
|
||||
d.busy = null
|
||||
d.error = it.message ?: "Upload failed."
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
return@run
|
||||
}
|
||||
d.remoteId = ref.key
|
||||
|
|
@ -1541,7 +1551,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
title = d.file.nameWithoutExtension))
|
||||
d.busy = "Transcribing…"
|
||||
d.uploadProgress = null
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
// Poll jobs until the WHOLE pipeline is terminal. Breaking on
|
||||
// transcribe alone froze the screen: the last d.jobs snapshot
|
||||
// still showed summarize running at 0%, and nothing refreshed
|
||||
|
|
@ -1557,7 +1567,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
// doesn't make the job look dead.
|
||||
setLive(d.file.name, jobProgress(jobs))
|
||||
rescanStatuses()
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status
|
||||
val s = jobs.firstOrNull { it.jobType == "summarize" }?.status
|
||||
if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break
|
||||
|
|
@ -1568,20 +1578,20 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
if (failed != null) {
|
||||
d.busy = null
|
||||
d.error = failed.error ?: "Transcription failed."
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
return@run
|
||||
}
|
||||
d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
|
||||
.getOrNull()
|
||||
d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull()
|
||||
d.busy = null
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
if (d.transcript != null) {
|
||||
saveReport(d)
|
||||
rescan()
|
||||
} else {
|
||||
d.error = "Transcription finished but no transcript was returned."
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1605,7 +1615,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
else "Summarizing… 0%"
|
||||
d0.uploadProgress = null
|
||||
d0.error = null
|
||||
_detail.value = d0.copy()
|
||||
_detail.value = d0.copy(rev = System.nanoTime())
|
||||
runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure {
|
||||
if (job == "transcribe" && it is ProviderError.NotFound) {
|
||||
// Mapping points at a deleted recording: drop it so the
|
||||
|
|
@ -1614,7 +1624,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
d0.remoteId = null
|
||||
d0.busy = null
|
||||
d0.error = "That server recording no longer exists — press Transcribe to upload again."
|
||||
_detail.value = d0.copy()
|
||||
_detail.value = d0.copy(rev = System.nanoTime())
|
||||
return@launch
|
||||
} else if (it is ProviderError.Transient &&
|
||||
it.message?.contains("already running") == true) {
|
||||
|
|
@ -1628,15 +1638,34 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
} else {
|
||||
d0.busy = null
|
||||
d0.error = it.message ?: "Reprocess failed."
|
||||
_detail.value = d0.copy()
|
||||
_detail.value = d0.copy(rev = System.nanoTime())
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
// Poll the target job to a terminal state.
|
||||
// Poll the target job to a terminal state. A job this long
|
||||
// (10+ min = hundreds of polls) WILL hit a transient fetch
|
||||
// failure; treating one as terminal (the old `orEmpty()` →
|
||||
// st == null → break) froze the screen on stale content until
|
||||
// the user left and returned — exactly the reported symptom.
|
||||
// So: only a SUCCESSFUL fetch reporting a terminal state
|
||||
// breaks; failures keep polling up to ~30s of contact loss.
|
||||
var miss = 0
|
||||
var gone = 0
|
||||
while (true) {
|
||||
delay(2000)
|
||||
val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull()
|
||||
.orEmpty()
|
||||
val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) }
|
||||
if (fetched.isFailure) {
|
||||
if (++miss >= 15) {
|
||||
d0.error = "Lost contact with the engine while " +
|
||||
"processing — reopen this recording to check."
|
||||
d0.busy = null
|
||||
_detail.value = d0.copy(rev = System.nanoTime())
|
||||
return@launch
|
||||
}
|
||||
continue
|
||||
}
|
||||
miss = 0
|
||||
val jobs = fetched.getOrDefault(emptyList())
|
||||
d0.jobs = jobs
|
||||
jobLabel(jobs)?.let { d0.busy = it }
|
||||
// Mirror live progress onto the library row too — otherwise
|
||||
|
|
@ -1644,9 +1673,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
// detail screen (the pump path already does this via setLive).
|
||||
setLive(d0.file.name, jobProgress(jobs))
|
||||
rescanStatuses()
|
||||
_detail.value = d0.copy()
|
||||
_detail.value = d0.copy(rev = System.nanoTime())
|
||||
val st = jobs.firstOrNull { it.jobType == job }?.status
|
||||
if (st == null || st in TERMINAL) break
|
||||
if (st in TERMINAL) break
|
||||
// Job row missing from a healthy fetch: give it a few
|
||||
// cycles (the engine rewrites rows mid-requeue) then stop.
|
||||
if (st == null && ++gone >= 3) break
|
||||
}
|
||||
setLive(d0.file.name, null)
|
||||
rescanStatuses()
|
||||
|
|
@ -1654,7 +1686,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
if (failed != null) {
|
||||
d0.busy = null
|
||||
d0.error = failed.error ?: "That stage failed."
|
||||
_detail.value = d0.copy()
|
||||
_detail.value = d0.copy(rev = System.nanoTime())
|
||||
return@launch
|
||||
}
|
||||
d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) }
|
||||
|
|
@ -1662,7 +1694,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) }
|
||||
.getOrNull()
|
||||
d0.busy = null
|
||||
_detail.value = d0.copy()
|
||||
_detail.value = d0.copy(rev = System.nanoTime())
|
||||
d0.summary?.let { announceSummaryReady(d0.file, it.version) }
|
||||
if (d0.transcript != null || d0.summary != null) {
|
||||
saveReport(d0)
|
||||
|
|
@ -1688,7 +1720,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
// opened for a since-renamed/deleted file). Say what's wrong.
|
||||
d.error = "This file has no server recording to summarize — " +
|
||||
"press Transcribe to upload it first."
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
return
|
||||
}
|
||||
d.remoteId = remoteId
|
||||
|
|
@ -1708,7 +1740,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
if (remoteId == null) {
|
||||
d.error = "This file has no server recording to save against — " +
|
||||
"press Transcribe to upload it first."
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
return
|
||||
}
|
||||
d.remoteId = remoteId
|
||||
|
|
@ -1716,7 +1748,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
scope.launch {
|
||||
d.busy = "Saving transcript…"
|
||||
d.error = null
|
||||
_detail.value = d.copy()
|
||||
_detail.value = d.copy(rev = System.nanoTime())
|
||||
runCatching { parseTranscript(provider.updateTranscript(remoteId,
|
||||
org.json.JSONObject().put("text", text).toString())) }
|
||||
.onSuccess { saved ->
|
||||
|
|
@ -1725,13 +1757,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
saveReport(d)
|
||||
}
|
||||
d.busy = null
|
||||
if (_detail.value?.file == d.file) _detail.value = d.copy()
|
||||
if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime())
|
||||
rescanStatuses()
|
||||
}
|
||||
.onFailure {
|
||||
d.busy = null
|
||||
d.error = it.message ?: "Transcript save failed."
|
||||
if (_detail.value?.file == d.file) _detail.value = d.copy()
|
||||
if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1744,17 +1776,40 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary))
|
||||
}
|
||||
|
||||
/** Write the .odt export next to the audio (same base name as the
|
||||
* .md report, .report.odt extension). Returns null on success or a
|
||||
* human reason it failed. */
|
||||
fun exportOdt(d: DetailUi): String? {
|
||||
val out = odtFile(d.file)
|
||||
return runCatching {
|
||||
out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary))
|
||||
}.fold(
|
||||
onSuccess = { null },
|
||||
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
|
||||
)
|
||||
/** Export summary + transcript into [dir] in "md" or "odt" format.
|
||||
* Returns null on success (caller shows the path) or a human
|
||||
* reason it failed. Never touches the library's own report file. */
|
||||
fun exportTo(d: DetailUi, dir: File, format: String): String? {
|
||||
if (!dir.isDirectory) return "That folder no longer exists."
|
||||
val base = d.file.nameWithoutExtension
|
||||
val report = d.reportText
|
||||
return when (format) {
|
||||
"md" -> {
|
||||
val out = File(dir, "$base.transcript.md")
|
||||
val text = d.transcript?.let {
|
||||
renderReport(base, it, d.summary)
|
||||
} ?: report
|
||||
if (text == null) "Nothing to export yet — no summary or transcript."
|
||||
else runCatching { out.writeText(text) }.fold(
|
||||
onSuccess = { null },
|
||||
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
|
||||
)
|
||||
}
|
||||
"odt" -> {
|
||||
if (d.transcript == null && d.summary == null && report == null)
|
||||
"Nothing to export yet — no summary or transcript."
|
||||
else {
|
||||
val out = File(dir, "$base.report.odt")
|
||||
runCatching {
|
||||
out.writeBytes(renderOdt(base, d.transcript, d.summary))
|
||||
}.fold(
|
||||
onSuccess = { null },
|
||||
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> "Unknown export format."
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -1786,6 +1841,20 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Parse server naive-UTC ISO ("…T15:50:12.556516", sometimes Z)
|
||||
* to epoch millis; null when absent/unparseable. */
|
||||
fun parseIsoUtc(s: String?): Long? = runCatching {
|
||||
s ?: return null
|
||||
if (s.endsWith("Z")) java.time.Instant.parse(s).toEpochMilli()
|
||||
else java.time.LocalDateTime.parse(s)
|
||||
.toInstant(java.time.ZoneOffset.UTC).toEpochMilli()
|
||||
}.getOrNull()
|
||||
|
||||
/** "1:23" mm:ss (or h:mm:ss) for elapsed/ETA display. */
|
||||
fun fmtMMSS(sec: Long): String =
|
||||
if (sec < 3600) "${sec / 60}:${"%02d".format(sec % 60)}"
|
||||
else "${sec / 3600}:${"%02d".format(sec % 3600 / 60)}:${"%02d".format(sec % 60)}"
|
||||
|
||||
/** " · 1:23" of wait time since a job started running, "" when
|
||||
* under 5s or the start time is unknown. The llama-swap backend
|
||||
* exposes no queue depth, so elapsed wait is the most truthful
|
||||
|
|
@ -1807,6 +1876,134 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
/** Label-only convenience for the Detail screen's busy text. */
|
||||
fun jobLabel(jobs: List<JobInfo>): String? = jobProgress(jobs)?.label
|
||||
|
||||
/** Linear ETA from elapsed + real pct; null unless the sample is
|
||||
* meaningful (>=15% done leaves the noisy startup zone, <=95%
|
||||
* avoids promising the last think-token). */
|
||||
fun etaFrom(elapsedSec: Long, pct: Int?): Long? {
|
||||
if (pct == null || pct < 15 || pct > 95) return null
|
||||
return elapsedSec * (100 - pct) / pct
|
||||
}
|
||||
|
||||
/** Build the pipeline view from live job rows + upload fraction.
|
||||
* Null when nothing is happening (detail screen shows no panel). */
|
||||
fun pipelineStatus(
|
||||
uploadProgress: Float?,
|
||||
remoteKnown: Boolean,
|
||||
hasTranscript: Boolean,
|
||||
hasSummary: Boolean,
|
||||
jobs: List<JobInfo>,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): PipelineUi? {
|
||||
val t = jobs.firstOrNull { it.jobType == "transcribe" }
|
||||
val s = jobs.firstOrNull { it.jobType == "summarize" }
|
||||
val uploading = uploadProgress != null
|
||||
val tRunning = t?.status == "running"
|
||||
val sLive = s?.status in setOf("queued", "running")
|
||||
if (!uploading && !tRunning && !sLive &&
|
||||
!(t?.status == "succeeded" && !hasTranscript) &&
|
||||
!(s?.status == "succeeded" && !hasSummary)) return null
|
||||
|
||||
fun elapsedOf(j: JobInfo?): Long? {
|
||||
val start = parseIsoUtc(j?.startedAt ?: j?.createdAt) ?: return null
|
||||
return ((nowMs - start) / 1000).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
// Upload: real bytes fraction while pushing; done once the
|
||||
// engine has the file (a transcribe job exists).
|
||||
val uploadStage = PipelineStage(
|
||||
"Upload",
|
||||
when {
|
||||
uploading -> "active"
|
||||
t != null || hasTranscript -> "done"
|
||||
else -> "pending"
|
||||
},
|
||||
pct = uploadProgress?.let { (it * 100).toInt() },
|
||||
)
|
||||
// Transcribe: stage from the engine; real pct while running.
|
||||
val tStage = PipelineStage(
|
||||
"Transcription",
|
||||
when {
|
||||
t?.status == "failed" -> "failed"
|
||||
tRunning -> "active"
|
||||
t?.status == "succeeded" || hasTranscript -> "done"
|
||||
else -> "pending"
|
||||
},
|
||||
pct = t?.progress?.takeIf { tRunning },
|
||||
note = when {
|
||||
tRunning && t?.stage == "loading-model" -> "loading model…"
|
||||
tRunning && t?.progress == null -> "working…"
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
// Summarize: engine reports streaming, never distance — so no
|
||||
// pct, no ETA; the stage + elapsed are the honest signal.
|
||||
val sStage = PipelineStage(
|
||||
"Summary",
|
||||
when {
|
||||
s?.status == "failed" -> "failed"
|
||||
s?.status == "running" -> "active"
|
||||
s?.status == "queued" -> "active"
|
||||
s?.status == "succeeded" || hasSummary -> "done"
|
||||
else -> "pending"
|
||||
},
|
||||
note = when {
|
||||
s?.status == "queued" -> "queued on the summarizer"
|
||||
s?.status == "running" &&
|
||||
(s.progress ?: 0) > 0 -> {
|
||||
val tone = s.tone?.lowercase()?.replace(",", "")
|
||||
?.takeIf { it.isNotBlank() && it != "neutral" }
|
||||
"writing${tone?.let { " in a $it voice" } ?: ""} · streaming reply"
|
||||
}
|
||||
s?.status == "running" -> "waiting for model"
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
// Finalizing: engine says done but the app hasn't landed the
|
||||
// fresh transcript/summary locally yet.
|
||||
val finalizing = (t?.status == "succeeded" && !hasTranscript) ||
|
||||
(s?.status == "succeeded" && !hasSummary)
|
||||
val fStage = PipelineStage(
|
||||
"Finalizing",
|
||||
when {
|
||||
finalizing -> "active"
|
||||
hasTranscript && hasSummary -> "done"
|
||||
else -> "pending"
|
||||
},
|
||||
)
|
||||
|
||||
val headline = when {
|
||||
uploading -> "Uploading recording…"
|
||||
tRunning -> "Transcribing recording…"
|
||||
s?.status == "queued" -> "Queued: summary pending…"
|
||||
s?.status == "running" -> "Summarizing recording…"
|
||||
else -> "Finalizing…"
|
||||
}
|
||||
val activeJob = when {
|
||||
tRunning -> t
|
||||
sLive -> s
|
||||
else -> null
|
||||
}
|
||||
val elapsed = elapsedOf(activeJob)
|
||||
val eta = if (tRunning) etaFrom(elapsed ?: 0, t?.progress) else null
|
||||
return PipelineUi(headline,
|
||||
listOf(uploadStage, tStage, sStage, fStage), elapsed, eta)
|
||||
}
|
||||
|
||||
/** Voice recorded inside a saved .transcript.md report — the
|
||||
* `*Voice: X*` metadata line written by Reports.renderReport.
|
||||
* Used when the live summary row isn't loaded (saved-report
|
||||
* view) so the voice chips can't contradict the report on
|
||||
* screen. "neutral" (or absent) maps to null. Pure — tested. */
|
||||
fun reportVoice(reportText: String?): String? {
|
||||
reportText ?: return null
|
||||
val line = reportText.lineSequence().firstOrNull {
|
||||
it.trim().startsWith("*Voice:")
|
||||
} ?: return null
|
||||
val v = line.trim().removePrefix("*Voice:").removeSuffix("*")
|
||||
.trim()
|
||||
return v.takeIf { it.isNotBlank() && !it.equals("neutral", true) }
|
||||
}
|
||||
|
||||
const val KEY_URL = "server.url"
|
||||
const val KEY_FOLDER = "library.folder"
|
||||
const val KEY_PASSWORD = "local.password"
|
||||
|
|
@ -1857,9 +2054,6 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
|
||||
fun reportFile(audio: File): File =
|
||||
File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md")
|
||||
|
||||
fun odtFile(audio: File): File =
|
||||
File(audio.parentFile, "${audio.nameWithoutExtension}.report.odt")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1873,3 +2067,27 @@ data class SearchHit(
|
|||
|
||||
/** What the pipeline is doing for a library file right now. */
|
||||
data class LiveProgress(val label: String, val fraction: Float?)
|
||||
|
||||
/** One row of the processing pipeline for the detail screen.
|
||||
* States only change when real work moves: nothing here is animated
|
||||
* by a timer pretending to know more than the engine. */
|
||||
data class PipelineStage(
|
||||
val label: String,
|
||||
/** "done" ✓ · "active" → · "pending" ○ · "failed" ✗ */
|
||||
val state: String,
|
||||
/** Real 0-100 percentage when the backend reports one that means
|
||||
* distance (upload bytes, transcribe audio-time); null when only
|
||||
* the stage is known. */
|
||||
val pct: Int? = null,
|
||||
/** Extra clause for the active stage ("loading model…"). */
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
data class PipelineUi(
|
||||
val headline: String,
|
||||
val stages: List<PipelineStage>,
|
||||
val elapsedSec: Long?,
|
||||
/** Honest ETA: only when derived from a real percentage that measures
|
||||
* distance (upload/transcribe). Never for summarize. */
|
||||
val etaSec: Long?,
|
||||
)
|
||||
|
|
@ -9,11 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
|
@ -155,41 +152,39 @@ fun main() = application {
|
|||
val summaryToast by state.summaryToast.collectAsState()
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
summaryToast?.let { t ->
|
||||
Card(
|
||||
SonarCard(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
.padding(horizontal = Shonar.CardPadding),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(t.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f))
|
||||
TextButton({ state.viewSummaryReady(t) }) {
|
||||
ShonarGhostButton({ state.viewSummaryReady(t) }) {
|
||||
Text("View")
|
||||
}
|
||||
TextButton({ state.dismissSummaryToast() }) {
|
||||
ShonarGhostButton({ state.dismissSummaryToast() }) {
|
||||
Text("✕")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(Shonar.Space2))
|
||||
}
|
||||
undoToast?.let { t ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
SonarCard(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
.padding(horizontal = Shonar.CardPadding),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(t.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f))
|
||||
TextButton({ state.undoLastTrash() }) {
|
||||
ShonarGhostButton({ state.undoLastTrash() }) {
|
||||
Text("Undo")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(Shonar.Space2))
|
||||
}
|
||||
when (screen) {
|
||||
DesktopState.Screen.ENGINE -> EngineScreen(state)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -25,21 +24,16 @@ import androidx.compose.material.icons.filled.Search
|
|||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -82,9 +76,9 @@ fun EngineScreen(state: DesktopState) {
|
|||
}
|
||||
else -> {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ state.startEngine() }) { Text("Start engine") }
|
||||
OutlinedButton({ state.ensureReady() }) { Text("Retry") }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
ShonarButton({ state.startEngine() }) { Text("Start engine") }
|
||||
ShonarOutlinedButton({ state.ensureReady() }) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -141,11 +135,11 @@ fun FolderPickerDialog(
|
|||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Breadcrumb row: home + each step; click to jump.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton({ current = home }) { Text("Home") }
|
||||
ShonarGhostButton({ current = home }) { Text("Home") }
|
||||
crumbs.dropWhile { it.absolutePath == home.absolutePath }
|
||||
.forEach { d ->
|
||||
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton({ current = d }) {
|
||||
ShonarGhostButton({ current = d }) {
|
||||
Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -155,7 +149,7 @@ fun FolderPickerDialog(
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
HorizontalDivider()
|
||||
if (current.parentFile != null) {
|
||||
TextButton(
|
||||
ShonarGhostButton(
|
||||
{ current.parentFile?.let { current = it } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("⋱ .. (go up)") }
|
||||
|
|
@ -167,31 +161,31 @@ fun FolderPickerDialog(
|
|||
}
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
items(subs, key = { it.absolutePath }) { d ->
|
||||
Card(Modifier.fillMaxWidth().clickable { current = d }) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp,
|
||||
vertical = 8.dp),
|
||||
SonarCard(Modifier.fillMaxWidth(),
|
||||
onClick = { current = d }) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = Shonar.CardPadding,
|
||||
vertical = Shonar.Space2),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("▸", color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.width(Shonar.Space2))
|
||||
Text(d.name, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Spacer(Modifier.height(Shonar.Space1))
|
||||
}
|
||||
}
|
||||
error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
HelperText(it, error = true)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton({
|
||||
ShonarGhostButton({
|
||||
if (current.isDirectory) onResult(current)
|
||||
else error = "That folder no longer exists."
|
||||
}) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") }
|
||||
},
|
||||
dismissButton = { TextButton({ onResult(null) }) { Text("Cancel") } },
|
||||
dismissButton = { ShonarGhostButton({ onResult(null) }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -552,6 +546,83 @@ fun LibraryScreen(state: DesktopState) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- export dialog -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Export summary + transcript to a folder of the user's choosing, in
|
||||
* Markdown or ODT. Reuses the in-app FolderPicker for navigation, so
|
||||
* there's no native-dialog/zoom weirdness. onDone receives the success
|
||||
* note; nothing is written until Save.
|
||||
*/
|
||||
@androidx.compose.runtime.Composable
|
||||
fun ExportDialog(
|
||||
detail: DesktopState.DetailUi,
|
||||
state: DesktopState,
|
||||
onDone: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var dir by remember {
|
||||
mutableStateOf(state.folder.value ?: File(System.getProperty("user.home")))
|
||||
}
|
||||
var format by remember { mutableStateOf("md") }
|
||||
var picking by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Export “${detail.file.nameWithoutExtension}”") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
Text("Format", style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
PillChip(selected = format == "md",
|
||||
onClick = { format = "md" },
|
||||
label = "Markdown (.md)")
|
||||
PillChip(selected = format == "odt",
|
||||
onClick = { format = "odt" },
|
||||
label = "OpenDocument (.odt)")
|
||||
}
|
||||
Text("Folder", style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
Text(dir.absolutePath,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f))
|
||||
ShonarOutlinedButton({ picking = true }) { Text("Change…") }
|
||||
}
|
||||
HelperText("Saves as " +
|
||||
"“${detail.file.nameWithoutExtension}" +
|
||||
(if (format == "md") ".transcript.md" else ".report.odt") +
|
||||
"” — your library's own report file is never touched.")
|
||||
error?.let { HelperText(it, error = true) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
ShonarGhostButton({
|
||||
error = state.exportTo(detail, dir, format)
|
||||
if (error == null) onDone(
|
||||
"Exported to ${dir.absolutePath} (${
|
||||
if (format == "md") "Markdown" else "ODT"})")
|
||||
}) { Text("Save here") }
|
||||
},
|
||||
dismissButton = { ShonarGhostButton(onCancel) { Text("Cancel") } },
|
||||
)
|
||||
|
||||
if (picking) {
|
||||
FolderPickerDialog(
|
||||
startDir = dir,
|
||||
onResult = { picked ->
|
||||
picking = false
|
||||
picked?.let { dir = it }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- detail -----------------------------------------------------------------
|
||||
|
||||
/** Personalities offered beside Re-summarize: (label, tone sent to the
|
||||
|
|
@ -586,6 +657,7 @@ fun DetailScreen(state: DesktopState) {
|
|||
}
|
||||
var savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
|
||||
var odtNote by remember(detail.file.name) { mutableStateOf<String?>(null) }
|
||||
var showExport 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()
|
||||
|
|
@ -611,6 +683,11 @@ fun DetailScreen(state: DesktopState) {
|
|||
val chipTone: String? = when {
|
||||
sJobNow != null && sJobNow.status in setOf("queued", "running") -> sJobNow.tone
|
||||
sSummary != null -> sSummary.tone
|
||||
// Live summary row unavailable (saved-report view): read the
|
||||
// voice out of the report itself so the chips can't contradict
|
||||
// the "*Voice: funny*" text on screen (user: report said funny,
|
||||
// Neutral pill was highlighted).
|
||||
detail.reportText != null -> DesktopState.reportVoice(detail.reportText)
|
||||
else -> sumTone
|
||||
}
|
||||
|
||||
|
|
@ -619,29 +696,28 @@ fun DetailScreen(state: DesktopState) {
|
|||
// the complaint. Everything below scrolls as one flow.
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
||||
ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
||||
Text(detail.file.nameWithoutExtension,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f))
|
||||
// Page through recordings without returning to the list
|
||||
// (Alt+←/→ do the same). Disabled honestly at the ends.
|
||||
TextButton(
|
||||
ShonarGhostButton(
|
||||
onClick = { state.neighborOf(detail.file, forward = false)
|
||||
?.let(state::openDetail) },
|
||||
enabled = state.neighborOf(detail.file, forward = false) != null,
|
||||
) { Text("← Prev") }
|
||||
TextButton(
|
||||
ShonarGhostButton(
|
||||
onClick = { state.neighborOf(detail.file, forward = true)
|
||||
?.let(state::openDetail) },
|
||||
enabled = state.neighborOf(detail.file, forward = true) != null,
|
||||
) { Text("Next →") }
|
||||
TextButton(
|
||||
ShonarDangerButton(
|
||||
{ confirmDelete = true },
|
||||
enabled = detail.busy == null,
|
||||
content = {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
},
|
||||
)
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
|
|
@ -657,10 +733,10 @@ fun DetailScreen(state: DesktopState) {
|
|||
// One Stop control, always present (the pause/resume pill
|
||||
// was redundant: Stop already ends playback, and the
|
||||
// separate Stop text button duplicated it).
|
||||
Button({ state.stopPlayback() }) {
|
||||
ShonarButton({ state.stopPlayback() }) {
|
||||
Icon(Icons.Filled.Stop, contentDescription = null,
|
||||
modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
modifier = Modifier.size(Shonar.IconSm))
|
||||
Spacer(Modifier.width(Shonar.Space2))
|
||||
Text("Stop")
|
||||
}
|
||||
if (transport && ps.durationSec > 0) {
|
||||
|
|
@ -679,23 +755,27 @@ fun DetailScreen(state: DesktopState) {
|
|||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton({ state.playFrom(detail.file) }) {
|
||||
ShonarOutlinedButton({ state.playFrom(detail.file) }) {
|
||||
Icon(Icons.Filled.PlayArrow, contentDescription = null,
|
||||
modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
modifier = Modifier.size(Shonar.IconSm))
|
||||
Spacer(Modifier.width(Shonar.Space2))
|
||||
Text("Play")
|
||||
}
|
||||
}
|
||||
playError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
HelperText(it, error = true)
|
||||
}
|
||||
}
|
||||
// Search within this recording: hits jump the audio to that moment.
|
||||
if (transcriptLines.isNotEmpty()) {
|
||||
OutlinedTextField(recQuery, { recQuery = it },
|
||||
label = { Text("Search this recording") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
SonarField(recQuery, { recQuery = it },
|
||||
placeholder = "Search this recording…",
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null,
|
||||
tint = Shonar.TextSecondary,
|
||||
modifier = Modifier.size(Shonar.IconSm))
|
||||
})
|
||||
if (recQuery.isNotBlank()) {
|
||||
if (recHits.isEmpty()) {
|
||||
Text("No matches in this recording.",
|
||||
|
|
@ -707,20 +787,20 @@ fun DetailScreen(state: DesktopState) {
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxWidth().heightIn(max = 260.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Shonar.Space1),
|
||||
) {
|
||||
items(recHits) { hit ->
|
||||
Card(Modifier.fillMaxWidth().clickable {
|
||||
state.playFrom(detail.file, hit.startSec)
|
||||
}) {
|
||||
Row(Modifier.padding(8.dp),
|
||||
SonarCard(Modifier.fillMaxWidth(),
|
||||
onClick = { state.playFrom(detail.file, hit.startSec) }) {
|
||||
Row(Modifier.padding(Shonar.Space2, Shonar.Space2),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(fmtTs(hit.startSec),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
color = Shonar.Accent)
|
||||
Spacer(Modifier.width(Shonar.Space3))
|
||||
Text(hit.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Shonar.TextPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f))
|
||||
|
|
@ -736,7 +816,7 @@ fun DetailScreen(state: DesktopState) {
|
|||
Text("Model: ", style = MaterialTheme.typography.bodyMedium)
|
||||
val current = detail.overrideModel ?: models?.defaultModel ?: "base"
|
||||
val currentLabel = models?.byName(current)?.displayName ?: current
|
||||
TextButton({ showModels = !showModels }) {
|
||||
ShonarGhostButton({ showModels = !showModels }) {
|
||||
Text(if (detail.overrideModel == null)
|
||||
"$currentLabel (default)" else currentLabel)
|
||||
}
|
||||
|
|
@ -753,76 +833,33 @@ fun DetailScreen(state: DesktopState) {
|
|||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
detail.uploadProgress?.let {
|
||||
LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth())
|
||||
Text("Uploading… ${(it * 100).toInt()}%")
|
||||
}
|
||||
detail.busy?.takeIf { detail.uploadProgress == null }?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(it)
|
||||
}
|
||||
}
|
||||
val tJob = detail.jobs.firstOrNull { it.jobType == "transcribe" }
|
||||
tJob?.let {
|
||||
val running = it.status == "running"
|
||||
val label = when {
|
||||
it.status == "succeeded" -> "Transcribed"
|
||||
it.status == "failed" -> "Failed"
|
||||
it.stage == "loading-model" -> "Loading model…"
|
||||
running && it.progress != null -> "Transcribing… ${it.progress}%"
|
||||
it.stage == "transcribing" -> "Transcribing…"
|
||||
running -> "Working… (attempt ${it.attempt}/${it.maxAttempts})"
|
||||
else -> "Queued"
|
||||
}
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (it.status == "failed") MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary)
|
||||
if (running) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (it.progress != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { it.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Processing pipeline: real stages only. Each row flips when the
|
||||
// engine actually moves; percentages appear only where the backend
|
||||
// measures distance (upload bytes, transcribe audio-time). No
|
||||
// decorative motion pretending to be progress.
|
||||
val pipeline = DesktopState.pipelineStatus(
|
||||
uploadProgress = detail.uploadProgress,
|
||||
remoteKnown = state.remoteIdFor(detail.file) != null,
|
||||
hasTranscript = detail.transcript != null,
|
||||
hasSummary = detail.summary != null,
|
||||
jobs = detail.jobs,
|
||||
)
|
||||
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
||||
sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let {
|
||||
// Honest milestone progress: the engine counts completed
|
||||
// contract units (JSON keys/items finished), so the number
|
||||
// means real work done — a determinate bar is truthful now.
|
||||
val toneWord = sJob.tone?.lowercase()?.replace(",", "")
|
||||
?.takeIf { it.isNotBlank() && it != "neutral" }
|
||||
val label = when {
|
||||
it.status == "running" && it.progress != null && it.progress > 0 ->
|
||||
"Summarizing" + (toneWord?.let { t -> " $t" } ?: "") +
|
||||
"… ${it.progress}%" +
|
||||
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
|
||||
it.status == "running" -> "Summarizing… 0% waiting for model" +
|
||||
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
|
||||
else -> "Summary queued" +
|
||||
DesktopState.waitSuffix(it.createdAt, System.currentTimeMillis())
|
||||
}
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
if (it.status == "running") {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (it.progress != null && it.progress > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { it.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
if (pipeline != null) {
|
||||
PipelinePanel(pipeline)
|
||||
} else if (detail.busy != null) {
|
||||
// Click-landed but no job row yet (POST/token/poll latency):
|
||||
// busy is REAL work in flight — showing nothing here was the
|
||||
// "hit transcribe, screen looks dead" blind window.
|
||||
SonarCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2),
|
||||
) {
|
||||
Text("→", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
Text(detail.busy!!, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -852,18 +889,21 @@ fun DetailScreen(state: DesktopState) {
|
|||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (detail.busy == null) {
|
||||
Button({ state.summarize() }) { Text("Retry summary") }
|
||||
ShonarButton({ state.summarize() }) { Text("Retry summary") }
|
||||
}
|
||||
}
|
||||
detail.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
if (tJob?.status == "failed") {
|
||||
Button({ state.retry() }) { Text("Retry transcription") }
|
||||
HelperText(it, error = true)
|
||||
val tFailed = detail.jobs.firstOrNull { j ->
|
||||
j.jobType == "transcribe" && j.status == "failed"
|
||||
}
|
||||
if (tFailed != null) {
|
||||
ShonarButton({ state.retry() }) { Text("Retry transcription") }
|
||||
}
|
||||
}
|
||||
if (detail.busy == null && detail.transcript == null &&
|
||||
detail.reportText == null && detail.error == null) {
|
||||
Button({ state.transcribe() }) { Text("Transcribe") }
|
||||
ShonarButton({ state.transcribe() }) { Text("Transcribe") }
|
||||
}
|
||||
// Summary ABOVE the transcript: a recording with a long transcript
|
||||
// pushed it below the fold (the transcript region is the only
|
||||
|
|
@ -890,14 +930,14 @@ fun DetailScreen(state: DesktopState) {
|
|||
var copiedNote by remember(detail.file.name) {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton({
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
ShonarGhostButton({
|
||||
clipboard.setText(AnnotatedString(s.short))
|
||||
copiedNote = "Summary copied"
|
||||
}) { Text("Copy summary") }
|
||||
val actionItems = s.list("action_items")
|
||||
if (actionItems.isNotEmpty()) {
|
||||
TextButton({
|
||||
ShonarGhostButton({
|
||||
clipboard.setText(AnnotatedString(
|
||||
actionItems.joinToString("\n") { "- $it" }))
|
||||
copiedNote = "Action items copied"
|
||||
|
|
@ -920,27 +960,25 @@ fun DetailScreen(state: DesktopState) {
|
|||
val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
||||
?.status in setOf("queued", "running")
|
||||
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
|
||||
Text("Re-summarize in the voice of",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
SUMMARIZE_PERSONAS.forEach { (label, tone) ->
|
||||
FilterChip(
|
||||
PillChip(
|
||||
selected = chipTone == tone,
|
||||
enabled = voicesEnabled,
|
||||
onClick = {
|
||||
sumTone = tone
|
||||
state.summarize(tone)
|
||||
},
|
||||
label = { Text(label) },
|
||||
label = label,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!voicesEnabled && !remoteKnown) {
|
||||
Text("Summarize unlocks once this file is uploaded (press Transcribe).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
HelperText("Summarize unlocks once this file is uploaded (press Transcribe).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -985,68 +1023,52 @@ fun DetailScreen(state: DesktopState) {
|
|||
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
|
||||
if (detail.transcript == null) {
|
||||
// Saved-report path: chips here, same rules.
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
|
||||
Text(if (hasSummary) "Re-summarize in the voice of"
|
||||
else "Summarize in the voice of",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
SUMMARIZE_PERSONAS.forEach { (label, tone) ->
|
||||
FilterChip(
|
||||
PillChip(
|
||||
selected = chipTone == tone,
|
||||
enabled = voicesEnabled,
|
||||
onClick = {
|
||||
sumTone = tone
|
||||
state.summarize(tone)
|
||||
},
|
||||
label = { Text(label) },
|
||||
label = label,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton({ state.transcribe() }) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
ShonarGhostButton({ state.transcribe() }) {
|
||||
Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
|
||||
}
|
||||
}
|
||||
// Summarize progress right where the buttons are: a running
|
||||
// summarize must be visible next to the chips that started it.
|
||||
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
||||
val sRunning = sJob?.status in setOf("queued", "running")
|
||||
if (sRunning) {
|
||||
val sStatus = sJob?.status
|
||||
val sProg = sJob?.progress
|
||||
val sAttempt = sJob?.attempt ?: 0
|
||||
// Determinate bar once real units complete: the engine
|
||||
// counts finished JSON contract units, so the percent
|
||||
// means work done, not vibes. Indeterminate only while
|
||||
// queued or pre-first-token.
|
||||
if (sProg != null && sProg > 0) {
|
||||
LinearProgressIndicator(progress = { sProg / 100f },
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
// Export beside the other actions up top — the report is
|
||||
// long and the bottom button was out of sight (user
|
||||
// couldn't find it). Kept at the bottom too.
|
||||
ShonarGhostButton({ showExport = true }) {
|
||||
Text("Export…")
|
||||
}
|
||||
}
|
||||
// Live status right where the chips are — one honest line
|
||||
// mirroring the pipeline panel's Summary stage (the panel may
|
||||
// be scrolled out of view when the chips get clicked). No
|
||||
// bar animation: the engine reports stage, not distance.
|
||||
val sRunning = sJob?.status in setOf("queued", "running")
|
||||
if (sRunning && pipeline != null) {
|
||||
val sStage = pipeline.stages.firstOrNull { it.label == "Summary" }
|
||||
Text(buildString {
|
||||
append(when {
|
||||
sStatus == "queued" -> "Queued…" +
|
||||
DesktopState.waitSuffix(sJob?.createdAt,
|
||||
System.currentTimeMillis())
|
||||
sProg != null && sProg > 0 ->
|
||||
"Summarizing" + (sJob?.tone?.lowercase()
|
||||
?.replace(",", "")
|
||||
?.takeIf { it.isNotBlank() && it != "neutral" }
|
||||
?.let { " $it" } ?: "") +
|
||||
"… $sProg%" +
|
||||
DesktopState.waitSuffix(sJob?.startedAt,
|
||||
System.currentTimeMillis())
|
||||
else -> "Summarizing… 0% waiting for model" +
|
||||
DesktopState.waitSuffix(sJob?.startedAt,
|
||||
System.currentTimeMillis())
|
||||
})
|
||||
if (sAttempt > 0)
|
||||
append(" (attempt $sAttempt/${sJob?.maxAttempts})")
|
||||
append("→ ").append(sStage?.note
|
||||
?: if (sJob?.status == "queued") "queued on the summarizer"
|
||||
else "writing")
|
||||
pipeline.elapsedSec?.let { append(" · ${DesktopState.fmtMMSS(it)} elapsed") }
|
||||
(sJob?.attempt ?: 0).let {
|
||||
if (it > 0) append(" (attempt $it/${sJob?.maxAttempts})")
|
||||
}
|
||||
}, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
|
@ -1072,7 +1094,7 @@ fun DetailScreen(state: DesktopState) {
|
|||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (editDraft == null && remoteKnownForEdit && detail.busy == null) {
|
||||
TextButton({
|
||||
ShonarGhostButton({
|
||||
editDraft = if (t.segments.isNotEmpty())
|
||||
t.segments.joinToString("\n") { it.text } else t.text
|
||||
}) { Text("Edit") }
|
||||
|
|
@ -1094,20 +1116,22 @@ fun DetailScreen(state: DesktopState) {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
SonarField(
|
||||
value = draft,
|
||||
onValueChange = { editDraft = it },
|
||||
placeholder = "Correct the words…",
|
||||
singleLine = false,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
ShonarButton(
|
||||
enabled = draft.isNotBlank() && !savingTranscript,
|
||||
onClick = {
|
||||
savingTranscript = true
|
||||
state.saveTranscriptEdit(draft)
|
||||
},
|
||||
) { Text(if (savingTranscript) "Saving…" else "Save") }
|
||||
OutlinedButton(
|
||||
ShonarOutlinedButton(
|
||||
enabled = !savingTranscript,
|
||||
onClick = { editDraft = null },
|
||||
) { Text("Cancel") }
|
||||
|
|
@ -1138,19 +1162,27 @@ fun DetailScreen(state: DesktopState) {
|
|||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
// Export lives once for both paths (live transcript or saved report).
|
||||
// Export: pick a folder and a format — the summary + transcript
|
||||
// leave the library as a file you can keep anywhere.
|
||||
if (detail.transcript != null || detail.reportText != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {
|
||||
odtNote = state.exportOdt(detail)
|
||||
?: "Saved to ${detail.file.nameWithoutExtension}.report.odt"
|
||||
}) { Text("Export .odt") }
|
||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
ShonarOutlinedButton(onClick = { showExport = true }) {
|
||||
Text("Export…")
|
||||
}
|
||||
odtNote?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
color = Shonar.Accent)
|
||||
}
|
||||
}
|
||||
if (showExport) {
|
||||
ExportDialog(detail = detail, state = state,
|
||||
onDone = { note ->
|
||||
showExport = false
|
||||
odtNote = note
|
||||
},
|
||||
onCancel = { showExport = false })
|
||||
}
|
||||
}
|
||||
// (Voice chips + Re-transcribe + summarize progress live above the
|
||||
// transcript, right under the summary.)
|
||||
|
|
@ -1182,12 +1214,14 @@ private fun ModelRow(
|
|||
) {
|
||||
val selected = detail.overrideModel == value ||
|
||||
(value == null && detail.overrideModel == null)
|
||||
Card(
|
||||
Modifier.fillMaxWidth().clickable { state.setOverride(value) },
|
||||
SonarCard(
|
||||
Modifier.fillMaxWidth(),
|
||||
onClick = { state.setOverride(value) },
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp),
|
||||
Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(if (selected) "◉ " else "○ ")
|
||||
Text(if (selected) "◉ " else "○ ",
|
||||
color = if (selected) Shonar.Accent else Shonar.TextMuted)
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium)
|
||||
if (subtitle.isNotBlank()) {
|
||||
|
|
@ -1215,38 +1249,36 @@ fun SettingsScreen(state: DesktopState) {
|
|||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
||||
ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
||||
Text("Settings", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
Text("Local engine: $url", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Recordings folder", style = MaterialTheme.typography.titleMedium)
|
||||
SectionHeader("Recordings folder")
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
Text(folder?.absolutePath ?: "No folder chosen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f))
|
||||
OutlinedButton({ showPicker = true }) {
|
||||
ShonarOutlinedButton({ showPicker = true }) {
|
||||
Text(if (folder == null) "Choose folder…" else "Change folder…")
|
||||
}
|
||||
}
|
||||
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",
|
||||
Spacer(Modifier.height(Shonar.Space1))
|
||||
SectionHeader("Summarizer")
|
||||
HelperText("Where summaries are written. Restarting the engine applies " +
|
||||
"the choice; work already running carries over.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
||||
PillChip(selected = summarizer == "auto",
|
||||
onClick = { state.setSummarizer("auto") },
|
||||
label = { Text("Auto") })
|
||||
FilterChip(selected = summarizer == "lan",
|
||||
label = "Auto")
|
||||
PillChip(selected = summarizer == "lan",
|
||||
onClick = { state.setSummarizer("lan") },
|
||||
label = { Text("LAN server (H200)") })
|
||||
FilterChip(selected = summarizer == "local",
|
||||
label = "LAN server (H200)")
|
||||
PillChip(selected = summarizer == "local",
|
||||
onClick = { state.setSummarizer("local") },
|
||||
label = { Text("This laptop (Ollama)") })
|
||||
label = "This laptop (Ollama)")
|
||||
}
|
||||
Text(when (summarizer) {
|
||||
"lan" -> "Best quality; shares the H200 with other clients — " +
|
||||
|
|
@ -1261,11 +1293,9 @@ fun SettingsScreen(state: DesktopState) {
|
|||
},
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(Shonar.Space1))
|
||||
SectionHeader("Default transcription model")
|
||||
HelperText("Applies to future transcriptions. Each recording keeps the model it used.")
|
||||
if (models == null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||
|
|
@ -1279,22 +1309,24 @@ fun SettingsScreen(state: DesktopState) {
|
|||
color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
models.models.forEach { m ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SonarCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(Shonar.CardPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
(if (m.isDefault) "◉ " else "○ ") + m.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (m.isDefault) Shonar.Accent
|
||||
else Shonar.TextPrimary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!m.isDefault) {
|
||||
TextButton({ state.setDefaultModel(m.name) }) {
|
||||
ShonarGhostButton({ state.setDefaultModel(m.name) }) {
|
||||
Text("Set default")
|
||||
}
|
||||
}
|
||||
if (!m.downloaded) {
|
||||
TextButton({ state.downloadModel(m.name) }) {
|
||||
ShonarGhostButton({ state.downloadModel(m.name) }) {
|
||||
Text("Download")
|
||||
}
|
||||
}
|
||||
|
|
@ -1322,3 +1354,88 @@ fun SettingsScreen(state: DesktopState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Processing pipeline panel — stage truth from `DesktopState.pipelineStatus`.
|
||||
*
|
||||
* Each row carries exactly one state marker (✓ done, → current, ✗ failed,
|
||||
* ○ not started) and only updates when the engine actually moves that
|
||||
* stage. A percentage bar appears only when the backend reports real
|
||||
* distance (upload bytes, transcribe audio-time); stage-only work shows
|
||||
* a steady marker, never a moving fake bar. ETA appears only when the
|
||||
* engine's percentage supports one. */
|
||||
@Composable
|
||||
fun PipelinePanel(p: PipelineUi) {
|
||||
SonarCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// SonarCard's Surface places un-wrapped siblings in a Box — without
|
||||
// this Column every row stacked on top of the headline (the
|
||||
// "text overlapping while summarizing" garble).
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(Shonar.CardPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(Shonar.Space1),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(p.headline, style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
buildString {
|
||||
p.elapsedSec?.let { append("${DesktopState.fmtMMSS(it)} elapsed") }
|
||||
p.etaSec?.let {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append("~${DesktopState.fmtMMSS(it)} remaining")
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Shonar.Space2))
|
||||
p.stages.forEach { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2),
|
||||
) {
|
||||
Text(
|
||||
when (s.state) {
|
||||
"done" -> "✓"
|
||||
"active" -> "→"
|
||||
"failed" -> "✗"
|
||||
else -> "○"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (s.state) {
|
||||
"failed" -> MaterialTheme.colorScheme.error
|
||||
"pending" -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
Text(
|
||||
s.label + (s.pct?.let { " $it%" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (s.state == "pending")
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
s.note?.let {
|
||||
Text("· $it", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
s.pct?.takeIf { s.state == "active" }?.let {
|
||||
LinearProgressIndicator(
|
||||
progress = { it / 100f },
|
||||
modifier = Modifier
|
||||
.padding(start = Shonar.Space4)
|
||||
.fillMaxWidth()
|
||||
.height(3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,4 +107,124 @@ class JobProgressTest {
|
|||
// visible time too, so it must count from created_at.
|
||||
assertEquals(" · 2:04", DesktopState.waitSuffix(iso, t0))
|
||||
}
|
||||
|
||||
// ---- pipelineStatus: honest stage rows, no fake distance ----
|
||||
|
||||
private fun pjob(type: String, status: String, stage: String? = null,
|
||||
prog: Int? = null, started: String? = null) =
|
||||
JobInfo(jobType = type, status = status, attempt = 0, maxAttempts = 3,
|
||||
stage = stage, progress = prog, startedAt = started)
|
||||
|
||||
@Test fun `pipeline is null when idle`() {
|
||||
assertNull(DesktopState.pipelineStatus(
|
||||
uploadProgress = null, remoteKnown = true,
|
||||
hasTranscript = true, hasSummary = true, jobs = emptyList()))
|
||||
}
|
||||
|
||||
@Test fun `summarize running shows stage not distance`() {
|
||||
// The engine's summarize percent means streaming, not distance —
|
||||
// so the pipeline must NOT surface a pct or ETA for it.
|
||||
val now = System.currentTimeMillis()
|
||||
val started = java.time.LocalDateTime.ofInstant(
|
||||
java.time.Instant.ofEpochMilli(now - 42_000), java.time.ZoneOffset.UTC)
|
||||
.withNano(0).toString()
|
||||
val p = DesktopState.pipelineStatus(
|
||||
uploadProgress = null, remoteKnown = true,
|
||||
hasTranscript = true, hasSummary = false,
|
||||
jobs = listOf(pjob("transcribe", "succeeded"),
|
||||
pjob("summarize", "running", prog = 99, started = started)),
|
||||
nowMs = now)!!
|
||||
assertEquals("Summarizing recording…", p.headline)
|
||||
assertNull(p.etaSec)
|
||||
assertEquals(42L, p.elapsedSec)
|
||||
val s = p.stages.first { it.label == "Summary" }
|
||||
assertEquals("active", s.state)
|
||||
assertNull(s.pct)
|
||||
assertEquals(0, p.stages.count { it.state == "failed" })
|
||||
assertEquals("done", p.stages.first { it.label == "Transcription" }.state)
|
||||
}
|
||||
|
||||
@Test fun `transcribe running carries real pct and eta`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val started = java.time.LocalDateTime.ofInstant(
|
||||
java.time.Instant.ofEpochMilli(now - 60_000), java.time.ZoneOffset.UTC)
|
||||
.withNano(0).toString()
|
||||
val p = DesktopState.pipelineStatus(
|
||||
uploadProgress = null, remoteKnown = true,
|
||||
hasTranscript = false, hasSummary = false,
|
||||
jobs = listOf(pjob("transcribe", "running", stage = "transcribing",
|
||||
prog = 40, started = started)),
|
||||
nowMs = now)!!
|
||||
assertEquals("Transcribing recording…", p.headline)
|
||||
val t = p.stages.first { it.label == "Transcription" }
|
||||
assertEquals("active", t.state)
|
||||
assertEquals(40, t.pct)
|
||||
// 60s elapsed at 40% -> 90s remaining, both real-derived.
|
||||
assertEquals(60L, p.elapsedSec)
|
||||
assertEquals(90L, p.etaSec)
|
||||
}
|
||||
|
||||
@Test fun `upload fraction is a real pct on the Upload row`() {
|
||||
val p = DesktopState.pipelineStatus(
|
||||
uploadProgress = 0.66f, remoteKnown = false,
|
||||
hasTranscript = false, hasSummary = false, jobs = emptyList())!!
|
||||
assertEquals("Uploading recording…", p.headline)
|
||||
val u = p.stages.first { it.label == "Upload" }
|
||||
assertEquals("active", u.state)
|
||||
assertEquals(66, u.pct)
|
||||
}
|
||||
|
||||
@Test fun `engine done but app not landed shows Finalizing active`() {
|
||||
val p = DesktopState.pipelineStatus(
|
||||
uploadProgress = null, remoteKnown = true,
|
||||
hasTranscript = true, hasSummary = false,
|
||||
jobs = listOf(pjob("transcribe", "succeeded"),
|
||||
pjob("summarize", "succeeded")))!!
|
||||
assertEquals("Finalizing…", p.headline)
|
||||
assertEquals("active", p.stages.first { it.label == "Finalizing" }.state)
|
||||
}
|
||||
|
||||
@Test fun `eta withheld in the noisy edges of a run`() {
|
||||
assertNull(DesktopState.etaFrom(10, 5)) // too early to trust
|
||||
assertNull(DesktopState.etaFrom(300, 99)) // no promising the last token
|
||||
assertEquals(25L, DesktopState.etaFrom(100, 80))
|
||||
}
|
||||
|
||||
// ---- reportVoice: chips follow the saved report's own voice ----
|
||||
|
||||
@Test fun `reportVoice reads the metadata line`() {
|
||||
val md = "# X\n## Summary\n*Voice: funny*\n\ntext"
|
||||
assertEquals("funny", DesktopState.reportVoice(md))
|
||||
}
|
||||
|
||||
@Test fun `reportVoice neutral and absent map to null`() {
|
||||
assertNull(DesktopState.reportVoice("# X\n*Voice: neutral*\n"))
|
||||
assertNull(DesktopState.reportVoice("# X\nno voice line\n"))
|
||||
assertNull(DesktopState.reportVoice(null))
|
||||
}
|
||||
|
||||
@Test fun `reportVoice keeps multi-word tones intact`() {
|
||||
assertEquals("dry, witty",
|
||||
DesktopState.reportVoice("*Voice: dry, witty*"))
|
||||
}
|
||||
|
||||
// ---- DetailUi revision: mutated-in-place publishes must not conflate ----
|
||||
|
||||
@Test fun `mutated copy without rev equals stale value (the old bug)`() {
|
||||
// Documents WHY DetailUi.rev exists: the app mutates one instance
|
||||
// and republishes copy(); StateFlow would drop that assignment as
|
||||
// "no change" — the finished summary never reached the open screen.
|
||||
val d = DesktopState.DetailUi(file = java.io.File("/tmp/x.m4a"))
|
||||
val published = d.copy()
|
||||
d.summary = null // mutate the SAME instance
|
||||
assertEquals(published, d.copy())
|
||||
}
|
||||
|
||||
@Test fun `rev bump makes every republish distinct`() {
|
||||
val d = DesktopState.DetailUi(file = java.io.File("/tmp/x.m4a"))
|
||||
val before = d.copy(rev = 1L)
|
||||
d.summary = null
|
||||
val after = d.copy(rev = 2L)
|
||||
org.junit.Assert.assertNotEquals(before, after)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,11 +138,18 @@ class ReportsTest {
|
|||
assertTrue("No transcript available." in xml)
|
||||
}
|
||||
|
||||
@Test fun odtFileName() {
|
||||
val dir = Files.createTempDirectory("odt").toFile()
|
||||
@Test fun odtExportFilenamesThroughExportTo() {
|
||||
// exportTo is the only export path now; check both formats land
|
||||
// with the promised names in the chosen folder.
|
||||
val dir = Files.createTempDirectory("exp").toFile()
|
||||
try {
|
||||
val audio = File(dir, "chat.m4a")
|
||||
assertEquals("chat.report.odt", DesktopState.odtFile(audio).name)
|
||||
val state = DesktopState(Files.createTempDirectory("expstate").toFile())
|
||||
val detail = DesktopState.DetailUi(audio)
|
||||
// No data yet -> honest refusal, no file written.
|
||||
assertTrue(state.exportTo(detail, dir, "odt") != null)
|
||||
assertTrue(state.exportTo(detail, dir, "md") != null)
|
||||
assertTrue(File(dir, "chat.report.odt").exists() == false)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,13 @@ async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: S
|
|||
session, user.id, body.declared_mime_type, body.declared_size_bytes,
|
||||
body.title, body.client_recording_id, body.transcription_model,
|
||||
)
|
||||
# Commit BEFORE responding: FastAPI's get_session commits after the
|
||||
# response is sent, and the desktop client GETs /uploads/{id} the
|
||||
# instant it sees the 201 — an uncommitted row raced that follow-up
|
||||
# into a 404 "Upload status failed" (seen live Sep 18).
|
||||
await session.commit()
|
||||
except up.UploadError as e:
|
||||
await session.rollback()
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
return us
|
||||
|
||||
|
|
|
|||
|
|
@ -435,6 +435,21 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None:
|
|||
await _fail(session, rec, job, str(e), ctx, e)
|
||||
await session.commit()
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — a raw provider crash (e.g. av.InvalidDataError
|
||||
# on corrupt audio) must FAIL the job, not escape: an escaping
|
||||
# exception leaves the row 'running' and the startup sweep
|
||||
# requeues it forever (crash-loop seen live Sep 19).
|
||||
logger.exception("transcribe provider raised for recording %s", rec.id)
|
||||
job.status = JobStatus.failed
|
||||
job.error = f"Transcription crashed: {type(e).__name__}: {e}"[:500]
|
||||
job.stage = None
|
||||
job.finished_at = utcnow()
|
||||
rec.processing_status = ProcessingStatus.failed
|
||||
rec.processing_error = job.error
|
||||
await session.commit()
|
||||
return
|
||||
await store_transcript(
|
||||
session, rec, result.text, result.segments, result.language,
|
||||
provider.name, getattr(result, "model", ""),
|
||||
|
|
@ -549,6 +564,25 @@ async def run_summarize(ctx: dict, recording_id: str) -> None:
|
|||
await session.commit()
|
||||
return
|
||||
result, result_provider, fallback_note = rescue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — same reason as transcribe: a raw
|
||||
# provider crash must fail the row, not escape into the sweep loop.
|
||||
logger.exception("summarize provider raised for recording %s", rec.id)
|
||||
job.status = JobStatus.failed
|
||||
job.error = f"Summarize crashed: {type(e).__name__}: {e}"[:500]
|
||||
job.stage = None
|
||||
job.finished_at = utcnow()
|
||||
# A usable transcript survives: complete with the failure note
|
||||
# (mirrors _fail's transcript-preserving rule).
|
||||
if await latest_transcript_text(session, rec.id):
|
||||
rec.processing_status = ProcessingStatus.completed
|
||||
rec.processing_error = f"Summary failed: {job.error}"
|
||||
else:
|
||||
rec.processing_status = ProcessingStatus.failed
|
||||
rec.processing_error = job.error
|
||||
await session.commit()
|
||||
return
|
||||
await store_summary(session, rec, result.to_dict(), result_provider,
|
||||
result.model, tone=job.tone)
|
||||
job.status = JobStatus.succeeded
|
||||
|
|
|
|||
|
|
@ -251,6 +251,45 @@ async def test_config_error_fails_fast(client, monkeypatch):
|
|||
assert stt.calls == 1
|
||||
|
||||
|
||||
async def test_raw_provider_crash_fails_job_no_escape(client, monkeypatch):
|
||||
"""A non-AIError from the provider (e.g. av.InvalidDataError on corrupt
|
||||
audio) must FAIL the row, not escape: an escaping exception leaves the
|
||||
job 'running' and the startup sweep requeues it forever."""
|
||||
stt = FakeTranscriber(fail=ValueError("Invalid data found when processing input"))
|
||||
use_fakes(monkeypatch, stt, FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-crash-1")
|
||||
await processing.run_transcribe({"job_try": 1}, rec["id"]) # must NOT raise
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.transcribe].status == JobStatus.failed
|
||||
assert "ValueError" in (jobs[JobType.transcribe].error or "")
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
assert r.json()["processing_status"] == "failed"
|
||||
|
||||
|
||||
async def test_raw_summarize_crash_keeps_transcript(client, monkeypatch):
|
||||
use_fakes(monkeypatch, FakeTranscriber("fine text"), FakeLlm())
|
||||
token = await user_tokens(client)
|
||||
rec = await upload_recording(client, token, client_id="m7-crash-2")
|
||||
await processing.run_transcribe({}, rec["id"])
|
||||
|
||||
class ExplodingLlm(FakeLlm):
|
||||
async def summarize(self, transcript, **kw):
|
||||
raise RuntimeError("llm segfaulted")
|
||||
|
||||
use_fakes(monkeypatch, FakeTranscriber("fine text"), ExplodingLlm())
|
||||
await processing.run_summarize({"job_try": 1}, rec["id"]) # must NOT raise
|
||||
jobs = await jobs_for(rec["id"])
|
||||
assert jobs[JobType.summarize].status == JobStatus.failed
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
|
||||
body = r.json()
|
||||
# Transcript survived: recording completes with a summary-failure note.
|
||||
assert body["processing_status"] == "completed"
|
||||
assert "Summary failed" in body["processing_error"]
|
||||
|
||||
|
||||
# --- versioning -----------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue