package service import ( "database/sql" "encoding/json/v2" "errors" "log/slog" "net/http" "strings" "unicode/utf8" "code.chimeric.al/chimerical/odidere/internal/conversation" "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), ) } } // updateConversation renames one conversation. The handler trims // the title, then rejects an empty title or a title longer than // conversation.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) > conversation.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) }