From dad99bf0eebcc308db2e2b3c661d33a25258a135 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 12 Jul 2026 23:12:44 +0530 Subject: [PATCH] render AI agent replies as markdown and fix assistant identity, handoff, and stats bugs Replies from the AI agent are now converted from markdown to HTML with goldmark before queueing, so bold, links, and lists render properly in the widget, agent app, and email. The prompt now allows simple markdown. Raw HTML in model output is escaped by goldmark, and both frontends sanitize on render anyway. Other fixes bundled in: - validate avatar type and size before creating or updating an assistant, and roll back the assistant if the avatar upload fails after create - return 404 from agent update and API key endpoints for AI assistant identity users, and hide assistants from mention and SLA user pickers - reserve the autonomous assistant's built-in tool names so custom tools cannot shadow them - apply resolve after the reply is posted so the CSAT survey follows the answer instead of preceding it - unassign the assistant on handoff even when the fallback team is the same team - count reopens by status category instead of status name, and exclude CSAT messages from the turn cap - split oversized wrapper divs into child blocks when chunking KB HTML instead of truncating them - return 404 when soft-deleting an already-deleted agent, and keep AI assistants (which have no email) visible in the compact users list --- cmd/aiagent.go | 9 ++++ cmd/users.go | 54 +++++++++++++------ .../main/src/features/admin/sla/SLAForm.vue | 10 ++-- .../features/conversation/ReplyBoxContent.vue | 2 +- frontend/apps/main/src/stores/users.js | 1 + .../views/admin/ai/CreateOrEditAssistant.vue | 2 +- go.mod | 1 + go.sum | 2 + internal/ai/tools.go | 17 +++--- internal/aiagent/prompt.go | 2 +- internal/aiagent/queries.sql | 9 ++-- internal/aiagent/tools.go | 21 ++------ internal/aiagent/worker.go | 33 ++++++++++-- internal/stringutil/htmlchunker.go | 46 +++++++++++++--- internal/stringutil/htmlchunker_test.go | 15 +++++- internal/stringutil/stringutil.go | 18 +++++++ internal/user/agent.go | 6 ++- internal/user/queries.sql | 5 +- 18 files changed, 184 insertions(+), 69 deletions(-) diff --git a/cmd/aiagent.go b/cmd/aiagent.go index 72e29b80..b801d1b7 100644 --- a/cmd/aiagent.go +++ b/cmd/aiagent.go @@ -42,11 +42,17 @@ func handleCreateAIAssistant(r *fastglue.Request) error { 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) @@ -66,6 +72,9 @@ func handleUpdateAIAssistant(r *fastglue.Request) error { 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) diff --git a/cmd/users.go b/cmd/users.go index 416d1d77..48448e6a 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -272,6 +272,10 @@ func handleUpdateAgent(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + // AI assistant identity users are managed via the AI assistant endpoints only. + if agent.Type != models.UserTypeAgent { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", app.i18n.T("globals.terms.agent")), nil, envelope.NotFoundError) + } oldAvailabilityStatus := agent.AvailabilityStatus // Update agent with individual fields @@ -470,10 +474,36 @@ func handleSetPassword(r *fastglue.Request) error { return r.SendEnvelope(true) } +// validateAvatarFile checks avatar type and size without side effects. +func validateAvatarFile(r *fastglue.Request, files []*multipart.FileHeader) error { + var app = r.Context.(*App) + + if len(files) == 0 { + return nil + } + fileHeader := files[0] + srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(stringutil.SanitizeFilename(fileHeader.Filename))), ".") + if !slices.Contains(image.Exts, srcExt) { + return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil) + } + if bytesToMegabytes(fileHeader.Size) > maxAvatarSizeMB { + return envelope.NewError( + envelope.InputError, + app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)), + nil, + ) + } + return nil +} + // uploadUserAvatar uploads the user avatar. func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error { var app = r.Context.(*App) + if err := validateAvatarFile(r, files); err != nil { + return err + } + fileHeader := files[0] file, err := fileHeader.Open() if err != nil { @@ -482,25 +512,9 @@ func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart. } defer file.Close() - // Sanitize filename. srcFileName := stringutil.SanitizeFilename(fileHeader.Filename) srcContentType := fileHeader.Header.Get("Content-Type") srcFileSize := fileHeader.Size - srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".") - - if !slices.Contains(image.Exts, srcExt) { - return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil) - } - - // Check file size - if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB { - app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB) - return envelope.NewError( - envelope.InputError, - app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)), - nil, - ) - } // Reset ptr. file.Seek(0, 0) @@ -545,6 +559,9 @@ func handleGenerateAPIKey(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + if user.Type != models.UserTypeAgent { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", app.i18n.T("globals.terms.agent")), nil, envelope.NotFoundError) + } // Generate API key and secret apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID) @@ -576,10 +593,13 @@ func handleRevokeAPIKey(r *fastglue.Request) error { } // Check if user exists - _, err := app.user.GetAgent(id, "") + user, err := app.user.GetAgent(id, "") if err != nil { return sendErrorEnvelope(r, err) } + if user.Type != models.UserTypeAgent { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", app.i18n.T("globals.terms.agent")), nil, envelope.NotFoundError) + } // Revoke API key if err := app.user.RevokeAPIKey(id); err != nil { diff --git a/frontend/apps/main/src/features/admin/sla/SLAForm.vue b/frontend/apps/main/src/features/admin/sla/SLAForm.vue index e56d4088..b38e8c2f 100644 --- a/frontend/apps/main/src/features/admin/sla/SLAForm.vue +++ b/frontend/apps/main/src/features/admin/sla/SLAForm.vue @@ -203,10 +203,12 @@ { const q = query.toLowerCase() const users = usersStore.users - .filter((u) => u.enabled) + .filter((u) => u.enabled && u.type !== 'ai_assistant') .filter((u) => `${u.first_name} ${u.last_name}`.toLowerCase().includes(q)) .map((u) => ({ id: u.id, diff --git a/frontend/apps/main/src/stores/users.js b/frontend/apps/main/src/stores/users.js index 832c7c10..e3dae65c 100644 --- a/frontend/apps/main/src/stores/users.js +++ b/frontend/apps/main/src/stores/users.js @@ -12,6 +12,7 @@ export const useUsersStore = defineStore('users', () => { const options = computed(() => users.value.map(user => ({ label: user.first_name + ' ' + user.last_name, value: String(user.id), + type: user.type, avatar_url: user.avatar_url, availability_status: user.availability_status, }))) diff --git a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue index e435697d..e7aea3b3 100644 --- a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue +++ b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue @@ -146,7 +146,7 @@ const buildDelta = (raw, unit, increaseIsGood) => { const value = raw ?? 0 const suffix = unit === 'percent' ? '%' : '' if (value === 0) { - return { deltaText: '—', deltaClass: 'text-muted-foreground' } + return { deltaText: '-', deltaClass: 'text-muted-foreground' } } const arrow = value > 0 ? '▲' : '▼' const good = value > 0 ? increaseIsGood : !increaseIsGood diff --git a/go.mod b/go.mod index 04935e07..e0b2aa01 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/valyala/fasthttp v1.62.0 github.com/volatiletech/null/v9 v9.0.0 + github.com/yuin/goldmark v1.8.4 github.com/zerodha/fastglue v1.8.0 github.com/zerodha/logf v0.5.5 github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0 diff --git a/go.sum b/go.sum index 4efff6d5..45d28634 100644 --- a/go.sum +++ b/go.sum @@ -167,6 +167,8 @@ github.com/volatiletech/null/v9 v9.0.0/go.mod h1:zRFghPVahaiIMRXiUJrc6gsoG83Cm3Z github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zerodha/fastglue v1.8.0 h1:yCfb8YwZLoFrzHiojRcie19olLDT48vjuinVn1Ge5Uc= diff --git a/internal/ai/tools.go b/internal/ai/tools.go index e792cf0c..06ab4675 100644 --- a/internal/ai/tools.go +++ b/internal/ai/tools.go @@ -24,8 +24,16 @@ const ( ) var ( - // reservedToolNames are built-in tool names custom tools may not use. - reservedToolNames = map[string]bool{toolSearchArticles: true} + // reservedToolNames are built-in tool names (including the autonomous assistant's, see + // internal/aiagent/tools.go) custom tools may not use: a colliding custom tool would be + // silently replaced by the built-in in the agent loop's registry. + reservedToolNames = map[string]bool{ + toolSearchArticles: true, + "search_knowledge_base": true, + "hand_off_to_human": true, + "resolve": true, + "get_previous_conversations": true, + } // allowedToolMethods are the HTTP methods a custom tool may use: GET reads, POST writes. allowedToolMethods = map[string]bool{http.MethodGet: true, http.MethodPost: true} @@ -210,10 +218,7 @@ func (t *httpTool) Execute(ctx context.Context, args string) (string, error) { return string(out), nil } -// buildToolRegistry returns the built-in and enabled custom tools plus their model-facing definitions. -// buildToolRegistry assembles the tools advertised to the model. allowedToolIDs nil means all enabled -// custom tools (trusted agent-facing callers); non-nil restricts to that set (the autonomous assistant's -// granted tools). includeBuiltinSearch adds the global knowledge search tool. +// buildToolRegistry assembles the tools advertised to the model: allowedToolIDs nil means all enabled custom tools, non-nil restricts to that set. func (m *Manager) buildToolRegistry(tctx ToolContext, allowedToolIDs []int, includeBuiltinSearch bool) (map[string]Tool, []models.ToolDef, error) { registry := map[string]Tool{} var defs []models.ToolDef diff --git a/internal/aiagent/prompt.go b/internal/aiagent/prompt.go index 72844424..c9987010 100644 --- a/internal/aiagent/prompt.go +++ b/internal/aiagent/prompt.go @@ -22,7 +22,7 @@ Core rules: - Never invent or guess facts, policies, prices, steps, or promises. - Do not mention tools, searching, retrieval, the knowledge base, or your reasoning to the customer. Never say you "could not find" anything. - Reply in the same language as the customer's last message. -- Keep replies short and conversational, usually one or two sentences. Write like speech: no markdown, bullet lists, numbered steps, or code blocks. +- Keep replies short and conversational, usually one or two sentences. You may use simple markdown (bold, links, bullet or numbered lists) when it genuinely helps, such as listing steps; otherwise write like speech. Never use headings, tables, code blocks, or images. - Do not promise to do something after this reply (check, look into it, follow up, email, call, refund, cancel, escalate) unless you actually do it now with a tool. - When the request is ambiguous, ask one short clarifying question instead of assuming. - Do not end the conversation with filler like "Talk soon" or "How can I help you further?". diff --git a/internal/aiagent/queries.sql b/internal/aiagent/queries.sql index 2bacdf4a..fa4d6a00 100644 --- a/internal/aiagent/queries.sql +++ b/internal/aiagent/queries.sql @@ -72,15 +72,13 @@ INSERT INTO ai_agent_events (assistant_id, conversation_id, type) VALUES ($1, $2 -- name: get-assistant-window-stats -- $1 = assistant user id (message sender), $2 = assistant id (events), $3 = window start, $4 = window end. --- CSAT survey messages are sent under the assistant's identity but are not genuine replies, so they --- are excluded from the reply/conversation counts. 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(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 - WHERE e.assistant_id = $2 AND e.type = 'resolve' AND e.created_at >= $3 AND e.created_at < $4 AND s.name <> 'Resolved') AS reopened, + WHERE e.assistant_id = $2 AND e.type = 'resolve' AND e.created_at >= $3 AND e.created_at < $4 AND s.category <> 'resolved') AS reopened, (SELECT count(*) FROM csat_responses cr WHERE cr.rating > 0 AND cr.created_at >= $3 AND cr.created_at < $4 AND EXISTS ( SELECT 1 FROM conversation_messages m WHERE m.conversation_id = cr.conversation_id AND m.sender_id = $1 AND m.type = 'outgoing' AND m.private = false AND NOT COALESCE((m.meta->>'is_csat')::boolean, false))) AS csat_count, COALESCE((SELECT round(avg(cr.rating)::numeric, 2) FROM csat_responses cr WHERE cr.rating > 0 AND cr.created_at >= $3 AND cr.created_at < $4 AND EXISTS ( @@ -89,11 +87,10 @@ SELECT SELECT 1 FROM conversation_messages m WHERE m.conversation_id = cr.conversation_id AND m.sender_id = $1 AND m.type = 'outgoing' AND m.private = false AND NOT COALESCE((m.meta->>'is_csat')::boolean, false))), 0)::float8 AS csat_positive; -- name: count-ai-turns-since-assignment --- Counts the assistant's public replies since it was last (re)assigned, so a fresh assignment resets --- the turn budget. Keyed off the last assignment activity only; unrelated activity (priority, status, --- tag changes) must not reset the cap. +-- Counts the assistant's public non-CSAT replies since the last assignment activity. 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 created_at > COALESCE(( SELECT max(created_at) FROM conversation_messages WHERE conversation_id = $1 AND type = 'activity' diff --git a/internal/aiagent/tools.go b/internal/aiagent/tools.go index 428b0794..ba739f1c 100644 --- a/internal/aiagent/tools.go +++ b/internal/aiagent/tools.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "html" "slices" "strings" @@ -122,10 +121,9 @@ func (t *handoffTool) Execute(ctx context.Context, args string) (string, error) } type resolveTool struct { - m *Manager - conv cmodels.Conversation - assistant models.Assistant - outcome *runOutcome + m *Manager + conv cmodels.Conversation + outcome *runOutcome } func (t *resolveTool) Name() string { return "resolve" } @@ -136,14 +134,10 @@ func (t *resolveTool) Description() string { func (t *resolveTool) Parameters() types.JSONText { return emptyParams } +// Execute only records the intent; the status change (which sends the CSAT survey) is applied +// after the assistant's reply is posted, so the survey never reaches the customer first. func (t *resolveTool) Execute(ctx context.Context, args string) (string, error) { t.m.lo.Debug("ai agent resolve tool called", "conversation_uuid", t.conv.UUID) - actor := t.m.actorUser(t.assistant) - if err := t.m.convo.UpdateConversationStatus(t.conv.UUID, 0, cmodels.StatusResolved, "", actor); err != nil { - t.m.lo.Error("error resolving conversation", "conversation_uuid", t.conv.UUID, "error", err) - return "Failed to resolve the conversation.", nil - } - t.m.recordEvent(t.assistant.ID, t.conv.ID, "resolve") t.outcome.resolved = true return "Conversation marked as resolved.", nil } @@ -190,8 +184,3 @@ func (t *previousConversationsTool) Execute(ctx context.Context, args string) (s } return b.String(), nil } - -// textToHTML escapes plain text and converts newlines to
for the HTML reply body. -func textToHTML(s string) string { - return strings.ReplaceAll(html.EscapeString(s), "\n", "
") -} diff --git a/internal/aiagent/worker.go b/internal/aiagent/worker.go index d8328f7b..fe717159 100644 --- a/internal/aiagent/worker.go +++ b/internal/aiagent/worker.go @@ -2,6 +2,7 @@ package aiagent import ( "context" + "database/sql" "fmt" "strings" @@ -11,6 +12,7 @@ import ( "github.com/abhinavxd/libredesk/internal/attachment" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" imageutil "github.com/abhinavxd/libredesk/internal/image" + "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" ) @@ -117,6 +119,9 @@ func (m *Manager) handle(ctx context.Context, convID int) { } assistant, err := m.GetAssistantByUserID(int(conv.AssignedUserID.Int)) if err != nil { + if err != sql.ErrNoRows { + m.lo.Error("error fetching assistant for ai agent", "conversation_id", convID, "user_id", conv.AssignedUserID.Int, "error", err) + } return } m.lo.Debug("ai agent handling conversation", "conversation_id", convID, "conversation_uuid", conv.UUID, "assistant_id", assistant.ID, "assistant", assistant.Name, "status", conv.Status.String, "enabled", assistant.Enabled) @@ -168,7 +173,7 @@ func (m *Manager) handle(ctx context.Context, convID int) { tools := []ai.Tool{ &searchKnowledgeTool{m: m}, &handoffTool{m: m, conv: conv, assistant: assistant, outcome: outcome}, - &resolveTool{m: m, conv: conv, assistant: assistant, outcome: outcome}, + &resolveTool{m: m, conv: conv, outcome: outcome}, } if recent := m.recentContactConversations(conv); len(recent) > 0 { systemPrompt += fmt.Sprintf("\n\nThis customer has %d other conversation(s) from the last %d days. Call get_previous_conversations if the current issue might be a follow-up or related to them.", len(recent), recentConversationDays) @@ -199,18 +204,32 @@ func (m *Manager) handle(ctx context.Context, convID int) { if answer != "" { m.lo.Debug("ai agent replying", "conversation_uuid", conv.UUID, "reply_len", len(answer), "resolved", outcome.resolved) m.postReply(conv, assistant, answer) - } else if !outcome.resolved { + } + if outcome.resolved { + m.resolve(conv, assistant) + return + } + if answer == "" { m.lo.Debug("ai agent no answer, handing off", "conversation_uuid", conv.UUID) m.handoff(conv, assistant, m.i18n.T("ai.agent.handoffNoAnswer")) } } +// resolve runs after the reply is queued so the CSAT survey sent on resolve follows the answer. +func (m *Manager) resolve(conv cmodels.Conversation, assistant models.Assistant) { + if err := m.convo.UpdateConversationStatus(conv.UUID, 0, cmodels.StatusResolved, "", m.actorUser(assistant)); err != nil { + m.lo.Error("error resolving conversation", "conversation_uuid", conv.UUID, "error", err) + return + } + m.recordEvent(assistant.ID, conv.ID, "resolve") +} + func (m *Manager) postReply(conv cmodels.Conversation, assistant models.Assistant, text string) { 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, textToHTML(text), to, nil, nil, map[string]interface{}{}); err != nil { + 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 { m.lo.Error("error sending assistant reply", "conversation_uuid", conv.UUID, "error", err) } } @@ -227,10 +246,14 @@ func (m *Manager) handoff(conv cmodels.Conversation, assistant models.Assistant, m.lo.Error("error posting handoff note", "conversation_uuid", conv.UUID, "error", err) } if assistant.FallbackTeamID.Valid { - if err := m.convo.UpdateConversationTeamAssignee(conv.UUID, int(assistant.FallbackTeamID.Int), actor); err != nil { + fallbackTeamID := int(assistant.FallbackTeamID.Int) + if err := m.convo.UpdateConversationTeamAssignee(conv.UUID, fallbackTeamID, actor); err != nil { m.lo.Error("error assigning fallback team", "conversation_uuid", conv.UUID, "error", err) } - return + // A same-team assignment keeps the assigned user, so the assistant must be removed explicitly. + if int(conv.AssignedTeamID.Int) != fallbackTeamID { + return + } } if err := m.convo.RemoveConversationAssignee(conv.UUID, cmodels.AssigneeTypeUser, actor); err != nil { m.lo.Error("error unassigning assistant on handoff", "conversation_uuid", conv.UUID, "error", err) diff --git a/internal/stringutil/htmlchunker.go b/internal/stringutil/htmlchunker.go index b7d70a07..cfbad248 100644 --- a/internal/stringutil/htmlchunker.go +++ b/internal/stringutil/htmlchunker.go @@ -141,13 +141,18 @@ func parseHTMLBoundaries(htmlContent string, cfg ChunkConfig) ([]htmlBoundary, e } if isBlockElement(tag) { - boundaries = append(boundaries, htmlBoundary{ - Type: tag, - Content: contentStr, - Priority: getPriority(tag), - Tokens: cfg.TokenizerFunc(cleanText), - }) - return + tokens := cfg.TokenizerFunc(cleanText) + // An oversized container taken as one atomic boundary would be truncated at + // MaxTokens, silently dropping the rest; descend into its block children instead. + if tokens <= cfg.MaxTokens || isPreservedBlock(tag, cfg.PreserveBlocks) || !splittableIntoBlocks(n) { + boundaries = append(boundaries, htmlBoundary{ + Type: tag, + Content: contentStr, + Priority: getPriority(tag), + Tokens: tokens, + }) + return + } } } @@ -181,6 +186,31 @@ func isPreservedBlock(blockType string, preserveBlocks []string) bool { return slices.Contains(preserveBlocks, blockType) } +// splittableIntoBlocks reports whether all of a node's visible content lives inside block-element +// children, so splitting the node into its children loses nothing. +func splittableIntoBlocks(n *html.Node) bool { + hasBlock := false + for c := n.FirstChild; c != nil; c = c.NextSibling { + switch c.Type { + case html.TextNode: + if strings.TrimSpace(c.Data) != "" { + return false + } + case html.ElementNode: + if isBlockElement(strings.ToLower(c.Data)) { + hasBlock = true + continue + } + var buf strings.Builder + html.Render(&buf, c) + if strings.TrimSpace(HTML2Text(buf.String())) != "" { + return false + } + } + } + return hasBlock +} + func mergeBoundaries(boundaries []htmlBoundary, cfg ChunkConfig) []htmlBoundary { if len(boundaries) == 0 { return boundaries @@ -293,7 +323,7 @@ func createChunks(boundaries []htmlBoundary, cfg ChunkConfig) []htmlBoundary { chunks = append(chunks, currentChunk) var overlapContent string - if !isPreservedBlock(boundary.Type, cfg.PreserveBlocks) && len(chunks) > 0 { + if !isPreservedBlock(boundary.Type, cfg.PreserveBlocks) { overlapContent = extractOverlap(currentChunk.Content, cfg) } diff --git a/internal/stringutil/htmlchunker_test.go b/internal/stringutil/htmlchunker_test.go index a25b5fc3..1a17fcb9 100644 --- a/internal/stringutil/htmlchunker_test.go +++ b/internal/stringutil/htmlchunker_test.go @@ -193,6 +193,17 @@ func TestChunkHTMLContent_EdgeCases(t *testing.T) { config: newTestConfig(50, 20, 10), expectedChunks: 1, // Should truncate oversized content to fit max tokens }, + { + name: "Oversized Wrapper Div Splits Into Children", + html: "
" + strings.Repeat("

"+strings.Repeat("word ", 100)+"

", 10) + "
", + config: newTestConfig(150, 20, 10), + validate: func(t *testing.T, chunks []string) { + assert.Greater(t, len(chunks), 1, "Wrapper div content should be split, not truncated to one chunk") + full := strings.Join(chunks, " ") + assert.Greater(t, len(full), 4000, "Most of the wrapped content should survive chunking") + }, + expectedChunks: -1, // Validated via validate func + }, } for _, tc := range testCases { @@ -203,7 +214,9 @@ func TestChunkHTMLContent_EdgeCases(t *testing.T) { assert.Contains(t, err.Error(), tc.expectedError) } else { require.NoError(t, err) - assert.Len(t, chunks, tc.expectedChunks) + if tc.expectedChunks >= 0 { + assert.Len(t, chunks, tc.expectedChunks) + } if tc.validate != nil { tc.validate(t, chunks) } diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index e9849e5b..b961bbb1 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -11,6 +11,9 @@ import ( "time" "github.com/jaytaylor/html2text" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" ) const ( @@ -23,6 +26,12 @@ var ( uuidV4Regex = regexp.MustCompile(`[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}`) regexpRefNumber = regexp.MustCompile(`#(\d+)`) regexpConvUUID = regexp.MustCompile(`(?i)\+conv-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}@`) + + // markdownRenderer escapes raw HTML in the input; single newlines render as
. + markdownRenderer = goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions(html.WithHardWraps()), + ) ) // SanitizeUTF8 removes NUL bytes and replaces invalid UTF-8 byte sequences with the Unicode replacement character. @@ -43,6 +52,15 @@ func HTML2Text(html string) string { return strings.TrimSpace(out) } +// Markdown2HTML converts markdown to HTML, falling back to the input on error. +func Markdown2HTML(md string) string { + var b strings.Builder + if err := markdownRenderer.Convert([]byte(md), &b); err != nil { + return md + } + return b.String() +} + // SanitizeFilename sanitizes the provided filename. func SanitizeFilename(fName string) string { // Trim whitespace. diff --git a/internal/user/agent.go b/internal/user/agent.go index 0b06d768..958473ca 100644 --- a/internal/user/agent.go +++ b/internal/user/agent.go @@ -149,10 +149,14 @@ func (u *Manager) SoftDeleteAgent(id int) error { if id == systemUser.ID { return envelope.NewError(envelope.InputError, u.i18n.T("user.cannotDeleteSystemUser"), nil) } - if _, err := u.q.SoftDeleteAgent.Exec(id); err != nil { + var deleted int + if err := u.q.SoftDeleteAgent.Get(&deleted, id); err != nil { u.lo.Error("error deleting user", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } + if deleted == 0 { + return envelope.NewError(envelope.NotFoundError, u.i18n.Ts("globals.messages.notFound", "name", u.i18n.T("globals.terms.agent")), nil) + } return nil } diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 618e3eee..c2501299 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -1,7 +1,8 @@ -- name: get-users-compact SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled, users.external_user_id, users.availability_status FROM users -WHERE users.email IS DISTINCT FROM 'System' AND users.deleted_at IS NULL AND type = ANY($1) +-- email != 'System' also drops NULL-email users (anonymous visitors); AI assistants have no email and must still be listed. +WHERE (users.email != 'System' OR users.type = 'ai_assistant') AND users.deleted_at IS NULL AND type = ANY($1) -- name: soft-delete-agent WITH soft_delete AS ( @@ -21,7 +22,7 @@ delete_user_roles AS ( WHERE user_id IN (SELECT id FROM soft_delete) RETURNING 1 ) -SELECT 1; +SELECT count(*) FROM soft_delete; -- name: get-user SELECT