Files
odidere/internal/service/conversations.go
T

289 lines
6.4 KiB
Go

package service
import (
"database/sql"
"encoding/json/v2"
"errors"
"log/slog"
"net/http"
"strings"
"unicode/utf8"
"code.chimeric.al/chimerical/odidere/internal/db"
)
func (svc *Service) listConversations(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value(logKey).(*slog.Logger)
)
rows, err := svc.dbr.SelectConversationsUserId(ctx, "default")
if err != nil {
log.ErrorContext(
ctx, "failed to list conversations",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.MarshalWrite(w, rows); err != nil {
log.ErrorContext(
ctx, "failed to encode conversations response",
slog.Any("error", err),
)
}
}
func (svc *Service) createConversation(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value(logKey).(*slog.Logger)
req struct {
Title *string `json:"title"`
SystemMessage *string `json:"system_message"`
}
)
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
if err := json.UnmarshalRead(r.Body, &req); err != nil {
log.ErrorContext(
ctx, "failed to decode request",
slog.Any("error", err),
)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
system := svc.cfg.SystemMessage
if req.SystemMessage != nil {
system = *req.SystemMessage
}
saved, _, err := svc.newConversation(
ctx, "default", req.Title, system,
)
if err != nil {
log.ErrorContext(
ctx, "failed to create conversation",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.MarshalWrite(w, saved); err != nil {
log.ErrorContext(
ctx, "failed to encode conversation response",
slog.Any("error", err),
)
}
}
func (svc *Service) getConversationHandler(
w http.ResponseWriter,
r *http.Request,
) {
var (
ctx = r.Context()
log = ctx.Value(logKey).(*slog.Logger)
)
saved, err := svc.dbr.SelectConversationIdUserId(
ctx, r.PathValue("id"), "default",
)
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
if err != nil {
log.ErrorContext(
ctx, "failed to load conversation",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.MarshalWrite(w, saved); err != nil {
log.ErrorContext(
ctx, "failed to encode conversation response",
slog.Any("error", err),
)
}
}
// maxTitleLength is the most characters, counted as Unicode code
// points, that a conversation title may hold. It keeps a title
// short enough for the picker and small in storage.
const maxTitleLength = 256
// updateConversation renames one conversation. The handler trims
// the title, then rejects an empty title or a title longer than
// maxTitleLength with 400. A conversation that does not exist, or
// belongs to another user, returns 404. The rename is allowed while
// inference runs: a title is not a message, and a concurrent
// inference does not read or write it.
func (svc *Service) updateConversation(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value(logKey).(*slog.Logger)
id = r.PathValue("id")
req struct {
Title string `json:"title"`
}
)
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
if err := json.UnmarshalRead(r.Body, &req); err != nil {
log.ErrorContext(
ctx, "failed to decode request",
slog.Any("error", err),
)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
req.Title = strings.TrimSpace(req.Title)
if req.Title == "" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if utf8.RuneCountInString(req.Title) > maxTitleLength {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Load the conversation first, so a rename of a conversation
// that does not belong to the user reports 404, not a silent
// no-op.
if _, err := svc.dbr.SelectConversationIdUserId(
ctx, id, "default",
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
log.ErrorContext(
ctx, "failed to load conversation",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
saved, err := svc.dbw.UpdateConversationTitle(
ctx,
db.UpdateConversationTitleParams{
Id: id,
Title: req.Title,
},
)
if err != nil {
// The conversation was deleted after the load above.
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
log.ErrorContext(
ctx, "failed to rename conversation",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.MarshalWrite(w, saved); err != nil {
log.ErrorContext(
ctx, "failed to encode conversation response",
slog.Any("error", err),
)
}
}
func (svc *Service) deleteConversation(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
log = ctx.Value(logKey).(*slog.Logger)
id = r.PathValue("id")
)
if _, err := svc.dbr.SelectConversationIdUserId(
ctx, id, "default",
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
log.ErrorContext(
ctx, "failed to load conversation",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
c, err := svc.getConversation(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
if err != nil {
log.ErrorContext(
ctx, "failed to get conversation handle",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
if err := c.Delete(ctx); err != nil {
log.ErrorContext(
ctx, "failed to delete conversation",
slog.Any("error", err),
)
http.Error(
w,
http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError,
)
return
}
svc.mu.Lock()
delete(svc.conversations, id)
svc.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}