Files
libredesk/cmd/aiagent.go
T
Abhinav Raut 7186c95e40 add WIP autonomous AI agent that replies to assigned conversations
Medium (title + short body):
add WIP autonomous AI agent

New internal/aiagent package runs AI assistants that reply to customers
on conversations assigned to them, grounded on a knowledge base. Also mines
resolved conversations for FAQ suggestions. Adds admin UI and the v2.7.0
schema. Still work in progress.
2026-07-11 04:54:49 +05:30

255 lines
8.3 KiB
Go

package main
import (
"encoding/json"
"mime/multipart"
"path/filepath"
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/aiagent/models"
authmodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetAIAssistants returns all AI assistants.
func handleGetAIAssistants(r *fastglue.Request) error {
app := r.Context.(*App)
assistants, err := app.aiAgent.GetAssistants()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistants)
}
func handleGetAIAssistant(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
assistant, err := app.aiAgent.GetAssistant(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistant)
}
func handleCreateAIAssistant(r *fastglue.Request) error {
app := r.Context.(*App)
req, files, err := decodeAssistantForm(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
assistant, err := app.aiAgent.CreateAssistant(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := applyAssistantAvatar(r, assistant.UserID, files, req.RemoveAvatar); err != nil {
return sendErrorEnvelope(r, err)
}
assistant, err = app.aiAgent.GetAssistant(assistant.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistant)
}
func handleUpdateAIAssistant(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
req, files, err := decodeAssistantForm(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
assistant, err := app.aiAgent.UpdateAssistant(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := applyAssistantAvatar(r, assistant.UserID, files, req.RemoveAvatar); err != nil {
return sendErrorEnvelope(r, err)
}
assistant, err = app.aiAgent.GetAssistant(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(assistant)
}
func handleDeleteAIAssistant(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := app.aiAgent.DeleteAssistant(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleAIAssistantPreview drafts a reply to a test message without any side effects.
func handleAIAssistantPreview(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req struct {
Message string `json:"message"`
}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
reply, err := app.aiAgent.PreviewReply(r.RequestCtx, id, req.Message)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(map[string]any{"reply": reply})
}
func handleGetAIAssistantStats(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
rangeDays, _ := strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("range")))
stats, err := app.aiAgent.GetAssistantStats(id, rangeDays)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(stats)
}
// decodeAssistantForm parses the multipart save payload: a JSON "data" field plus an optional avatar file.
func decodeAssistantForm(r *fastglue.Request) (amodels.Assistant, []*multipart.FileHeader, error) {
app := r.Context.(*App)
var req amodels.Assistant
form, err := r.RequestCtx.MultipartForm()
if err != nil {
return req, nil, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)
}
data := form.Value["data"]
if len(data) == 0 {
return req, nil, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)
}
if err := json.Unmarshal([]byte(data[0]), &req); err != nil {
return req, nil, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil)
}
return req, form.File["files"], nil
}
// applyAssistantAvatar uploads a new avatar for the assistant's paired user, or clears it when remove is set.
func applyAssistantAvatar(r *fastglue.Request, userID int, files []*multipart.FileHeader, remove bool) error {
app := r.Context.(*App)
if len(files) == 0 && !remove {
return nil
}
user, err := app.user.GetAgentCachedOrLoad(userID)
if err != nil {
return err
}
if len(files) > 0 {
return uploadUserAvatar(r, user, files)
}
if user.AvatarURL.String == "" {
return nil
}
if err := app.media.Delete(filepath.Base(user.AvatarURL.String)); err != nil {
return err
}
if err := app.user.UpdateAvatar(userID, ""); err != nil {
return err
}
app.user.InvalidateAgentCache(userID)
return nil
}
type faqApproveReq struct {
Question string `json:"question"`
Answer string `json:"answer"`
}
type faqLearningReq struct {
Enabled bool `json:"enabled"`
}
// handleGetAIFaqSuggestions returns FAQ suggestions filtered by ?status ("" returns all).
func handleGetAIFaqSuggestions(r *fastglue.Request) error {
app := r.Context.(*App)
status := string(r.RequestCtx.QueryArgs().Peek("status"))
items, err := app.aiAgent.GetFAQSuggestions(status)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(items)
}
// handleApproveAIFaqSuggestion turns a suggestion into a knowledge base snippet and marks it approved.
func handleApproveAIFaqSuggestion(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req faqApproveReq
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
auser := r.RequestCtx.UserValue("user").(authmodels.User)
if err := app.aiAgent.ApproveFAQSuggestion(id, req.Question, req.Answer, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleRejectAIFaqSuggestion marks a suggestion rejected without creating a snippet.
func handleRejectAIFaqSuggestion(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
auser := r.RequestCtx.UserValue("user").(authmodels.User)
if err := app.aiAgent.RejectFAQSuggestion(id, auser.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleGetAIFaqLearning returns whether FAQ learning from resolved conversations is enabled.
func handleGetAIFaqLearning(r *fastglue.Request) error {
app := r.Context.(*App)
b, err := app.setting.Get("ai_agent.faq_learning_enabled")
if err != nil {
return sendErrorEnvelope(r, err)
}
var enabled bool
_ = json.Unmarshal(b, &enabled)
return r.SendEnvelope(map[string]any{"enabled": enabled})
}
// handleUpdateAIFaqLearning toggles FAQ learning from resolved conversations.
func handleUpdateAIFaqLearning(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req faqLearningReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
if err := app.setting.Update(map[string]any{"ai_agent.faq_learning_enabled": req.Enabled}); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}