Files
Abhinav Raut f460f6b163 expand copilot with customer-history tools, persona picker, and tag suggestions
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
2026-07-19 23:33:18 +05:30

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)
}