Files
tapir/internal/web/uploads.go
T
mathiasandClaude Sonnet 5.5 6c39ab86cf feat(web): chat over an upload opens with App.UploadChatModel
The Chatter's own default is the summarizer's (koala/phi4-mini, 8,192-token
context), which answers HTTP 400 on an hour of speech. Empty keeps the old
behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 17:07:33 +02:00

271 lines
8.6 KiB
Go

package web
import (
"context"
"errors"
"io"
"net/http"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/a-h/templ"
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/uploads"
)
// Uploader accepts an uploaded recording for transcription (ADR-033).
// *uploads.Intake satisfies it. It is the whole of what the handlers know about
// validation, spooling and queueing.
type Uploader interface {
Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error)
}
const (
// uploadOverhead is headroom above the file-size cap for the multipart framing
// and the title field; the cap itself is enforced on the file part by Intake.
uploadOverhead = 1 << 20
maxTitleRunes = 200
// uploadPollEvery is how often an unfinished upload re-asks for its status.
uploadPollEvery = "5s"
)
// uploadsView is what the /uploads page renders.
type uploadsView struct {
Limits uploads.Limits
Rows []domain.UploadedTranscript
Error string // a synchronous rejection message, empty when none
}
// handleUploadsPage renders the upload form and the caller's own uploads.
func (a *App) handleUploadsPage(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
a.renderUploadsPage(w, r, userID, http.StatusOK, "")
}
func (a *App) renderUploadsPage(w http.ResponseWriter, r *http.Request, userID string, status int, msg string) {
rows, err := a.Store.ListUploads(r.Context(), userID)
if err != nil {
a.serverError(w, r, "list uploads", err)
return
}
a.renderStatus(w, r, status, UploadsPage(uploadsView{Limits: a.UploadLimits, Rows: rows, Error: msg}))
}
// handleUploadPost streams the multipart body: the optional title field, then the
// file part, which goes straight to Intake without being buffered in memory. A
// rejection is synchronous — a status code and a message on the upload page, no
// row, no timeout (ADR-033).
func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, a.UploadLimits.MaxBytes+uploadOverhead)
mr, err := r.MultipartReader()
if err != nil {
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
return
}
title := ""
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
a.uploadFailure(w, r, userID, err)
return
}
switch part.FormName() {
case "title":
b, _ := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
title = clipRunes(strings.TrimSpace(string(b)), maxTitleRunes)
case "file":
if title == "" {
title = titleFromFilename(part.FileName())
}
id, err := a.Uploads.Accept(r.Context(), userID, title, part)
if err != nil {
a.uploadFailure(w, r, userID, err)
return
}
http.Redirect(w, r, "/uploads/"+id, http.StatusSeeOther)
return
}
}
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
}
// uploadFailure maps an Accept (or multipart) error to a response: a validation
// rejection is the user's to fix (422, or 413 for size) and carries its own
// message; anything else is ours.
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
var rej *uploads.RejectError
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &rej):
status := http.StatusUnprocessableEntity
if rej.Reason == uploads.ReasonTooLarge {
status = http.StatusRequestEntityTooLarge
}
a.renderUploadsPage(w, r, userID, status, rej.Message)
case errors.As(err, &tooBig):
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.")
default:
a.serverError(w, r, "upload", err)
}
}
// handleUploadDetail renders one upload's page (GET /uploads/{id}). The row is
// read through the user-scoped store, so another user's id is a 404 — existence is
// not disclosed, and the title never appears in the response.
func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
u, ok := a.loadOwnedUpload(w, r)
if !ok {
return
}
a.render(w, r, UploadPage(*u, a.Chat != nil))
}
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
// keep polling; terminal states render without hx-trigger so the page stops
// asking (ADR-025: never a stuck spinner).
func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
u, ok := a.loadOwnedUpload(w, r)
if !ok {
return
}
a.render(w, r, uploadStatus(*u, a.Chat != nil))
}
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
userID, ok := a.currentUserID(w, r)
if !ok {
return nil, false
}
u, err := a.Store.GetUpload(r.Context(), userID, r.PathValue("id"))
if errors.Is(err, store.ErrUploadNotFound) {
http.NotFound(w, r)
return nil, false
}
if err != nil {
a.serverError(w, r, "get upload", err)
return nil, false
}
return u, true
}
// titleFromFilename is the default title: the file's name without its extension.
func titleFromFilename(name string) string {
base := filepath.Base(name)
t := strings.TrimSuffix(base, filepath.Ext(base))
if t == "" || t == "." {
return "Untitled recording"
}
return clipRunes(t, maxTitleRunes)
}
func clipRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n])
}
// uploadFailureMessage turns a typed failure kind into words for the user. It is
// keyed on OUR kinds, never on a provider's error string (ADR-033).
func uploadFailureMessage(k domain.UploadErrorKind) string {
switch k {
case domain.UploadErrInterrupted:
return "The server restarted before this recording was transcribed. Please upload it again."
case domain.UploadErrTranscriptionFailed:
return "Transcription failed on every available service. Please upload it again later."
default:
return "This upload failed. Please upload it again."
}
}
// handleUploadChat renders the chat page for a FINISHED upload (GET). The row is
// read through the user-scoped store, so another user's id is a 404 before any
// model is involved. An unfinished upload has no transcript to ask about yet, so
// it goes back to its status page.
func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) {
u, ok := a.loadOwnedUpload(w, r)
if !ok {
return
}
if u.Status != domain.UploadDone {
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
return
}
a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, a.Chat.DefaultModel(a.UploadChatModel), nil, false, "")))
}
// handleUploadChatMessage answers one question against the owner's uploaded
// transcript (POST), reusing the ADR-027 Chatter. Nothing is persisted: the prior
// turns ride in the form. The transcript goes to the model with the UPLOAD budget,
// not the caption default. A model failure is inline, not a 500.
func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
u, ok := a.loadOwnedUpload(w, r)
if !ok {
return
}
if u.Status != domain.UploadDone {
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
model := a.resolveModel(r.FormValue("model"), a.UploadChatModel)
history := parseHistory(r.Form["hq"], r.Form["ha"])
question := strings.TrimSpace(r.FormValue("question"))
view := a.uploadChatView(*u, model, history, false, "")
if question != "" {
reply, err := a.Chat.Answer(r.Context(), chat.Request{
Model: model,
Transcript: u.Content,
History: history,
Question: question,
MaxChars: a.UploadChatMaxChars,
})
if err != nil {
a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "err", err)
view.Error = "That model couldn't answer just now. Try again, or switch models."
} else {
view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
view.Truncated = reply.Truncated
}
}
if isHTMX(r) {
a.render(w, r, chatPanel(view))
return
}
a.render(w, r, UploadChatPage(*u, view))
}
func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history []chat.Turn, truncated bool, errMsg string) chatView {
return chatView{
VideoID: u.ID,
Title: u.Title,
Available: true,
Models: a.Chat.Models(),
Selected: model,
History: history,
Truncated: truncated,
Error: errMsg,
PostURL: templ.SafeURL("/uploads/" + u.ID + "/chat"),
Subject: "recording",
}
}