From e1695b7836d9bf720c68e584b9b628ded5ceacfd Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 13 Jul 2026 02:23:35 +0530 Subject: [PATCH] send AI agent confirmation question as a separate widget message The model marks its trailing confirmation question with a [[confirm]] line. We split that off and send it as its own chat bubble so the widget reads like a real conversation. The customer never sees the marker. Email is left as one combined message since separate bubbles only suit the widget. Confirmation messages are tagged with is_confirmation in meta so they no longer inflate the reply count in assistant stats. Keep the widget typing indicator alive during long agent runs by re-broadcasting every 3s, under the widget's 5s typing expiry. Fix the AI assistant and tool edit forms to stay on the page after save. They now only go back to the list when creating, matching the rest of the admin forms. --- .../views/admin/ai/CreateOrEditAssistant.vue | 2 +- .../src/views/admin/ai/CreateOrEditTool.vue | 2 +- internal/aiagent/prompt.go | 2 +- internal/aiagent/queries.sql | 3 +- internal/aiagent/worker.go | 76 +++++++++++++++++-- 5 files changed, 74 insertions(+), 11 deletions(-) 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 +}