596 lines
13 KiB
Go
596 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json/v2"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"code.chimeric.al/chimerical/odidere/internal/conversation"
|
|
"code.chimeric.al/chimerical/odidere/internal/db"
|
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
|
)
|
|
|
|
func (svc *Service) insertMessage(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
|
id = r.PathValue("id")
|
|
req struct {
|
|
Message *llm.Message `json:"message"`
|
|
MessageId string `json:"message_id"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.Message == nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var saved *db.Message
|
|
if req.Message.Role == llm.RoleTool {
|
|
if req.MessageId == "" || req.UpdatedAt.IsZero() {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
saved, err = c.AddToolResult(
|
|
ctx,
|
|
req.MessageId,
|
|
req.Message.ToolCallID,
|
|
req.UpdatedAt,
|
|
req.Message.Text(),
|
|
)
|
|
} else {
|
|
saved, err = c.InsertMessage(ctx, *req.Message)
|
|
}
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, conversation.ErrBusy),
|
|
errors.Is(err, conversation.ErrRevisionConflict):
|
|
http.Error(
|
|
w,
|
|
http.StatusText(http.StatusConflict),
|
|
http.StatusConflict,
|
|
)
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
http.NotFound(w, r)
|
|
default:
|
|
log.ErrorContext(
|
|
ctx, "failed to insert message",
|
|
slog.Any("error", err),
|
|
)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
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 message response",
|
|
slog.Any("error", err),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (svc *Service) updateMessage(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
|
id = r.PathValue("id")
|
|
mid = r.PathValue("mid")
|
|
req struct {
|
|
Message *llm.Message `json:"message"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.Message == nil || req.UpdatedAt.IsZero() {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var saved *db.Message
|
|
if req.Message.Role == llm.RoleTool {
|
|
// mid identifies the parent assistant message, while
|
|
// UpdatedAt is the tool result message's updated_at.
|
|
saved, err = c.UpdateToolResult(
|
|
ctx,
|
|
mid,
|
|
req.Message.ToolCallID,
|
|
req.UpdatedAt,
|
|
req.Message.Text(),
|
|
)
|
|
} else {
|
|
saved, err = c.UpdateMessage(
|
|
ctx, mid, req.UpdatedAt, *req.Message,
|
|
)
|
|
}
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, conversation.ErrBusy),
|
|
errors.Is(err, conversation.ErrRevisionConflict):
|
|
http.Error(
|
|
w,
|
|
http.StatusText(http.StatusConflict),
|
|
http.StatusConflict,
|
|
)
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
http.NotFound(w, r)
|
|
default:
|
|
log.ErrorContext(
|
|
ctx, "failed to update message",
|
|
slog.Any("error", err),
|
|
)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.MarshalWrite(w, saved); err != nil {
|
|
log.ErrorContext(
|
|
ctx, "failed to encode message response",
|
|
slog.Any("error", err),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (svc *Service) deleteMessage(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
|
id = r.PathValue("id")
|
|
mid = r.PathValue("mid")
|
|
req struct {
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.UpdatedAt.IsZero() {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := c.DeleteMessage(ctx, mid, req.UpdatedAt); err != nil {
|
|
switch {
|
|
case errors.Is(err, conversation.ErrBusy),
|
|
errors.Is(err, conversation.ErrRevisionConflict):
|
|
http.Error(
|
|
w,
|
|
http.StatusText(http.StatusConflict),
|
|
http.StatusConflict,
|
|
)
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
http.NotFound(w, r)
|
|
default:
|
|
log.ErrorContext(
|
|
ctx, "failed to delete message",
|
|
slog.Any("error", err),
|
|
)
|
|
http.Error(
|
|
w,
|
|
http.StatusText(http.StatusInternalServerError),
|
|
http.StatusInternalServerError,
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (svc *Service) addToolCall(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
|
id = r.PathValue("id")
|
|
mid = r.PathValue("mid")
|
|
req struct {
|
|
Call *llm.ToolCall `json:"call"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.Call == nil || req.UpdatedAt.IsZero() {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
saved, err := c.AddToolCall(ctx, mid, req.UpdatedAt, *req.Call)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, conversation.ErrBusy),
|
|
errors.Is(err, conversation.ErrRevisionConflict):
|
|
http.Error(
|
|
w,
|
|
http.StatusText(http.StatusConflict),
|
|
http.StatusConflict,
|
|
)
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
http.NotFound(w, r)
|
|
default:
|
|
log.ErrorContext(
|
|
ctx, "failed to add tool call",
|
|
slog.Any("error", err),
|
|
)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.MarshalWrite(w, saved); err != nil {
|
|
log.ErrorContext(
|
|
ctx, "failed to encode message response",
|
|
slog.Any("error", err),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (svc *Service) updateToolCall(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
|
id = r.PathValue("id")
|
|
mid = r.PathValue("mid")
|
|
cid = r.PathValue("cid")
|
|
req struct {
|
|
Call *llm.ToolCall `json:"call"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.Call == nil || req.UpdatedAt.IsZero() || req.Call.ID != cid {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
saved, err := c.UpdateToolCall(
|
|
ctx, mid, cid, req.UpdatedAt, *req.Call,
|
|
)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, conversation.ErrBusy),
|
|
errors.Is(err, conversation.ErrRevisionConflict):
|
|
http.Error(
|
|
w,
|
|
http.StatusText(http.StatusConflict),
|
|
http.StatusConflict,
|
|
)
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
http.NotFound(w, r)
|
|
default:
|
|
log.ErrorContext(
|
|
ctx, "failed to update tool call",
|
|
slog.Any("error", err),
|
|
)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.MarshalWrite(w, saved); err != nil {
|
|
log.ErrorContext(
|
|
ctx, "failed to encode message response",
|
|
slog.Any("error", err),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (svc *Service) deleteToolCall(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
log = ctx.Value(logKey).(*slog.Logger)
|
|
id = r.PathValue("id")
|
|
mid = r.PathValue("mid")
|
|
cid = r.PathValue("cid")
|
|
req struct {
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
if req.UpdatedAt.IsZero() {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := c.DeleteToolCall(ctx, mid, cid, req.UpdatedAt); err != nil {
|
|
switch {
|
|
case errors.Is(err, conversation.ErrBusy),
|
|
errors.Is(err, conversation.ErrRevisionConflict):
|
|
http.Error(
|
|
w,
|
|
http.StatusText(http.StatusConflict),
|
|
http.StatusConflict,
|
|
)
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
http.NotFound(w, r)
|
|
default:
|
|
log.ErrorContext(
|
|
ctx, "failed to delete tool call",
|
|
slog.Any("error", err),
|
|
)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|