mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
+37
-17
@@ -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 {
|
||||
|
||||
@@ -203,10 +203,12 @@
|
||||
<FormControl>
|
||||
<SelectTag
|
||||
:items="
|
||||
usersStore.options.concat({
|
||||
label: t('admin.sla.assignedUser'),
|
||||
value: 'assigned_user'
|
||||
})
|
||||
usersStore.options
|
||||
.filter((o) => o.type !== 'ai_assistant')
|
||||
.concat({
|
||||
label: t('admin.sla.assignedUser'),
|
||||
value: 'assigned_user'
|
||||
})
|
||||
"
|
||||
:placeholder="t('globals.messages.startTypingToSearch')"
|
||||
v-model="componentField.modelValue"
|
||||
|
||||
@@ -185,7 +185,7 @@ const getSuggestions = async (query) => {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
})))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+11
-6
@@ -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
|
||||
|
||||
@@ -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?".
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 <br> for the HTML reply body.
|
||||
func textToHTML(s string) string {
|
||||
return strings.ReplaceAll(html.EscapeString(s), "\n", "<br>")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "<div>" + strings.Repeat("<p>"+strings.Repeat("word ", 100)+"</p>", 10) + "</div>",
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 <br>.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user