255 lines
9.2 KiB
Templ
255 lines
9.2 KiB
Templ
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
|
"git.d-ma.be/mathias/tapir/internal/uploads"
|
|
)
|
|
|
|
// UploadsPage is the upload form plus the caller's own uploads (ADR-033).
|
|
templ UploadsPage(v uploadsView) {
|
|
@Layout("Tapir — Upload a recording") {
|
|
<article class="account">
|
|
<h1>Upload a recording</h1>
|
|
<p class="muted">
|
|
Audio only. Up to { fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20) } and
|
|
{ uploads.FormatDuration(v.Limits.MaxDuration) }. The transcript is private to you;
|
|
the audio is deleted once it has been transcribed.
|
|
</p>
|
|
if v.Error != "" {
|
|
<div class="flash flash-error" role="alert">{ v.Error }</div>
|
|
}
|
|
<form method="post" action="/uploads" enctype="multipart/form-data">
|
|
<p><label>Title (optional) <input type="text" name="title" maxlength="200"/></label></p>
|
|
<p><input type="file" name="file" accept="audio/*,.m4a,.mp3,.wav,.opus,.ogg,.mp4" required/></p>
|
|
<p><button type="submit">Upload and transcribe</button></p>
|
|
</form>
|
|
if len(v.Rows) > 0 {
|
|
<section>
|
|
<h2>Your recordings</h2>
|
|
<ul>
|
|
for _, u := range v.Rows {
|
|
<li>
|
|
<a href={ templ.SafeURL("/uploads/" + u.ID) }>{ u.Title }</a>
|
|
<span class="chip">{ string(u.Status) }</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// UploadPage is one upload's page: its title and the polling status region.
|
|
templ UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
|
|
@Layout("Tapir — " + u.Title) {
|
|
<article class="account">
|
|
<h1>{ u.Title }</h1>
|
|
@uploadStatus(u, chatEnabled, now)
|
|
<p><a href="/uploads">All recordings</a></p>
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
|
|
// page re-asks; done and failed render without it, so nothing polls forever.
|
|
templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
|
|
switch u.Status {
|
|
case domain.UploadDone:
|
|
<div class="upload-status upload-done" role="status">
|
|
@videoNotice(u.HadVideo)
|
|
<p>
|
|
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
|
|
of audio by <span class="chip">{ u.Endpoint }</span>.
|
|
</p>
|
|
if provider, cloud := cloudProvider(u.Endpoint); cloud {
|
|
<p class="cloud-note">
|
|
This recording's audio left the homelab: it was transcribed by a cloud service ({ provider })
|
|
because the local engine could not.
|
|
</p>
|
|
}
|
|
@reconciliationNote(u.Reconciliation)
|
|
if len(u.Progress) > 0 {
|
|
<details class="what-was-done" if hasNote(u.Progress) { open }>
|
|
<summary>What was done</summary>
|
|
@progressSteps(u.Progress, now, false)
|
|
</details>
|
|
}
|
|
if chatEnabled {
|
|
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
|
|
}
|
|
<details open>
|
|
<summary>Transcript</summary>
|
|
<pre class="transcript">{ u.Content }</pre>
|
|
</details>
|
|
</div>
|
|
case domain.UploadFailed:
|
|
<div class="upload-status upload-failed flash flash-error" role="alert">
|
|
@videoNotice(u.HadVideo)
|
|
<p>{ uploadFailureMessage(u.ErrorKind) }</p>
|
|
if len(u.Progress) > 0 {
|
|
<details class="what-was-done" open>
|
|
<summary>Where it stopped</summary>
|
|
@progressSteps(u.Progress, now, false)
|
|
</details>
|
|
}
|
|
</div>
|
|
default:
|
|
<div
|
|
class="upload-status upload-working"
|
|
role="status"
|
|
hx-get={ "/uploads/" + u.ID + "/status" }
|
|
hx-trigger={ "every " + uploadPollEvery }
|
|
hx-swap="outerHTML"
|
|
>
|
|
@videoNotice(u.HadVideo)
|
|
if u.Status == domain.UploadTranscribing {
|
|
{{ health, silent := uploadHealth(u, now) }}
|
|
if len(u.Progress) > 0 {
|
|
@progressSteps(u.Progress, now, health != healthLost)
|
|
@healthNote(health, silent)
|
|
} else {
|
|
<p>Transcribing… a long recording takes a few minutes.</p>
|
|
}
|
|
} else {
|
|
<p>Queued — waiting for its turn to be transcribed.</p>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// UploadChatPage is the no-JS full page for chatting with an uploaded transcript:
|
|
// the same chat panel as a video's (ADR-027), scoped to this recording.
|
|
templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
|
|
@Layout("Tapir — Ask about " + u.Title) {
|
|
<article class="account">
|
|
<h1>{ u.Title }</h1>
|
|
<section id="chat-section" class="chat-dock chat-dock-open">
|
|
<h2 class="chat-heading">Ask about this recording</h2>
|
|
<p class="chat-scope muted">Answers come only from this recording's transcript, which is private to you.</p>
|
|
@chatPanel(v)
|
|
</section>
|
|
<p><a href={ templ.SafeURL("/uploads/" + u.ID) }>Back to the transcript</a></p>
|
|
</article>
|
|
}
|
|
}
|
|
|
|
// videoNotice tells the user a video track was present and what became of it. A
|
|
// Teams meeting with the camera off is still a video file; it is accepted, only the
|
|
// audio is transcribed, and the picture is thrown away — so say so, in every state,
|
|
// instead of leaving the user to wonder (ADR-033).
|
|
templ videoNotice(hadVideo bool) {
|
|
if hadVideo {
|
|
<p class="muted video-notice">
|
|
This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
|
|
</p>
|
|
}
|
|
}
|
|
|
|
// reconciliationNote says what was done when two engines transcribed the audio
|
|
// (ADR-034), and never claims a second opinion that was not there: nil means a single
|
|
// engine and renders nothing. Spots the model changed or was unsure about are listed
|
|
// with a minute mark and both readings so the user can check them against the audio.
|
|
templ reconciliationNote(r *domain.Reconciliation) {
|
|
if r != nil {
|
|
switch r.Status {
|
|
case domain.ReconcileDone:
|
|
<div class="reconciliation">
|
|
<p>
|
|
Reconciled from two transcripts ({ r.Base } and { r.Alt }):
|
|
{ strconv.Itoa(r.Disputes) } differences,
|
|
{ strconv.Itoa(r.AutoResolved) } settled automatically,
|
|
{ strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }),
|
|
{ strconv.Itoa(r.Unsure) } uncertain.
|
|
</p>
|
|
if len(shownSpots(r)) > 0 {
|
|
<details>
|
|
<summary>Spots to check against the audio</summary>
|
|
<ul>
|
|
for _, c := range shownSpots(r) {
|
|
<li>
|
|
{ minuteMark(c.Seconds) }:
|
|
if c.Before != "" {
|
|
<span class="muted">…{ c.Before }</span>
|
|
}
|
|
{ c.Base } / { c.Alt } —
|
|
if c.Unsure {
|
|
<span class="chip chip-warn">uncertain</span> kept { c.Used }
|
|
} else {
|
|
<span class="chip">changed</span> used { c.Used }
|
|
}
|
|
</li>
|
|
}
|
|
</ul>
|
|
if moreSpots(r) > 0 {
|
|
<p class="muted">and { strconv.Itoa(moreSpots(r)) } more not listed</p>
|
|
}
|
|
</details>
|
|
}
|
|
</div>
|
|
case domain.ReconcileSingle:
|
|
<p class="muted reconciliation">Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.</p>
|
|
case domain.ReconcileSkipped:
|
|
<p class="muted reconciliation">Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.</p>
|
|
}
|
|
}
|
|
}
|
|
|
|
// progressSteps is the job's phases as a log that fills in as it runs: finished ones
|
|
// collapse to a line with how long they took, the running one has a spinner and its
|
|
// elapsed time, and what has not started is dimmed. live is false when the spinner would
|
|
// be a lie (the service is lost, or the job is over), so the running phase shows a
|
|
// warning mark instead. Only a counted phase gets a bar (ADR-035).
|
|
templ progressSteps(steps []domain.ProgressStep, now time.Time, live bool) {
|
|
<ol class="steps" aria-label="Progress">
|
|
for _, s := range steps {
|
|
<li
|
|
class={ "step step-" + string(s.State) }
|
|
if s.State == domain.StepRunning {
|
|
aria-current="step"
|
|
}
|
|
>
|
|
if s.State == domain.StepRunning && live {
|
|
<span class="step-mark" aria-hidden="true"><span class="spinner"></span></span>
|
|
} else if s.State == domain.StepRunning {
|
|
<span class="step-mark step-mark-warn" aria-hidden="true">!</span>
|
|
} else {
|
|
<span class="step-mark" aria-hidden="true">{ stepMark(s) }</span>
|
|
}
|
|
<span class="step-label">{ s.Label }</span>
|
|
if t := stepTime(s, now); t != "" {
|
|
<span class="step-time">{ t }</span>
|
|
}
|
|
if s.Note != "" {
|
|
<span class="step-note">{ s.Note }</span>
|
|
}
|
|
if s.State == domain.StepRunning && s.Total > 0 {
|
|
<progress class="step-bar" value={ strconv.Itoa(s.Done) } max={ strconv.Itoa(s.Total) }></progress>
|
|
<span class="step-count">{ strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) }</span>
|
|
}
|
|
</li>
|
|
}
|
|
</ol>
|
|
}
|
|
|
|
// healthNote says so when the job is not simply running: the service stopped reporting,
|
|
// or a counted phase has gone quiet. Running says nothing: the spinner does.
|
|
templ healthNote(h jobHealth, silent time.Duration) {
|
|
switch h {
|
|
case healthLost:
|
|
<p class="step-health step-health-lost" role="alert">
|
|
The service stopped reporting { minutesAgo(silent) } ago. It may have restarted or crashed.
|
|
This page keeps checking; if nothing changes, upload the recording again.
|
|
</p>
|
|
case healthQuiet:
|
|
<p class="step-health step-health-quiet">
|
|
Nothing new for { minutesAgo(silent) }: the model may be slow. The service is still running,
|
|
and the job gives up on its own if it takes too long.
|
|
</p>
|
|
}
|
|
}
|