Compare commits
No commits in common. "c32fc2079c44dad6086551c8b774e00e691a9871" and "a3d16a6c1801cf6cf532820df8c3238c8fd56020" have entirely different histories.
c32fc2079c
...
a3d16a6c18
8 changed files with 282 additions and 818 deletions
|
|
@ -66,16 +66,6 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
* an already-transcribed file (no live transcript in memory). */
|
* an already-transcribed file (no live transcript in memory). */
|
||||||
var reportText: String? = null,
|
var reportText: String? = null,
|
||||||
var error: 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)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
@ -922,7 +912,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
_liveProgress.value = _liveProgress.value.toMutableMap().apply {
|
_liveProgress.value = _liveProgress.value.toMutableMap().apply {
|
||||||
remove(old.name)
|
remove(old.name)
|
||||||
}
|
}
|
||||||
if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target, rev = System.nanoTime())
|
if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target)
|
||||||
rescan()
|
rescan()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -1361,17 +1351,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
// idle "Transcribe" button for a file already working.
|
// idle "Transcribe" button for a file already working.
|
||||||
if (d.remoteId == null && file.name in inFlight) {
|
if (d.remoteId == null && file.name in inFlight) {
|
||||||
d.busy = _liveProgress.value[file.name]?.label ?: "queued for transcription…"
|
d.busy = _liveProgress.value[file.name]?.label ?: "queued for transcription…"
|
||||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||||
while (file.name in inFlight && _detail.value?.file == file) {
|
while (file.name in inFlight && _detail.value?.file == file) {
|
||||||
delay(500)
|
delay(500)
|
||||||
d.busy = _liveProgress.value[file.name]?.label ?: "uploading…"
|
d.busy = _liveProgress.value[file.name]?.label ?: "uploading…"
|
||||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||||
}
|
}
|
||||||
if (_detail.value?.file != file) return@launch
|
if (_detail.value?.file != file) return@launch
|
||||||
d.busy = null
|
d.busy = null
|
||||||
d.remoteId = loadMapping(file)?.recordingId
|
d.remoteId = loadMapping(file)?.recordingId
|
||||||
d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
|
d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
|
||||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||||
}
|
}
|
||||||
// Files that predate remote-id mapping have no sidecar: adopt
|
// Files that predate remote-id mapping have no sidecar: adopt
|
||||||
// the server recording by exact title match so Re-summarize
|
// the server recording by exact title match so Re-summarize
|
||||||
|
|
@ -1396,7 +1386,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
d.transcript = t ?: d.transcript
|
d.transcript = t ?: d.transcript
|
||||||
d.summary = s
|
d.summary = s
|
||||||
saveReport(d)
|
saveReport(d)
|
||||||
if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
|
if (_detail.value?.file == file) _detail.value = d.copy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If the file is mid-pipeline (pump running, or reprocess started
|
// If the file is mid-pipeline (pump running, or reprocess started
|
||||||
|
|
@ -1422,7 +1412,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
}
|
}
|
||||||
d.jobs = jobs
|
d.jobs = jobs
|
||||||
jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null }
|
jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null }
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
val active = jobs.any { it.status == "running" || it.status == "queued" }
|
val active = jobs.any { it.status == "running" || it.status == "queued" }
|
||||||
sawActive = sawActive || active
|
sawActive = sawActive || active
|
||||||
if (!active) break
|
if (!active) break
|
||||||
|
|
@ -1446,7 +1436,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
d.busy = null
|
d.busy = null
|
||||||
d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
|
d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
|
||||||
?: d.reportText
|
?: d.reportText
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1479,7 +1469,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setOverride(model: String?) {
|
fun setOverride(model: String?) {
|
||||||
_detail.value?.let { it.overrideModel = model; _detail.value = it.copy(rev = System.nanoTime()) }
|
_detail.value?.let { it.overrideModel = model; _detail.value = it.copy() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun transcribe() {
|
fun transcribe() {
|
||||||
|
|
@ -1493,7 +1483,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (d.file.name in inFlight || isRunning(d.file.name)) {
|
if (d.file.name in inFlight || isRunning(d.file.name)) {
|
||||||
d.busy = _liveProgress.value[d.file.name]?.label ?: "Already queued…"
|
d.busy = _liveProgress.value[d.file.name]?.label ?: "Already queued…"
|
||||||
d.error = null
|
d.error = null
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A file with a known server recording re-runs the pipeline IN
|
// A file with a known server recording re-runs the pipeline IN
|
||||||
|
|
@ -1519,7 +1509,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
d.busy = "Uploading…"
|
d.busy = "Uploading…"
|
||||||
d.uploadProgress = 0f
|
d.uploadProgress = 0f
|
||||||
d.error = null
|
d.error = null
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
val draft = RecordingDraft(
|
val draft = RecordingDraft(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
title = d.file.nameWithoutExtension,
|
title = d.file.nameWithoutExtension,
|
||||||
|
|
@ -1538,12 +1528,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
// always change something on screen, immediately.
|
// always change something on screen, immediately.
|
||||||
setLive(d.file.name, LiveProgress("uploading…", p))
|
setLive(d.file.name, LiveProgress("uploading…", p))
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
}
|
}
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
d.busy = null
|
d.busy = null
|
||||||
d.error = it.message ?: "Upload failed."
|
d.error = it.message ?: "Upload failed."
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
return@run
|
return@run
|
||||||
}
|
}
|
||||||
d.remoteId = ref.key
|
d.remoteId = ref.key
|
||||||
|
|
@ -1551,7 +1541,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
title = d.file.nameWithoutExtension))
|
title = d.file.nameWithoutExtension))
|
||||||
d.busy = "Transcribing…"
|
d.busy = "Transcribing…"
|
||||||
d.uploadProgress = null
|
d.uploadProgress = null
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
// Poll jobs until the WHOLE pipeline is terminal. Breaking on
|
// Poll jobs until the WHOLE pipeline is terminal. Breaking on
|
||||||
// transcribe alone froze the screen: the last d.jobs snapshot
|
// transcribe alone froze the screen: the last d.jobs snapshot
|
||||||
// still showed summarize running at 0%, and nothing refreshed
|
// still showed summarize running at 0%, and nothing refreshed
|
||||||
|
|
@ -1567,7 +1557,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
// doesn't make the job look dead.
|
// doesn't make the job look dead.
|
||||||
setLive(d.file.name, jobProgress(jobs))
|
setLive(d.file.name, jobProgress(jobs))
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status
|
val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status
|
||||||
val s = jobs.firstOrNull { it.jobType == "summarize" }?.status
|
val s = jobs.firstOrNull { it.jobType == "summarize" }?.status
|
||||||
if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break
|
if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break
|
||||||
|
|
@ -1578,20 +1568,20 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (failed != null) {
|
if (failed != null) {
|
||||||
d.busy = null
|
d.busy = null
|
||||||
d.error = failed.error ?: "Transcription failed."
|
d.error = failed.error ?: "Transcription failed."
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
return@run
|
return@run
|
||||||
}
|
}
|
||||||
d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
|
d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull()
|
d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull()
|
||||||
d.busy = null
|
d.busy = null
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
if (d.transcript != null) {
|
if (d.transcript != null) {
|
||||||
saveReport(d)
|
saveReport(d)
|
||||||
rescan()
|
rescan()
|
||||||
} else {
|
} else {
|
||||||
d.error = "Transcription finished but no transcript was returned."
|
d.error = "Transcription finished but no transcript was returned."
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1615,7 +1605,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
else "Summarizing… 0%"
|
else "Summarizing… 0%"
|
||||||
d0.uploadProgress = null
|
d0.uploadProgress = null
|
||||||
d0.error = null
|
d0.error = null
|
||||||
_detail.value = d0.copy(rev = System.nanoTime())
|
_detail.value = d0.copy()
|
||||||
runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure {
|
runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure {
|
||||||
if (job == "transcribe" && it is ProviderError.NotFound) {
|
if (job == "transcribe" && it is ProviderError.NotFound) {
|
||||||
// Mapping points at a deleted recording: drop it so the
|
// Mapping points at a deleted recording: drop it so the
|
||||||
|
|
@ -1624,7 +1614,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
d0.remoteId = null
|
d0.remoteId = null
|
||||||
d0.busy = null
|
d0.busy = null
|
||||||
d0.error = "That server recording no longer exists — press Transcribe to upload again."
|
d0.error = "That server recording no longer exists — press Transcribe to upload again."
|
||||||
_detail.value = d0.copy(rev = System.nanoTime())
|
_detail.value = d0.copy()
|
||||||
return@launch
|
return@launch
|
||||||
} else if (it is ProviderError.Transient &&
|
} else if (it is ProviderError.Transient &&
|
||||||
it.message?.contains("already running") == true) {
|
it.message?.contains("already running") == true) {
|
||||||
|
|
@ -1638,34 +1628,15 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
} else {
|
} else {
|
||||||
d0.busy = null
|
d0.busy = null
|
||||||
d0.error = it.message ?: "Reprocess failed."
|
d0.error = it.message ?: "Reprocess failed."
|
||||||
_detail.value = d0.copy(rev = System.nanoTime())
|
_detail.value = d0.copy()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Poll the target job to a terminal state. A job this long
|
// Poll the target job to a terminal state.
|
||||||
// (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) {
|
while (true) {
|
||||||
delay(2000)
|
delay(2000)
|
||||||
val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) }
|
val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull()
|
||||||
if (fetched.isFailure) {
|
.orEmpty()
|
||||||
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
|
d0.jobs = jobs
|
||||||
jobLabel(jobs)?.let { d0.busy = it }
|
jobLabel(jobs)?.let { d0.busy = it }
|
||||||
// Mirror live progress onto the library row too — otherwise
|
// Mirror live progress onto the library row too — otherwise
|
||||||
|
|
@ -1673,12 +1644,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
// detail screen (the pump path already does this via setLive).
|
// detail screen (the pump path already does this via setLive).
|
||||||
setLive(d0.file.name, jobProgress(jobs))
|
setLive(d0.file.name, jobProgress(jobs))
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
_detail.value = d0.copy(rev = System.nanoTime())
|
_detail.value = d0.copy()
|
||||||
val st = jobs.firstOrNull { it.jobType == job }?.status
|
val st = jobs.firstOrNull { it.jobType == job }?.status
|
||||||
if (st in TERMINAL) break
|
if (st == null || 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)
|
setLive(d0.file.name, null)
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
|
|
@ -1686,7 +1654,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (failed != null) {
|
if (failed != null) {
|
||||||
d0.busy = null
|
d0.busy = null
|
||||||
d0.error = failed.error ?: "That stage failed."
|
d0.error = failed.error ?: "That stage failed."
|
||||||
_detail.value = d0.copy(rev = System.nanoTime())
|
_detail.value = d0.copy()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) }
|
d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) }
|
||||||
|
|
@ -1694,7 +1662,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) }
|
d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
d0.busy = null
|
d0.busy = null
|
||||||
_detail.value = d0.copy(rev = System.nanoTime())
|
_detail.value = d0.copy()
|
||||||
d0.summary?.let { announceSummaryReady(d0.file, it.version) }
|
d0.summary?.let { announceSummaryReady(d0.file, it.version) }
|
||||||
if (d0.transcript != null || d0.summary != null) {
|
if (d0.transcript != null || d0.summary != null) {
|
||||||
saveReport(d0)
|
saveReport(d0)
|
||||||
|
|
@ -1720,7 +1688,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
// opened for a since-renamed/deleted file). Say what's wrong.
|
// opened for a since-renamed/deleted file). Say what's wrong.
|
||||||
d.error = "This file has no server recording to summarize — " +
|
d.error = "This file has no server recording to summarize — " +
|
||||||
"press Transcribe to upload it first."
|
"press Transcribe to upload it first."
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.remoteId = remoteId
|
d.remoteId = remoteId
|
||||||
|
|
@ -1740,7 +1708,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (remoteId == null) {
|
if (remoteId == null) {
|
||||||
d.error = "This file has no server recording to save against — " +
|
d.error = "This file has no server recording to save against — " +
|
||||||
"press Transcribe to upload it first."
|
"press Transcribe to upload it first."
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.remoteId = remoteId
|
d.remoteId = remoteId
|
||||||
|
|
@ -1748,7 +1716,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
d.busy = "Saving transcript…"
|
d.busy = "Saving transcript…"
|
||||||
d.error = null
|
d.error = null
|
||||||
_detail.value = d.copy(rev = System.nanoTime())
|
_detail.value = d.copy()
|
||||||
runCatching { parseTranscript(provider.updateTranscript(remoteId,
|
runCatching { parseTranscript(provider.updateTranscript(remoteId,
|
||||||
org.json.JSONObject().put("text", text).toString())) }
|
org.json.JSONObject().put("text", text).toString())) }
|
||||||
.onSuccess { saved ->
|
.onSuccess { saved ->
|
||||||
|
|
@ -1757,13 +1725,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
saveReport(d)
|
saveReport(d)
|
||||||
}
|
}
|
||||||
d.busy = null
|
d.busy = null
|
||||||
if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime())
|
if (_detail.value?.file == d.file) _detail.value = d.copy()
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
d.busy = null
|
d.busy = null
|
||||||
d.error = it.message ?: "Transcript save failed."
|
d.error = it.message ?: "Transcript save failed."
|
||||||
if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime())
|
if (_detail.value?.file == d.file) _detail.value = d.copy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1776,40 +1744,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary))
|
reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Export summary + transcript into [dir] in "md" or "odt" format.
|
/** Write the .odt export next to the audio (same base name as the
|
||||||
* Returns null on success (caller shows the path) or a human
|
* .md report, .report.odt extension). Returns null on success or a
|
||||||
* reason it failed. Never touches the library's own report file. */
|
* human reason it failed. */
|
||||||
fun exportTo(d: DetailUi, dir: File, format: String): String? {
|
fun exportOdt(d: DetailUi): String? {
|
||||||
if (!dir.isDirectory) return "That folder no longer exists."
|
val out = odtFile(d.file)
|
||||||
val base = d.file.nameWithoutExtension
|
return runCatching {
|
||||||
val report = d.reportText
|
out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary))
|
||||||
return when (format) {
|
}.fold(
|
||||||
"md" -> {
|
onSuccess = { null },
|
||||||
val out = File(dir, "$base.transcript.md")
|
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
|
||||||
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 {
|
companion object {
|
||||||
|
|
@ -1841,20 +1786,6 @@ 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
|
/** " · 1:23" of wait time since a job started running, "" when
|
||||||
* under 5s or the start time is unknown. The llama-swap backend
|
* under 5s or the start time is unknown. The llama-swap backend
|
||||||
* exposes no queue depth, so elapsed wait is the most truthful
|
* exposes no queue depth, so elapsed wait is the most truthful
|
||||||
|
|
@ -1876,134 +1807,6 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
/** Label-only convenience for the Detail screen's busy text. */
|
/** Label-only convenience for the Detail screen's busy text. */
|
||||||
fun jobLabel(jobs: List<JobInfo>): String? = jobProgress(jobs)?.label
|
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_URL = "server.url"
|
||||||
const val KEY_FOLDER = "library.folder"
|
const val KEY_FOLDER = "library.folder"
|
||||||
const val KEY_PASSWORD = "local.password"
|
const val KEY_PASSWORD = "local.password"
|
||||||
|
|
@ -2054,6 +1857,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
|
|
||||||
fun reportFile(audio: File): File =
|
fun reportFile(audio: File): File =
|
||||||
File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md")
|
File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md")
|
||||||
|
|
||||||
|
fun odtFile(audio: File): File =
|
||||||
|
File(audio.parentFile, "${audio.nameWithoutExtension}.report.odt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2067,27 +1873,3 @@ data class SearchHit(
|
||||||
|
|
||||||
/** What the pipeline is doing for a library file right now. */
|
/** What the pipeline is doing for a library file right now. */
|
||||||
data class LiveProgress(val label: String, val fraction: Float?)
|
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,8 +9,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
|
@ -152,39 +155,41 @@ fun main() = application {
|
||||||
val summaryToast by state.summaryToast.collectAsState()
|
val summaryToast by state.summaryToast.collectAsState()
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
summaryToast?.let { t ->
|
summaryToast?.let { t ->
|
||||||
SonarCard(
|
Card(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||||
) {
|
) {
|
||||||
Row(Modifier.fillMaxWidth()
|
Row(Modifier.fillMaxWidth()
|
||||||
.padding(horizontal = Shonar.CardPadding),
|
.padding(horizontal = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(t.label,
|
Text(t.label,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.weight(1f))
|
modifier = Modifier.weight(1f))
|
||||||
ShonarGhostButton({ state.viewSummaryReady(t) }) {
|
TextButton({ state.viewSummaryReady(t) }) {
|
||||||
Text("View")
|
Text("View")
|
||||||
}
|
}
|
||||||
ShonarGhostButton({ state.dismissSummaryToast() }) {
|
TextButton({ state.dismissSummaryToast() }) {
|
||||||
Text("✕")
|
Text("✕")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(Shonar.Space2))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
undoToast?.let { t ->
|
undoToast?.let { t ->
|
||||||
SonarCard(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth()
|
Row(Modifier.fillMaxWidth()
|
||||||
.padding(horizontal = Shonar.CardPadding),
|
.padding(horizontal = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(t.label,
|
Text(t.label,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.weight(1f))
|
modifier = Modifier.weight(1f))
|
||||||
ShonarGhostButton({ state.undoLastTrash() }) {
|
TextButton({ state.undoLastTrash() }) {
|
||||||
Text("Undo")
|
Text("Undo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(Shonar.Space2))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
when (screen) {
|
when (screen) {
|
||||||
DesktopState.Screen.ENGINE -> EngineScreen(state)
|
DesktopState.Screen.ENGINE -> EngineScreen(state)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.shonar.desktop
|
package com.shonar.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -24,16 +25,21 @@ import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.AlertDialog
|
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.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.LaunchedEffect
|
||||||
|
|
@ -76,9 +82,9 @@ fun EngineScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShonarButton({ state.startEngine() }) { Text("Start engine") }
|
Button({ state.startEngine() }) { Text("Start engine") }
|
||||||
ShonarOutlinedButton({ state.ensureReady() }) { Text("Retry") }
|
OutlinedButton({ state.ensureReady() }) { Text("Retry") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,11 +141,11 @@ fun FolderPickerDialog(
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
// Breadcrumb row: home + each step; click to jump.
|
// Breadcrumb row: home + each step; click to jump.
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
ShonarGhostButton({ current = home }) { Text("Home") }
|
TextButton({ current = home }) { Text("Home") }
|
||||||
crumbs.dropWhile { it.absolutePath == home.absolutePath }
|
crumbs.dropWhile { it.absolutePath == home.absolutePath }
|
||||||
.forEach { d ->
|
.forEach { d ->
|
||||||
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
ShonarGhostButton({ current = d }) {
|
TextButton({ current = d }) {
|
||||||
Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1)
|
Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +155,7 @@ fun FolderPickerDialog(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
if (current.parentFile != null) {
|
if (current.parentFile != null) {
|
||||||
ShonarGhostButton(
|
TextButton(
|
||||||
{ current.parentFile?.let { current = it } },
|
{ current.parentFile?.let { current = it } },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("⋱ .. (go up)") }
|
) { Text("⋱ .. (go up)") }
|
||||||
|
|
@ -161,31 +167,31 @@ fun FolderPickerDialog(
|
||||||
}
|
}
|
||||||
LazyColumn(Modifier.weight(1f)) {
|
LazyColumn(Modifier.weight(1f)) {
|
||||||
items(subs, key = { it.absolutePath }) { d ->
|
items(subs, key = { it.absolutePath }) { d ->
|
||||||
SonarCard(Modifier.fillMaxWidth(),
|
Card(Modifier.fillMaxWidth().clickable { current = d }) {
|
||||||
onClick = { current = d }) {
|
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp,
|
||||||
Row(Modifier.fillMaxWidth().padding(horizontal = Shonar.CardPadding,
|
vertical = 8.dp),
|
||||||
vertical = Shonar.Space2),
|
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("▸", color = MaterialTheme.colorScheme.primary)
|
Text("▸", color = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.width(Shonar.Space2))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(d.name, style = MaterialTheme.typography.bodyMedium)
|
Text(d.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(Shonar.Space1))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
error?.let {
|
error?.let {
|
||||||
HelperText(it, error = true)
|
Text(it, color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
ShonarGhostButton({
|
TextButton({
|
||||||
if (current.isDirectory) onResult(current)
|
if (current.isDirectory) onResult(current)
|
||||||
else error = "That folder no longer exists."
|
else error = "That folder no longer exists."
|
||||||
}) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") }
|
}) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") }
|
||||||
},
|
},
|
||||||
dismissButton = { ShonarGhostButton({ onResult(null) }) { Text("Cancel") } },
|
dismissButton = { TextButton({ onResult(null) }) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -546,83 +552,6 @@ 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 -----------------------------------------------------------------
|
// ---- detail -----------------------------------------------------------------
|
||||||
|
|
||||||
/** Personalities offered beside Re-summarize: (label, tone sent to the
|
/** Personalities offered beside Re-summarize: (label, tone sent to the
|
||||||
|
|
@ -657,7 +586,6 @@ fun DetailScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
var savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
|
var savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
|
||||||
var odtNote by remember(detail.file.name) { mutableStateOf<String?>(null) }
|
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.
|
// The save (or its failure) clears busy — release the button lock.
|
||||||
LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false }
|
LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false }
|
||||||
val playingFile by state.playingFile.collectAsState()
|
val playingFile by state.playingFile.collectAsState()
|
||||||
|
|
@ -683,11 +611,6 @@ fun DetailScreen(state: DesktopState) {
|
||||||
val chipTone: String? = when {
|
val chipTone: String? = when {
|
||||||
sJobNow != null && sJobNow.status in setOf("queued", "running") -> sJobNow.tone
|
sJobNow != null && sJobNow.status in setOf("queued", "running") -> sJobNow.tone
|
||||||
sSummary != null -> sSummary.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
|
else -> sumTone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,28 +619,29 @@ fun DetailScreen(state: DesktopState) {
|
||||||
// the complaint. Everything below scrolls as one flow.
|
// the complaint. Everything below scrolls as one flow.
|
||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
||||||
Text(detail.file.nameWithoutExtension,
|
Text(detail.file.nameWithoutExtension,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.weight(1f))
|
modifier = Modifier.weight(1f))
|
||||||
// Page through recordings without returning to the list
|
// Page through recordings without returning to the list
|
||||||
// (Alt+←/→ do the same). Disabled honestly at the ends.
|
// (Alt+←/→ do the same). Disabled honestly at the ends.
|
||||||
ShonarGhostButton(
|
TextButton(
|
||||||
onClick = { state.neighborOf(detail.file, forward = false)
|
onClick = { state.neighborOf(detail.file, forward = false)
|
||||||
?.let(state::openDetail) },
|
?.let(state::openDetail) },
|
||||||
enabled = state.neighborOf(detail.file, forward = false) != null,
|
enabled = state.neighborOf(detail.file, forward = false) != null,
|
||||||
) { Text("← Prev") }
|
) { Text("← Prev") }
|
||||||
ShonarGhostButton(
|
TextButton(
|
||||||
onClick = { state.neighborOf(detail.file, forward = true)
|
onClick = { state.neighborOf(detail.file, forward = true)
|
||||||
?.let(state::openDetail) },
|
?.let(state::openDetail) },
|
||||||
enabled = state.neighborOf(detail.file, forward = true) != null,
|
enabled = state.neighborOf(detail.file, forward = true) != null,
|
||||||
) { Text("Next →") }
|
) { Text("Next →") }
|
||||||
ShonarDangerButton(
|
TextButton(
|
||||||
{ confirmDelete = true },
|
{ confirmDelete = true },
|
||||||
enabled = detail.busy == null,
|
enabled = detail.busy == null,
|
||||||
) {
|
content = {
|
||||||
Text("Delete")
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Column(Modifier.fillMaxWidth().weight(1f)
|
Column(Modifier.fillMaxWidth().weight(1f)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
|
|
@ -733,10 +657,10 @@ fun DetailScreen(state: DesktopState) {
|
||||||
// One Stop control, always present (the pause/resume pill
|
// One Stop control, always present (the pause/resume pill
|
||||||
// was redundant: Stop already ends playback, and the
|
// was redundant: Stop already ends playback, and the
|
||||||
// separate Stop text button duplicated it).
|
// separate Stop text button duplicated it).
|
||||||
ShonarButton({ state.stopPlayback() }) {
|
Button({ state.stopPlayback() }) {
|
||||||
Icon(Icons.Filled.Stop, contentDescription = null,
|
Icon(Icons.Filled.Stop, contentDescription = null,
|
||||||
modifier = Modifier.size(Shonar.IconSm))
|
modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(Shonar.Space2))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text("Stop")
|
Text("Stop")
|
||||||
}
|
}
|
||||||
if (transport && ps.durationSec > 0) {
|
if (transport && ps.durationSec > 0) {
|
||||||
|
|
@ -755,27 +679,23 @@ fun DetailScreen(state: DesktopState) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ShonarOutlinedButton({ state.playFrom(detail.file) }) {
|
OutlinedButton({ state.playFrom(detail.file) }) {
|
||||||
Icon(Icons.Filled.PlayArrow, contentDescription = null,
|
Icon(Icons.Filled.PlayArrow, contentDescription = null,
|
||||||
modifier = Modifier.size(Shonar.IconSm))
|
modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(Shonar.Space2))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text("Play")
|
Text("Play")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
playError?.let {
|
playError?.let {
|
||||||
HelperText(it, error = true)
|
Text(it, color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Search within this recording: hits jump the audio to that moment.
|
// Search within this recording: hits jump the audio to that moment.
|
||||||
if (transcriptLines.isNotEmpty()) {
|
if (transcriptLines.isNotEmpty()) {
|
||||||
SonarField(recQuery, { recQuery = it },
|
OutlinedTextField(recQuery, { recQuery = it },
|
||||||
placeholder = "Search this recording…",
|
label = { Text("Search this recording") },
|
||||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||||
leadingIcon = {
|
|
||||||
Icon(Icons.Filled.Search, contentDescription = null,
|
|
||||||
tint = Shonar.TextSecondary,
|
|
||||||
modifier = Modifier.size(Shonar.IconSm))
|
|
||||||
})
|
|
||||||
if (recQuery.isNotBlank()) {
|
if (recQuery.isNotBlank()) {
|
||||||
if (recHits.isEmpty()) {
|
if (recHits.isEmpty()) {
|
||||||
Text("No matches in this recording.",
|
Text("No matches in this recording.",
|
||||||
|
|
@ -787,20 +707,20 @@ fun DetailScreen(state: DesktopState) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxWidth().heightIn(max = 260.dp),
|
Modifier.fillMaxWidth().heightIn(max = 260.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(Shonar.Space1),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
items(recHits) { hit ->
|
items(recHits) { hit ->
|
||||||
SonarCard(Modifier.fillMaxWidth(),
|
Card(Modifier.fillMaxWidth().clickable {
|
||||||
onClick = { state.playFrom(detail.file, hit.startSec) }) {
|
state.playFrom(detail.file, hit.startSec)
|
||||||
Row(Modifier.padding(Shonar.Space2, Shonar.Space2),
|
}) {
|
||||||
|
Row(Modifier.padding(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(fmtTs(hit.startSec),
|
Text(fmtTs(hit.startSec),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = Shonar.Accent)
|
color = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.width(Shonar.Space3))
|
Spacer(Modifier.width(10.dp))
|
||||||
Text(hit.text,
|
Text(hit.text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Shonar.TextPrimary,
|
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f))
|
modifier = Modifier.weight(1f))
|
||||||
|
|
@ -816,7 +736,7 @@ fun DetailScreen(state: DesktopState) {
|
||||||
Text("Model: ", style = MaterialTheme.typography.bodyMedium)
|
Text("Model: ", style = MaterialTheme.typography.bodyMedium)
|
||||||
val current = detail.overrideModel ?: models?.defaultModel ?: "base"
|
val current = detail.overrideModel ?: models?.defaultModel ?: "base"
|
||||||
val currentLabel = models?.byName(current)?.displayName ?: current
|
val currentLabel = models?.byName(current)?.displayName ?: current
|
||||||
ShonarGhostButton({ showModels = !showModels }) {
|
TextButton({ showModels = !showModels }) {
|
||||||
Text(if (detail.overrideModel == null)
|
Text(if (detail.overrideModel == null)
|
||||||
"$currentLabel (default)" else currentLabel)
|
"$currentLabel (default)" else currentLabel)
|
||||||
}
|
}
|
||||||
|
|
@ -833,33 +753,76 @@ fun DetailScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
// Processing pipeline: real stages only. Each row flips when the
|
detail.uploadProgress?.let {
|
||||||
// engine actually moves; percentages appear only where the backend
|
LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth())
|
||||||
// measures distance (upload bytes, transcribe audio-time). No
|
Text("Uploading… ${(it * 100).toInt()}%")
|
||||||
// decorative motion pretending to be progress.
|
}
|
||||||
val pipeline = DesktopState.pipelineStatus(
|
detail.busy?.takeIf { detail.uploadProgress == null }?.let {
|
||||||
uploadProgress = detail.uploadProgress,
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
remoteKnown = state.remoteIdFor(detail.file) != null,
|
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||||
hasTranscript = detail.transcript != null,
|
Spacer(Modifier.width(10.dp))
|
||||||
hasSummary = detail.summary != null,
|
Text(it)
|
||||||
jobs = detail.jobs,
|
}
|
||||||
)
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
||||||
if (pipeline != null) {
|
sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let {
|
||||||
PipelinePanel(pipeline)
|
// Honest milestone progress: the engine counts completed
|
||||||
} else if (detail.busy != null) {
|
// contract units (JSON keys/items finished), so the number
|
||||||
// Click-landed but no job row yet (POST/token/poll latency):
|
// means real work done — a determinate bar is truthful now.
|
||||||
// busy is REAL work in flight — showing nothing here was the
|
val toneWord = sJob.tone?.lowercase()?.replace(",", "")
|
||||||
// "hit transcribe, screen looks dead" blind window.
|
?.takeIf { it.isNotBlank() && it != "neutral" }
|
||||||
SonarCard(modifier = Modifier.fillMaxWidth()) {
|
val label = when {
|
||||||
Row(
|
it.status == "running" && it.progress != null && it.progress > 0 ->
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
"Summarizing" + (toneWord?.let { t -> " $t" } ?: "") +
|
||||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2),
|
"… ${it.progress}%" +
|
||||||
) {
|
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
|
||||||
Text("→", style = MaterialTheme.typography.bodySmall,
|
it.status == "running" -> "Summarizing… 0% waiting for model" +
|
||||||
color = MaterialTheme.colorScheme.primary)
|
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
|
||||||
Text(detail.busy!!, style = MaterialTheme.typography.bodySmall,
|
else -> "Summary queued" +
|
||||||
color = MaterialTheme.colorScheme.primary)
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -889,21 +852,18 @@ fun DetailScreen(state: DesktopState) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
if (detail.busy == null) {
|
if (detail.busy == null) {
|
||||||
ShonarButton({ state.summarize() }) { Text("Retry summary") }
|
Button({ state.summarize() }) { Text("Retry summary") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
detail.error?.let {
|
detail.error?.let {
|
||||||
HelperText(it, error = true)
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
val tFailed = detail.jobs.firstOrNull { j ->
|
if (tJob?.status == "failed") {
|
||||||
j.jobType == "transcribe" && j.status == "failed"
|
Button({ state.retry() }) { Text("Retry transcription") }
|
||||||
}
|
|
||||||
if (tFailed != null) {
|
|
||||||
ShonarButton({ state.retry() }) { Text("Retry transcription") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (detail.busy == null && detail.transcript == null &&
|
if (detail.busy == null && detail.transcript == null &&
|
||||||
detail.reportText == null && detail.error == null) {
|
detail.reportText == null && detail.error == null) {
|
||||||
ShonarButton({ state.transcribe() }) { Text("Transcribe") }
|
Button({ state.transcribe() }) { Text("Transcribe") }
|
||||||
}
|
}
|
||||||
// Summary ABOVE the transcript: a recording with a long transcript
|
// Summary ABOVE the transcript: a recording with a long transcript
|
||||||
// pushed it below the fold (the transcript region is the only
|
// pushed it below the fold (the transcript region is the only
|
||||||
|
|
@ -930,14 +890,14 @@ fun DetailScreen(state: DesktopState) {
|
||||||
var copiedNote by remember(detail.file.name) {
|
var copiedNote by remember(detail.file.name) {
|
||||||
mutableStateOf<String?>(null)
|
mutableStateOf<String?>(null)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShonarGhostButton({
|
TextButton({
|
||||||
clipboard.setText(AnnotatedString(s.short))
|
clipboard.setText(AnnotatedString(s.short))
|
||||||
copiedNote = "Summary copied"
|
copiedNote = "Summary copied"
|
||||||
}) { Text("Copy summary") }
|
}) { Text("Copy summary") }
|
||||||
val actionItems = s.list("action_items")
|
val actionItems = s.list("action_items")
|
||||||
if (actionItems.isNotEmpty()) {
|
if (actionItems.isNotEmpty()) {
|
||||||
ShonarGhostButton({
|
TextButton({
|
||||||
clipboard.setText(AnnotatedString(
|
clipboard.setText(AnnotatedString(
|
||||||
actionItems.joinToString("\n") { "- $it" }))
|
actionItems.joinToString("\n") { "- $it" }))
|
||||||
copiedNote = "Action items copied"
|
copiedNote = "Action items copied"
|
||||||
|
|
@ -960,25 +920,27 @@ fun DetailScreen(state: DesktopState) {
|
||||||
val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
||||||
?.status in setOf("queued", "running")
|
?.status in setOf("queued", "running")
|
||||||
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
|
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text("Re-summarize in the voice of",
|
Text("Re-summarize in the voice of",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
SUMMARIZE_PERSONAS.forEach { (label, tone) ->
|
SUMMARIZE_PERSONAS.forEach { (label, tone) ->
|
||||||
PillChip(
|
FilterChip(
|
||||||
selected = chipTone == tone,
|
selected = chipTone == tone,
|
||||||
enabled = voicesEnabled,
|
enabled = voicesEnabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
sumTone = tone
|
sumTone = tone
|
||||||
state.summarize(tone)
|
state.summarize(tone)
|
||||||
},
|
},
|
||||||
label = label,
|
label = { Text(label) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!voicesEnabled && !remoteKnown) {
|
if (!voicesEnabled && !remoteKnown) {
|
||||||
HelperText("Summarize unlocks once this file is uploaded (press Transcribe).")
|
Text("Summarize unlocks once this file is uploaded (press Transcribe).",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1023,52 +985,68 @@ fun DetailScreen(state: DesktopState) {
|
||||||
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
|
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
|
||||||
if (detail.transcript == null) {
|
if (detail.transcript == null) {
|
||||||
// Saved-report path: chips here, same rules.
|
// Saved-report path: chips here, same rules.
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(if (hasSummary) "Re-summarize in the voice of"
|
Text(if (hasSummary) "Re-summarize in the voice of"
|
||||||
else "Summarize in the voice of",
|
else "Summarize in the voice of",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
SUMMARIZE_PERSONAS.forEach { (label, tone) ->
|
SUMMARIZE_PERSONAS.forEach { (label, tone) ->
|
||||||
PillChip(
|
FilterChip(
|
||||||
selected = chipTone == tone,
|
selected = chipTone == tone,
|
||||||
enabled = voicesEnabled,
|
enabled = voicesEnabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
sumTone = tone
|
sumTone = tone
|
||||||
state.summarize(tone)
|
state.summarize(tone)
|
||||||
},
|
},
|
||||||
label = label,
|
label = { Text(label) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShonarGhostButton({ state.transcribe() }) {
|
TextButton({ state.transcribe() }) {
|
||||||
Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
|
Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
|
||||||
}
|
}
|
||||||
// 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
|
// Summarize progress right where the buttons are: a running
|
||||||
// mirroring the pipeline panel's Summary stage (the panel may
|
// summarize must be visible next to the chips that started it.
|
||||||
// be scrolled out of view when the chips get clicked). No
|
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
||||||
// bar animation: the engine reports stage, not distance.
|
|
||||||
val sRunning = sJob?.status in setOf("queued", "running")
|
val sRunning = sJob?.status in setOf("queued", "running")
|
||||||
if (sRunning && pipeline != null) {
|
if (sRunning) {
|
||||||
val sStage = pipeline.stages.firstOrNull { it.label == "Summary" }
|
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())
|
||||||
|
}
|
||||||
Text(buildString {
|
Text(buildString {
|
||||||
append("→ ").append(sStage?.note
|
append(when {
|
||||||
?: if (sJob?.status == "queued") "queued on the summarizer"
|
sStatus == "queued" -> "Queued…" +
|
||||||
else "writing")
|
DesktopState.waitSuffix(sJob?.createdAt,
|
||||||
pipeline.elapsedSec?.let { append(" · ${DesktopState.fmtMMSS(it)} elapsed") }
|
System.currentTimeMillis())
|
||||||
(sJob?.attempt ?: 0).let {
|
sProg != null && sProg > 0 ->
|
||||||
if (it > 0) append(" (attempt $it/${sJob?.maxAttempts})")
|
"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})")
|
||||||
}, style = MaterialTheme.typography.bodySmall,
|
}, style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary)
|
color = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
|
|
@ -1094,7 +1072,7 @@ fun DetailScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (editDraft == null && remoteKnownForEdit && detail.busy == null) {
|
if (editDraft == null && remoteKnownForEdit && detail.busy == null) {
|
||||||
ShonarGhostButton({
|
TextButton({
|
||||||
editDraft = if (t.segments.isNotEmpty())
|
editDraft = if (t.segments.isNotEmpty())
|
||||||
t.segments.joinToString("\n") { it.text } else t.text
|
t.segments.joinToString("\n") { it.text } else t.text
|
||||||
}) { Text("Edit") }
|
}) { Text("Edit") }
|
||||||
|
|
@ -1116,22 +1094,20 @@ fun DetailScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SonarField(
|
OutlinedTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { editDraft = it },
|
onValueChange = { editDraft = it },
|
||||||
placeholder = "Correct the words…",
|
|
||||||
singleLine = false,
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp),
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShonarButton(
|
Button(
|
||||||
enabled = draft.isNotBlank() && !savingTranscript,
|
enabled = draft.isNotBlank() && !savingTranscript,
|
||||||
onClick = {
|
onClick = {
|
||||||
savingTranscript = true
|
savingTranscript = true
|
||||||
state.saveTranscriptEdit(draft)
|
state.saveTranscriptEdit(draft)
|
||||||
},
|
},
|
||||||
) { Text(if (savingTranscript) "Saving…" else "Save") }
|
) { Text(if (savingTranscript) "Saving…" else "Save") }
|
||||||
ShonarOutlinedButton(
|
OutlinedButton(
|
||||||
enabled = !savingTranscript,
|
enabled = !savingTranscript,
|
||||||
onClick = { editDraft = null },
|
onClick = { editDraft = null },
|
||||||
) { Text("Cancel") }
|
) { Text("Cancel") }
|
||||||
|
|
@ -1162,27 +1138,19 @@ fun DetailScreen(state: DesktopState) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
// Export: pick a folder and a format — the summary + transcript
|
// Export lives once for both paths (live transcript or saved report).
|
||||||
// leave the library as a file you can keep anywhere.
|
|
||||||
if (detail.transcript != null || detail.reportText != null) {
|
if (detail.transcript != null || detail.reportText != null) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically,
|
Row(verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShonarOutlinedButton(onClick = { showExport = true }) {
|
OutlinedButton(onClick = {
|
||||||
Text("Export…")
|
odtNote = state.exportOdt(detail)
|
||||||
}
|
?: "Saved to ${detail.file.nameWithoutExtension}.report.odt"
|
||||||
|
}) { Text("Export .odt") }
|
||||||
odtNote?.let {
|
odtNote?.let {
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall,
|
Text(it, style = MaterialTheme.typography.bodySmall,
|
||||||
color = Shonar.Accent)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
// (Voice chips + Re-transcribe + summarize progress live above the
|
||||||
// transcript, right under the summary.)
|
// transcript, right under the summary.)
|
||||||
|
|
@ -1214,14 +1182,12 @@ private fun ModelRow(
|
||||||
) {
|
) {
|
||||||
val selected = detail.overrideModel == value ||
|
val selected = detail.overrideModel == value ||
|
||||||
(value == null && detail.overrideModel == null)
|
(value == null && detail.overrideModel == null)
|
||||||
SonarCard(
|
Card(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth().clickable { state.setOverride(value) },
|
||||||
onClick = { state.setOverride(value) },
|
|
||||||
) {
|
) {
|
||||||
Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
|
Row(Modifier.fillMaxWidth().padding(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(if (selected) "◉ " else "○ ",
|
Text(if (selected) "◉ " else "○ ")
|
||||||
color = if (selected) Shonar.Accent else Shonar.TextMuted)
|
|
||||||
Column {
|
Column {
|
||||||
Text(title, style = MaterialTheme.typography.bodyMedium)
|
Text(title, style = MaterialTheme.typography.bodyMedium)
|
||||||
if (subtitle.isNotBlank()) {
|
if (subtitle.isNotBlank()) {
|
||||||
|
|
@ -1249,36 +1215,38 @@ fun SettingsScreen(state: DesktopState) {
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineSmall)
|
Text("Settings", style = MaterialTheme.typography.headlineSmall)
|
||||||
}
|
}
|
||||||
Text("Local engine: $url", style = MaterialTheme.typography.bodySmall)
|
Text("Local engine: $url", style = MaterialTheme.typography.bodySmall)
|
||||||
SectionHeader("Recordings folder")
|
Text("Recordings folder", style = MaterialTheme.typography.titleMedium)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically,
|
Row(verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(folder?.absolutePath ?: "No folder chosen",
|
Text(folder?.absolutePath ?: "No folder chosen",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f))
|
modifier = Modifier.weight(1f))
|
||||||
ShonarOutlinedButton({ showPicker = true }) {
|
OutlinedButton({ showPicker = true }) {
|
||||||
Text(if (folder == null) "Choose folder…" else "Change folder…")
|
Text(if (folder == null) "Choose folder…" else "Change folder…")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(Shonar.Space1))
|
Spacer(Modifier.height(4.dp))
|
||||||
SectionHeader("Summarizer")
|
Text("Summarizer", style = MaterialTheme.typography.titleMedium)
|
||||||
HelperText("Where summaries are written. Restarting the engine applies " +
|
Text("Where summaries are written. Restarting the engine applies " +
|
||||||
"the choice; work already running carries over.")
|
"the choice; work already running carries over.",
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
PillChip(selected = summarizer == "auto",
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(selected = summarizer == "auto",
|
||||||
onClick = { state.setSummarizer("auto") },
|
onClick = { state.setSummarizer("auto") },
|
||||||
label = "Auto")
|
label = { Text("Auto") })
|
||||||
PillChip(selected = summarizer == "lan",
|
FilterChip(selected = summarizer == "lan",
|
||||||
onClick = { state.setSummarizer("lan") },
|
onClick = { state.setSummarizer("lan") },
|
||||||
label = "LAN server (H200)")
|
label = { Text("LAN server (H200)") })
|
||||||
PillChip(selected = summarizer == "local",
|
FilterChip(selected = summarizer == "local",
|
||||||
onClick = { state.setSummarizer("local") },
|
onClick = { state.setSummarizer("local") },
|
||||||
label = "This laptop (Ollama)")
|
label = { Text("This laptop (Ollama)") })
|
||||||
}
|
}
|
||||||
Text(when (summarizer) {
|
Text(when (summarizer) {
|
||||||
"lan" -> "Best quality; shares the H200 with other clients — " +
|
"lan" -> "Best quality; shares the H200 with other clients — " +
|
||||||
|
|
@ -1293,9 +1261,11 @@ fun SettingsScreen(state: DesktopState) {
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Spacer(Modifier.height(Shonar.Space1))
|
Spacer(Modifier.height(4.dp))
|
||||||
SectionHeader("Default transcription model")
|
Text("Default transcription model", style = MaterialTheme.typography.titleMedium)
|
||||||
HelperText("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,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
if (models == null) {
|
if (models == null) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||||
|
|
@ -1309,24 +1279,22 @@ fun SettingsScreen(state: DesktopState) {
|
||||||
color = MaterialTheme.colorScheme.error)
|
color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
models.models.forEach { m ->
|
models.models.forEach { m ->
|
||||||
SonarCard(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(Shonar.CardPadding),
|
Column(Modifier.padding(10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
|
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
(if (m.isDefault) "◉ " else "○ ") + m.displayName,
|
(if (m.isDefault) "◉ " else "○ ") + m.displayName,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (m.isDefault) Shonar.Accent
|
|
||||||
else Shonar.TextPrimary,
|
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (!m.isDefault) {
|
if (!m.isDefault) {
|
||||||
ShonarGhostButton({ state.setDefaultModel(m.name) }) {
|
TextButton({ state.setDefaultModel(m.name) }) {
|
||||||
Text("Set default")
|
Text("Set default")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!m.downloaded) {
|
if (!m.downloaded) {
|
||||||
ShonarGhostButton({ state.downloadModel(m.name) }) {
|
TextButton({ state.downloadModel(m.name) }) {
|
||||||
Text("Download")
|
Text("Download")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1354,88 +1322,3 @@ 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,124 +107,4 @@ class JobProgressTest {
|
||||||
// visible time too, so it must count from created_at.
|
// visible time too, so it must count from created_at.
|
||||||
assertEquals(" · 2:04", DesktopState.waitSuffix(iso, t0))
|
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,18 +138,11 @@ class ReportsTest {
|
||||||
assertTrue("No transcript available." in xml)
|
assertTrue("No transcript available." in xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun odtExportFilenamesThroughExportTo() {
|
@Test fun odtFileName() {
|
||||||
// exportTo is the only export path now; check both formats land
|
val dir = Files.createTempDirectory("odt").toFile()
|
||||||
// with the promised names in the chosen folder.
|
|
||||||
val dir = Files.createTempDirectory("exp").toFile()
|
|
||||||
try {
|
try {
|
||||||
val audio = File(dir, "chat.m4a")
|
val audio = File(dir, "chat.m4a")
|
||||||
val state = DesktopState(Files.createTempDirectory("expstate").toFile())
|
assertEquals("chat.report.odt", DesktopState.odtFile(audio).name)
|
||||||
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 {
|
} finally {
|
||||||
dir.deleteRecursively()
|
dir.deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,13 +86,7 @@ async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: S
|
||||||
session, user.id, body.declared_mime_type, body.declared_size_bytes,
|
session, user.id, body.declared_mime_type, body.declared_size_bytes,
|
||||||
body.title, body.client_recording_id, body.transcription_model,
|
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:
|
except up.UploadError as e:
|
||||||
await session.rollback()
|
|
||||||
raise HTTPException(e.status_code, e.message) from None
|
raise HTTPException(e.status_code, e.message) from None
|
||||||
return us
|
return us
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -435,21 +435,6 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None:
|
||||||
await _fail(session, rec, job, str(e), ctx, e)
|
await _fail(session, rec, job, str(e), ctx, e)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return
|
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(
|
await store_transcript(
|
||||||
session, rec, result.text, result.segments, result.language,
|
session, rec, result.text, result.segments, result.language,
|
||||||
provider.name, getattr(result, "model", ""),
|
provider.name, getattr(result, "model", ""),
|
||||||
|
|
@ -564,25 +549,6 @@ async def run_summarize(ctx: dict, recording_id: str) -> None:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return
|
return
|
||||||
result, result_provider, fallback_note = rescue
|
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,
|
await store_summary(session, rec, result.to_dict(), result_provider,
|
||||||
result.model, tone=job.tone)
|
result.model, tone=job.tone)
|
||||||
job.status = JobStatus.succeeded
|
job.status = JobStatus.succeeded
|
||||||
|
|
|
||||||
|
|
@ -251,45 +251,6 @@ async def test_config_error_fails_fast(client, monkeypatch):
|
||||||
assert stt.calls == 1
|
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 -----------------------------------------------------------------
|
# --- versioning -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue