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