diff --git a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue index e7aea3b3..bab87c01 100644 --- a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue +++ b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue @@ -188,11 +188,11 @@ const submitForm = async (values) => { await api.updateAIAssistant(props.id, values) } else { await api.createAIAssistant(values) + router.push({ name: 'ai-assistants' }) } emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { description: t('globals.messages.savedSuccessfully') }) - router.push({ name: 'ai-assistants' }) } catch (error) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', diff --git a/frontend/apps/main/src/views/admin/ai/CreateOrEditTool.vue b/frontend/apps/main/src/views/admin/ai/CreateOrEditTool.vue index 3234797e..e6eb2af7 100644 --- a/frontend/apps/main/src/views/admin/ai/CreateOrEditTool.vue +++ b/frontend/apps/main/src/views/admin/ai/CreateOrEditTool.vue @@ -40,11 +40,11 @@ const submitForm = async (values) => { await api.updateAITool(props.id, values) } else { await api.createAITool(values) + router.push({ name: 'ai-tools' }) } emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { description: t('globals.messages.savedSuccessfully') }) - router.push({ name: 'ai-tools' }) } catch (error) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', diff --git a/internal/aiagent/prompt.go b/internal/aiagent/prompt.go index 8a399ca1..987193f8 100644 --- a/internal/aiagent/prompt.go +++ b/internal/aiagent/prompt.go @@ -31,7 +31,7 @@ Handling requests: - Greeting or small talk: reply briefly and warmly, then offer to help. - A question about the company/product: search the knowledge base first, then answer only from what it returns. - %s -- When you believe you have fully answered, do not resolve yet. End that reply with a short confirmation question such as "Did that resolve your question?" on its own line, separated from the answer by a blank line, and wait for the customer. +- When you believe you have fully answered, do not resolve yet. After your answer, write a line containing only [[confirm]], then a short confirmation question such as "Did that resolve your question?", and wait for the customer. The customer never sees the [[confirm]] line; it sends the question as its own follow-up message. - Call resolve only after the customer confirms they are done (for example "yes", "thanks, that's all", or clear agreement). If they raise something new instead, keep helping.` const handoffToolLine = "- hand_off_to_human: transfer the conversation to a human agent.\n" diff --git a/internal/aiagent/queries.sql b/internal/aiagent/queries.sql index 4e8b491f..4f48c77e 100644 --- a/internal/aiagent/queries.sql +++ b/internal/aiagent/queries.sql @@ -74,7 +74,7 @@ INSERT INTO ai_agent_events (assistant_id, conversation_id, type) VALUES ($1, $2 -- $1 = assistant user id (message sender), $2 = assistant id (events), $3 = window start, $4 = window end. SELECT (SELECT count(DISTINCT conversation_id) FROM conversation_messages WHERE sender_id = $1 AND type = 'outgoing' AND private = false AND created_at >= $3 AND created_at < $4 AND NOT COALESCE((meta->>'is_csat')::boolean, false)) AS conversations, - (SELECT count(*) FROM conversation_messages WHERE sender_id = $1 AND type = 'outgoing' AND private = false AND created_at >= $3 AND created_at < $4 AND NOT COALESCE((meta->>'is_csat')::boolean, false)) AS replies, + (SELECT count(*) FROM conversation_messages WHERE sender_id = $1 AND type = 'outgoing' AND private = false AND created_at >= $3 AND created_at < $4 AND NOT COALESCE((meta->>'is_csat')::boolean, false) AND NOT COALESCE((meta->>'is_confirmation')::boolean, false)) AS replies, (SELECT count(DISTINCT conversation_id) FROM ai_agent_events WHERE assistant_id = $2 AND type = 'handoff' AND created_at >= $3 AND created_at < $4) AS handoffs, (SELECT count(DISTINCT conversation_id) FROM ai_agent_events WHERE assistant_id = $2 AND type = 'resolve' AND created_at >= $3 AND created_at < $4) AS resolves, (SELECT count(DISTINCT e.conversation_id) FROM ai_agent_events e JOIN conversations c ON c.id = e.conversation_id JOIN conversation_statuses s ON s.id = c.status_id @@ -91,6 +91,7 @@ SELECT SELECT count(*) FROM conversation_messages WHERE conversation_id = $1 AND sender_id = $2 AND type = 'outgoing' AND private = false AND NOT COALESCE((meta->>'is_csat')::boolean, false) + AND NOT COALESCE((meta->>'is_confirmation')::boolean, false) AND created_at > COALESCE(( SELECT max(created_at) FROM conversation_messages WHERE conversation_id = $1 AND type = 'activity' diff --git a/internal/aiagent/worker.go b/internal/aiagent/worker.go index 3d51fa85..eeb358ad 100644 --- a/internal/aiagent/worker.go +++ b/internal/aiagent/worker.go @@ -12,6 +12,8 @@ import ( "github.com/abhinavxd/libredesk/internal/attachment" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" imageutil "github.com/abhinavxd/libredesk/internal/image" + "time" + "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" ) @@ -24,6 +26,13 @@ const ( maxImagesPerMessage = 3 maxImagesTotal = 4 maxImageBytes = 8 << 20 + + // confirmMarker is the line the model emits before its trailing confirmation question so it + // can be split off and sent as a separate message. + confirmMarker = "[[confirm]]" + + // typingRefreshInterval must stay under the widget's 5s typing expiry (TYPING_RECEIVE_TIMEOUT). + typingRefreshInterval = 3 * time.Second ) // terminalStatuses are conversation statuses the assistant does not act on. @@ -188,9 +197,9 @@ func (m *Manager) handle(ctx context.Context, convID int) { if conv.Contact.Type == umodels.UserTypeContact { tctx = ai.ToolContext{ContactExternalID: conv.Contact.ExternalUserID.String, ContactEmail: conv.Contact.Email.String} } - m.convo.BroadcastTypingToWidgetClientsOnly(conv.UUID, true) + stopTyping := m.keepTyping(conv.UUID) answer, err := m.ai.RunAgentWithTools(ctx, systemPrompt, history, aiRunMaxSteps, tctx, assistant.ToolIDs, false, tools) - m.convo.BroadcastTypingToWidgetClientsOnly(conv.UUID, false) + stopTyping() if err != nil { m.lo.Error("error running ai agent", "conversation_uuid", conv.UUID, "error", err) m.handoff(conv, assistant, m.i18n.T("ai.agent.handoffError")) @@ -202,10 +211,17 @@ func (m *Manager) handle(ctx context.Context, convID int) { return } // The model's text answer is the reply to the customer. Handoff and resolve are separate tool actions. - answer = strings.TrimSpace(answer) + answer, confirm := splitConfirmation(strings.TrimSpace(answer)) + // Email gets one message; separate chat-style bubbles only suit the widget. + if conv.InboxChannel == channelEmail && confirm != "" { + answer, confirm = answer+"\n\n"+confirm, "" + } if answer != "" { m.lo.Debug("ai agent replying", "conversation_uuid", conv.UUID, "reply_len", len(answer), "resolved", outcome.resolved) - m.postReply(conv, assistant, answer) + m.postReply(conv, assistant, answer, nil) + } + if confirm != "" { + m.postReply(conv, assistant, confirm, map[string]any{"is_confirmation": true}) } if outcome.resolved { m.resolve(conv, assistant) @@ -226,12 +242,37 @@ func (m *Manager) resolve(conv cmodels.Conversation, assistant models.Assistant) m.recordEvent(assistant.ID, conv.ID, "resolve") } -func (m *Manager) postReply(conv cmodels.Conversation, assistant models.Assistant, text string) { +// keepTyping re-broadcasts typing until the returned stop func runs; the widget clears the indicator 5s after the last event. +func (m *Manager) keepTyping(conversationUUID string) func() { + m.convo.BroadcastTypingToWidgetClientsOnly(conversationUUID, true) + stop := make(chan struct{}) + go func() { + t := time.NewTicker(typingRefreshInterval) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + m.convo.BroadcastTypingToWidgetClientsOnly(conversationUUID, true) + } + } + }() + return func() { + close(stop) + m.convo.BroadcastTypingToWidgetClientsOnly(conversationUUID, false) + } +} + +func (m *Manager) postReply(conv cmodels.Conversation, assistant models.Assistant, text string, meta map[string]any) { var to []string if conv.InboxChannel == channelEmail && conv.Contact.Email.String != "" { to = []string{conv.Contact.Email.String} } - if _, err := m.convo.QueueReply(nil, conv.InboxID, assistant.UserID, conv.ContactID, conv.UUID, stringutil.Markdown2HTML(text), to, nil, nil, map[string]interface{}{}); err != nil { + if meta == nil { + meta = map[string]any{} + } + if _, err := m.convo.QueueReply(nil, conv.InboxID, assistant.UserID, conv.ContactID, conv.UUID, stringutil.Markdown2HTML(text), to, nil, nil, meta); err != nil { m.lo.Error("error sending assistant reply", "conversation_uuid", conv.UUID, "error", err) } } @@ -280,7 +321,15 @@ func (m *Manager) PreviewReply(ctx context.Context, assistantID int, message str history := []aimodels.ChatMessage{{Role: "user", Content: message}} tools := []ai.Tool{&searchKnowledgeTool{m: m}} // Preview is search-only: no custom tools (empty allowed set), no built-in, no side effects. - return m.ai.RunAgentWithTools(ctx, buildSystemPrompt(a), history, aiRunMaxSteps, ai.ToolContext{}, []int{}, false, tools) + answer, err := m.ai.RunAgentWithTools(ctx, buildSystemPrompt(a), history, aiRunMaxSteps, ai.ToolContext{}, []int{}, false, tools) + if err != nil { + return "", err + } + main, confirm := splitConfirmation(strings.TrimSpace(answer)) + if confirm != "" { + main += "\n\n" + confirm + } + return main, nil } func lastIsInboundContact(msgs []cmodels.Message) bool { @@ -383,3 +432,16 @@ func unreadableFileMarker(att attachment.Attachment) string { func unreadableImageMarker(att attachment.Attachment) string { return fmt.Sprintf("[The customer attached an image %q that you cannot view. Ask them to describe it if it matters.]", att.Name) } + +func splitConfirmation(answer string) (string, string) { + idx := strings.LastIndex(answer, confirmMarker) + if idx == -1 { + return answer, "" + } + main := strings.TrimSpace(strings.ReplaceAll(answer[:idx], confirmMarker, "")) + confirm := strings.TrimSpace(answer[idx+len(confirmMarker):]) + if main == "" { + return confirm, "" + } + return main, confirm +}