mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-28 07:17:01 +00:00
a1117ac5fe
The handoff tool used to reassign the conversation right away, so the assistant's own reply was dropped and the customer saw nothing. It now records the intent, the reply goes out, then the handoff runs.
667 lines
23 KiB
Go
667 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/abhinavxd/libredesk/internal/ai"
|
|
aimodels "github.com/abhinavxd/libredesk/internal/ai/models"
|
|
"github.com/abhinavxd/libredesk/internal/aiagent"
|
|
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
|
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
|
"github.com/abhinavxd/libredesk/internal/stringutil"
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/zerodha/fastglue"
|
|
)
|
|
|
|
// maxTranscriptMessages bounds how many recent messages are fed to the LLM as context.
|
|
const maxTranscriptMessages = 50
|
|
|
|
// maxCopilotHistoryMessages bounds how many persisted copilot turns are fed back to the LLM.
|
|
const maxCopilotHistoryMessages = 50
|
|
|
|
type aiCompletionReq struct {
|
|
PromptKey string `json:"prompt_key"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type generateReplyReq struct {
|
|
ConversationUUID string `json:"conversation_uuid"`
|
|
Instruction string `json:"instruction"`
|
|
}
|
|
|
|
type copilotReq struct {
|
|
ConversationUUID string `json:"conversation_uuid"`
|
|
Message string `json:"message"`
|
|
AssistantID int `json:"assistant_id"`
|
|
}
|
|
|
|
type summarizeReq struct {
|
|
ConversationUUID string `json:"conversation_uuid"`
|
|
}
|
|
|
|
type suggestTagsReq struct {
|
|
ConversationUUID string `json:"conversation_uuid"`
|
|
}
|
|
|
|
type promptReq struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type snippetReq struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type snippetImportReq struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type aiToolReq struct {
|
|
aimodels.Tool
|
|
RequiresAgentApproval *bool `json:"requires_agent_approval"`
|
|
}
|
|
|
|
// handleAICompletion runs a stored prompt over the supplied content (reply-box actions).
|
|
func handleAICompletion(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = aiCompletionReq{}
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
resp, err := app.ai.Completion(r.RequestCtx, req.PromptKey, req.Content)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(resp)
|
|
}
|
|
|
|
// handleGetAIPrompts returns the agent-facing prompts.
|
|
func handleGetAIPrompts(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
resp, err := app.ai.GetPrompts()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(resp)
|
|
}
|
|
|
|
func handleGetAIPrompt(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)
|
|
}
|
|
p, err := app.ai.GetPromptByID(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(p)
|
|
}
|
|
|
|
func handleCreateAIPrompt(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req promptReq
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
p, err := app.ai.CreatePrompt(req.Title, req.Content)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(p)
|
|
}
|
|
|
|
func handleUpdateAIPrompt(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req promptReq
|
|
)
|
|
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))
|
|
}
|
|
p, err := app.ai.UpdatePrompt(id, req.Title, req.Content)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(p)
|
|
}
|
|
|
|
func handleDeleteAIPrompt(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.ai.DeletePrompt(id); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleGetAIConfig returns the sanitized provider config for a type (completion/embedding).
|
|
func handleGetAIConfig(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
providerType := r.RequestCtx.UserValue("type").(string)
|
|
cfg, err := app.ai.GetProviderConfig(providerType)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(cfg)
|
|
}
|
|
|
|
// handleUpdateAIConfig updates the provider config for a type.
|
|
func handleUpdateAIConfig(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
providerType = r.RequestCtx.UserValue("type").(string)
|
|
req aimodels.ProviderConfig
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := app.ai.UpdateProviderConfig(providerType, req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleTestAIConfig makes one live provider request with the submitted config.
|
|
func handleTestAIConfig(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
providerType = r.RequestCtx.UserValue("type").(string)
|
|
req aimodels.ProviderConfig
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := app.ai.TestProviderConfig(providerType, req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleGetAITools returns all custom tools (auth secrets masked).
|
|
func handleGetAITools(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
tools, err := app.ai.GetTools()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(tools)
|
|
}
|
|
|
|
func handleGetAITool(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)
|
|
}
|
|
tool, err := app.ai.GetTool(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(tool)
|
|
}
|
|
|
|
func handleCreateAITool(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req aiToolReq
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
toolInput := req.Tool
|
|
toolInput.RequiresAgentApproval = req.RequiresAgentApproval == nil || *req.RequiresAgentApproval
|
|
tool, err := app.ai.CreateTool(toolInput)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(tool)
|
|
}
|
|
|
|
func handleUpdateAITool(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req aiToolReq
|
|
)
|
|
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))
|
|
}
|
|
toolInput := req.Tool
|
|
if req.RequiresAgentApproval == nil {
|
|
existing, err := app.ai.GetTool(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
toolInput.RequiresAgentApproval = existing.RequiresAgentApproval
|
|
} else {
|
|
toolInput.RequiresAgentApproval = *req.RequiresAgentApproval
|
|
}
|
|
tool, err := app.ai.UpdateTool(id, toolInput)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(tool)
|
|
}
|
|
|
|
func handleDeleteAITool(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.ai.DeleteTool(id); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleGetAISnippets returns the knowledge base snippets.
|
|
func handleGetAISnippets(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
items, err := app.ai.GetKnowledgeBaseItems()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(items)
|
|
}
|
|
|
|
func handleCreateAISnippet(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req snippetReq
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
item, err := app.ai.CreateKnowledgeBaseItem(req.Title, req.Content, aimodels.KnowledgeSourceManual, "", req.Enabled)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(item)
|
|
}
|
|
|
|
func handleUpdateAISnippet(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req snippetReq
|
|
)
|
|
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))
|
|
}
|
|
item, err := app.ai.UpdateKnowledgeBaseItem(id, req.Title, req.Content, req.Enabled)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(item)
|
|
}
|
|
|
|
// handleImportAISnippetFromURL fetches a page and adds its readable content as a snippet.
|
|
func handleImportAISnippetFromURL(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req snippetImportReq
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
item, err := app.ai.ImportKnowledgeBaseFromURL(r.RequestCtx, req.URL)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(item)
|
|
}
|
|
|
|
func handleDeleteAISnippet(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.ai.DeleteKnowledgeBaseItem(id); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleAIGenerateReply drafts a reply to a conversation using the agentic loop.
|
|
func handleAIGenerateReply(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req generateReplyReq
|
|
)
|
|
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").(amodels.User)
|
|
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
transcript := ""
|
|
var conv *cmodels.Conversation
|
|
if req.ConversationUUID != "" {
|
|
conv, err = enforceAIConversationAccess(r, req.ConversationUUID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
transcript = conversationTranscript(app, req.ConversationUUID)
|
|
}
|
|
toolIDs := []int(nil)
|
|
tctx := ai.ToolContext{}
|
|
scope := ai.AgentRunScope{AgentID: auser.ID, Surface: aimodels.ToolInvocationReply}
|
|
if conv != nil {
|
|
toolIDs, err = app.ai.GetEnabledGenerateReplyToolIDs()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
tctx = agentToolContext(conv)
|
|
scope.ConversationID = conv.ID
|
|
scope.ConversationUUID = conv.UUID
|
|
app.ai.ClearPendingAgentRuns(scope)
|
|
}
|
|
resp, err := app.ai.GenerateReply(r.RequestCtx, transcript, req.Instruction, tctx, generateReplyTools(app, user, conv), toolIDs, scope)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
|
|
}
|
|
if resp.Status == aimodels.AgentRunCompleted {
|
|
resp.Content = stringutil.Markdown2HTML(resp.Content)
|
|
}
|
|
return r.SendEnvelope(resp)
|
|
}
|
|
|
|
// handleAISummarizeConversation summarizes a conversation and posts the summary as the requesting agent's private note.
|
|
func handleAISummarizeConversation(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req summarizeReq
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if req.ConversationUUID == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if _, err := enforceAIConversationAccess(r, req.ConversationUUID); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
transcript := conversationTranscript(app, req.ConversationUUID)
|
|
if strings.TrimSpace(transcript) == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("ai.summarizeEmptyConversation"), nil))
|
|
}
|
|
summary, err := app.ai.Summarize(r.RequestCtx, transcript)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if strings.TrimSpace(summary) == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
|
|
}
|
|
auser := r.RequestCtx.UserValue("user").(amodels.User)
|
|
note := "**" + app.i18n.T("ai.summaryNoteTitle") + "**\n\n" + summary
|
|
if _, err := app.conversation.SendPrivateNote(nil, auser.ID, req.ConversationUUID, stringutil.Markdown2HTML(note), nil); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleAISuggestTags suggests up to 3 existing tags for a conversation. Read-only: it never applies them.
|
|
func handleAISuggestTags(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req suggestTagsReq
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if req.ConversationUUID == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if _, err := enforceAIConversationAccess(r, req.ConversationUUID); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
transcript := conversationTranscript(app, req.ConversationUUID)
|
|
if strings.TrimSpace(transcript) == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("ai.tagSuggestEmptyConversation"), nil))
|
|
}
|
|
|
|
suggestions, err := app.ai.SuggestTags(r.RequestCtx, transcript)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(suggestions)
|
|
}
|
|
|
|
// handleAICopilot answers an agent's copilot chat message.
|
|
func handleAICopilot(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req copilotReq
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
req.Message = strings.TrimSpace(req.Message)
|
|
if req.Message == "" || req.ConversationUUID == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
conv, err := enforceAIConversationAccess(r, req.ConversationUUID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
auser := r.RequestCtx.UserValue("user").(amodels.User)
|
|
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
persona := ""
|
|
if req.AssistantID > 0 {
|
|
assistant, err := app.aiAgent.GetAssistant(req.AssistantID)
|
|
if err != nil || !assistant.Enabled {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("ai.assistantUnavailable"), nil))
|
|
}
|
|
persona = aiagent.BuildCopilotPersona(assistant)
|
|
}
|
|
|
|
saved, err := app.ai.GetCopilotMessages(conv.ID, auser.ID, maxCopilotHistoryMessages)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
history := make([]aimodels.ChatMessage, 0, len(saved)+1)
|
|
for _, m := range saved {
|
|
history = append(history, aimodels.ChatMessage{Role: m.Role, Content: m.Content})
|
|
}
|
|
history = append(history, aimodels.ChatMessage{Role: aimodels.RoleUser, Content: req.Message})
|
|
|
|
scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot, UserMessage: req.Message}
|
|
app.ai.ClearPendingAgentRuns(scope)
|
|
toolIDs, err := app.ai.GetEnabledCopilotToolIDs()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
convoContext := conversationTranscript(app, req.ConversationUUID)
|
|
resp, err := app.ai.Copilot(r.RequestCtx, convoContext, history, agentToolContext(conv), copilotTools(app, user, conv), persona, toolIDs, scope)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil))
|
|
}
|
|
if resp.Status == aimodels.AgentRunCompleted {
|
|
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleUser, req.Message); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if err := app.ai.SaveCopilotMessage(conv.ID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil {
|
|
app.lo.Error("error saving copilot reply", "error", err)
|
|
}
|
|
resp.Content = stringutil.Markdown2HTML(resp.Content)
|
|
}
|
|
return r.SendEnvelope(resp)
|
|
}
|
|
|
|
func handleApproveAIToolRun(r *fastglue.Request) error {
|
|
resp, err := decideAIToolRun(r, true)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(resp)
|
|
}
|
|
|
|
func handleDeclineAIToolRun(r *fastglue.Request) error {
|
|
resp, err := decideAIToolRun(r, false)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(resp)
|
|
}
|
|
|
|
// handleGetCopilotMessages returns the requesting agent's persisted copilot chat for a conversation.
|
|
func handleGetCopilotMessages(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
uuid := string(r.RequestCtx.QueryArgs().Peek("conversation_uuid"))
|
|
if uuid == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
conv, err := enforceAIConversationAccess(r, uuid)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
auser := r.RequestCtx.UserValue("user").(amodels.User)
|
|
msgs, err := app.ai.GetCopilotMessages(conv.ID, auser.ID, maxCopilotHistoryMessages)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
// Assistant turns are stored as markdown; the panel renders HTML.
|
|
for i := range msgs {
|
|
if msgs[i].Role == aimodels.RoleAssistant {
|
|
msgs[i].Content = stringutil.Markdown2HTML(msgs[i].Content)
|
|
}
|
|
}
|
|
scope := ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot}
|
|
if userMessage, approval := app.ai.PendingAgentApproval(scope); approval != nil {
|
|
if userMessage != "" {
|
|
msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleUser, Content: userMessage})
|
|
}
|
|
msgs = append(msgs, aimodels.CopilotMessage{Role: aimodels.RoleApproval, Approval: approval})
|
|
}
|
|
return r.SendEnvelope(msgs)
|
|
}
|
|
|
|
// handleClearCopilotMessages clears the requesting agent's copilot chat for a conversation.
|
|
func handleClearCopilotMessages(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
uuid := string(r.RequestCtx.QueryArgs().Peek("conversation_uuid"))
|
|
if uuid == "" {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
conv, err := enforceAIConversationAccess(r, uuid)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
auser := r.RequestCtx.UserValue("user").(amodels.User)
|
|
if err := app.ai.ClearCopilotMessages(conv.ID, auser.ID); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
app.ai.ClearPendingAgentRuns(ai.AgentRunScope{AgentID: auser.ID, ConversationID: conv.ID, ConversationUUID: conv.UUID, Surface: aimodels.ToolInvocationCopilot})
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// enforceAIConversationAccess checks the requesting agent can access the conversation whose transcript is being fed to the LLM.
|
|
func enforceAIConversationAccess(r *fastglue.Request, uuid string) (*cmodels.Conversation, error) {
|
|
app := r.Context.(*App)
|
|
auser := r.RequestCtx.UserValue("user").(amodels.User)
|
|
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return enforceConversationAccess(app, uuid, user)
|
|
}
|
|
|
|
// conversationTranscript builds a plaintext transcript of a conversation's public messages for use as AI context.
|
|
func conversationTranscript(app *App, uuid string) string {
|
|
private := false
|
|
msgs, err := app.conversation.GetAllConversationMessages(uuid, &private, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}, maxTranscriptMessages)
|
|
if err != nil {
|
|
app.lo.Error("error building conversation transcript for AI", "error", err)
|
|
return ""
|
|
}
|
|
return cmodels.Transcript(msgs, maxTranscriptMessages)
|
|
}
|
|
|
|
func decideAIToolRun(r *fastglue.Request, approved bool) (aimodels.AgentRunResult, error) {
|
|
app := r.Context.(*App)
|
|
auser := r.RequestCtx.UserValue("user").(amodels.User)
|
|
runID := r.RequestCtx.UserValue("id").(string)
|
|
scope, err := app.ai.PendingAgentRunScope(runID, auser.ID)
|
|
if err != nil {
|
|
return aimodels.AgentRunResult{}, err
|
|
}
|
|
if scope.ConversationUUID != "" {
|
|
conv, err := enforceAIConversationAccess(r, scope.ConversationUUID)
|
|
if err != nil {
|
|
return aimodels.AgentRunResult{}, err
|
|
}
|
|
if conv.ID != scope.ConversationID {
|
|
return aimodels.AgentRunResult{}, envelope.NewError(envelope.PermissionError, app.i18n.T("ai.toolApprovalUnavailable"), nil)
|
|
}
|
|
}
|
|
resp, resumedScope, err := app.ai.ResumeAgentRun(r.RequestCtx, runID, auser.ID, approved)
|
|
if err != nil {
|
|
return aimodels.AgentRunResult{}, err
|
|
}
|
|
if resp.Status == aimodels.AgentRunCompleted && strings.TrimSpace(resp.Content) == "" {
|
|
return aimodels.AgentRunResult{}, envelope.NewError(envelope.GeneralError, app.i18n.T("ai.emptyResponse"), nil)
|
|
}
|
|
if resp.Status == aimodels.AgentRunCompleted {
|
|
if resumedScope.Surface == aimodels.ToolInvocationCopilot {
|
|
if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleUser, resumedScope.UserMessage); err != nil {
|
|
app.lo.Error("error saving copilot message", "error", err)
|
|
} else if err := app.ai.SaveCopilotMessage(resumedScope.ConversationID, auser.ID, aimodels.RoleAssistant, resp.Content); err != nil {
|
|
app.lo.Error("error saving copilot reply", "error", err)
|
|
}
|
|
}
|
|
resp.Content = stringutil.Markdown2HTML(resp.Content)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func agentToolContext(conv *cmodels.Conversation) ai.ToolContext {
|
|
return ai.ToolContext{
|
|
ContactID: conv.Contact.ID,
|
|
ContactExternalID: conv.Contact.ExternalUserID.String,
|
|
ContactType: conv.Contact.Type,
|
|
ConversationUUID: conv.UUID,
|
|
InboxID: conv.InboxID,
|
|
ContactEmail: func() string { return conv.Contact.Email.String },
|
|
// The agent picked this conversation and reviews the call before it runs, so the customer's
|
|
// own OTP state does not gate an agent-initiated lookup.
|
|
Verified: func() bool { return true },
|
|
}
|
|
}
|