mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
f460f6b163
Copilot and Generate Reply get four read-only, access-filtered tools to look up a customer's history: list the contact's other conversations, search conversations by exact email, fetch one conversation by reference number, and search contacts. Tool output is marked untrusted and capped so a big response can't blow the context window. Copilot panel changes: - per-agent persona picker that borrows an enabled assistant's voice, language and instructions without changing the tool set (stored in localStorage) - answers render as HTML; copy, insert-into-reply, and add-as-private-note actions per answer - chat history moves to its own copilot store; server history is persisted only after a successful reply and read back with a limit Adds AI tag suggestions: a new endpoint suggests up to 3 existing tags for a conversation, applied from the sidebar, never auto-applied. Hardening and fixes: - OIDC callback rejects non-agent users - custom tool URLs and params schema validated on save; tool HTTP client no longer follows redirects; query keys pinned in the tool URL win - GetAllConversationMessages takes an explicit limit (cap 1000) - FAQ mining skips a candidate already pending review - ai agent handoff records its event only after the move actually lands - share chat message role constants; rename v2.7.0 migration to v2.6.0 and add the fix_grammar_spelling prompt
289 lines
9.6 KiB
Go
289 lines
9.6 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"
|
|
)
|
|
|
|
type compactAssistant struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// handleGetAIAssistantsCompact returns enabled assistants as {id, name} for the copilot persona picker.
|
|
// It exposes neither instructions nor tool ids, so it is safe behind auth rather than ai:manage.
|
|
func handleGetAIAssistantsCompact(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
assistants, err := app.aiAgent.GetAssistants()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
out := make([]compactAssistant, 0, len(assistants))
|
|
for _, a := range assistants {
|
|
if a.Enabled {
|
|
out = append(out, compactAssistant{ID: a.ID, Name: a.Name})
|
|
}
|
|
}
|
|
return r.SendEnvelope(out)
|
|
}
|
|
|
|
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)
|
|
}
|
|
if err := validateAvatarFile(r, files); 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 {
|
|
if _, delErr := app.aiAgent.DeleteAssistant(assistant.ID); delErr != nil {
|
|
app.lo.Error("error rolling back assistant after avatar failure", "assistant_id", assistant.ID, "error", delErr)
|
|
}
|
|
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)
|
|
}
|
|
if err := validateAvatarFile(r, files); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
assistant, err := app.aiAgent.UpdateAssistant(id, req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
app.user.InvalidateAgentCache(assistant.UserID)
|
|
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)
|
|
}
|
|
userID, err := app.aiAgent.DeleteAssistant(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
app.user.InvalidateAgentCache(userID)
|
|
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, sources, err := app.aiAgent.PreviewReply(r.RequestCtx, id, req.Message)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(map[string]any{"reply": reply, "sources": sources})
|
|
}
|
|
|
|
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.user.UpdateAvatar(userID, ""); err != nil {
|
|
return err
|
|
}
|
|
if err := app.media.Delete(filepath.Base(user.AvatarURL.String)); err != nil {
|
|
app.lo.Error("error deleting avatar media", "user_id", userID, "error", 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)
|
|
}
|