feat(web): chat with an uploaded transcript, reusing the ADR-027 path (ADR-033)
GET/POST /uploads/{id}/chat, mounted only when both an Uploader and a Chatter
are wired. The row comes from the user-scoped private store, so another user's
upload is a 404 before any model is involved. The owner's transcript goes to
the SAME Chatter with App.UploadChatMaxChars instead of the 18,000-char caption
default. The chat panel is shared, not forked: chatView gains PostURL/Subject
(empty = a YouTube video, so existing callers are unchanged). The done card
links to chat only when chat is wired.
Mutation-checked: dropping the upload budget, ignoring the done-status guard,
or scoping the lookup to the wrong user each turn tests red.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+81
-2
@@ -9,6 +9,9 @@ import (
|
||||
"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"
|
||||
@@ -129,7 +132,7 @@ func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.render(w, r, UploadPage(*u))
|
||||
a.render(w, r, UploadPage(*u, a.Chat != nil))
|
||||
}
|
||||
|
||||
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
|
||||
@@ -140,7 +143,7 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.render(w, r, uploadStatus(*u))
|
||||
a.render(w, r, uploadStatus(*u, a.Chat != nil))
|
||||
}
|
||||
|
||||
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
|
||||
@@ -189,3 +192,79 @@ func uploadFailureMessage(k domain.UploadErrorKind) string {
|
||||
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(""), 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"), "")
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user