feat: Add Markdown to HTML conversion and clean JSON response utility

- Implemented MarkdownToHTML function using goldmark for converting markdown content to HTML.
- Added CleanJSONResponse function to remove markdown code blocks from LLM responses.
- Updated stringutil tests to remove unnecessary test cases for empty strings and special characters.

refactor: Update SQL schema for knowledge base and help center

- Introduced ai_knowledge_type enum for knowledge base categorization.
- Added help_center_id reference in inboxes table.
- Enhanced help_centers table with default_locale column.
- Changed data types from INTEGER to INT for consistency across tables.
- Renamed ai_custom_answers table to ai_knowledge_base and adjusted its schema.

fix: Remove unnecessary CSS filter from default icon in widget

- Cleaned up widget.js by removing the brightness filter from the default icon styling.
This commit is contained in:
Abhinav Raut
2025-08-17 19:21:14 +05:30
parent 8bf0255b61
commit 30902310dc
71 changed files with 7918 additions and 950 deletions

View File

@@ -1,111 +0,0 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// customAnswerReq represents the request payload for custom answers.
type customAnswerReq struct {
Question string `json:"question"`
Answer string `json:"answer"`
Enabled bool `json:"enabled"`
}
// validateCustomAnswerReq validates the custom answer request payload.
func validateCustomAnswerReq(r *fastglue.Request, customAnswerData *customAnswerReq) error {
var app = r.Context.(*App)
if customAnswerData.Question == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`question`"), nil, envelope.InputError)
}
if customAnswerData.Answer == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`answer`"), nil, envelope.InputError)
}
return nil
}
// handleGetAICustomAnswers returns all AI custom answers from the database.
func handleGetAICustomAnswers(r *fastglue.Request) error {
var app = r.Context.(*App)
customAnswers, err := app.ai.GetAICustomAnswers()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(customAnswers)
}
// handleGetAICustomAnswer returns a single AI custom answer by ID.
func handleGetAICustomAnswer(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
customAnswer, err := app.ai.GetAICustomAnswer(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(customAnswer)
}
// handleCreateAICustomAnswer creates a new AI custom answer in the database.
func handleCreateAICustomAnswer(r *fastglue.Request) error {
var (
app = r.Context.(*App)
customAnswerData customAnswerReq
)
if err := r.Decode(&customAnswerData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCustomAnswerReq(r, &customAnswerData); err != nil {
return err
}
customAnswer, err := app.ai.CreateAICustomAnswer(customAnswerData.Question, customAnswerData.Answer, customAnswerData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(customAnswer)
}
// handleUpdateAICustomAnswer updates an existing AI custom answer in the database.
func handleUpdateAICustomAnswer(r *fastglue.Request) error {
var (
app = r.Context.(*App)
customAnswerData customAnswerReq
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&customAnswerData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCustomAnswerReq(r, &customAnswerData); err != nil {
return err
}
customAnswer, err := app.ai.UpdateAICustomAnswer(id, customAnswerData.Question, customAnswerData.Answer, customAnswerData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(customAnswer)
}
// handleDeleteAICustomAnswer deletes an AI custom answer from the database.
func handleDeleteAICustomAnswer(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.ai.DeleteAICustomAnswer(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -28,22 +28,6 @@ var (
}
)
// handleGetCustomAttribute retrieves a custom attribute by its ID.
func handleGetCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
attribute, err := app.customAttribute.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attribute)
}
// handleGetCustomAttributes retrieves all custom attributes from the database.
func handleGetCustomAttributes(r *fastglue.Request) error {

View File

@@ -100,12 +100,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/ai-assistants/{id}", perm(handleUpdateAIAssistant, "ai:manage"))
g.DELETE("/api/v1/ai-assistants/{id}", perm(handleDeleteAIAssistant, "ai:manage"))
// AI Custom Answers.
g.GET("/api/v1/ai-custom-answers", perm(handleGetAICustomAnswers, "ai:manage"))
g.GET("/api/v1/ai-custom-answers/{id}", perm(handleGetAICustomAnswer, "ai:manage"))
g.POST("/api/v1/ai-custom-answers", perm(handleCreateAICustomAnswer, "ai:manage"))
g.PUT("/api/v1/ai-custom-answers/{id}", perm(handleUpdateAICustomAnswer, "ai:manage"))
g.DELETE("/api/v1/ai-custom-answers/{id}", perm(handleDeleteAICustomAnswer, "ai:manage"))
// AI Snippets.
g.GET("/api/v1/ai-snippets", perm(handleGetAISnippets, "ai:manage"))
g.GET("/api/v1/ai-snippets/{id}", perm(handleGetAISnippet, "ai:manage"))
g.POST("/api/v1/ai-snippets", perm(handleCreateAISnippet, "ai:manage"))
g.PUT("/api/v1/ai-snippets/{id}", perm(handleUpdateAISnippet, "ai:manage"))
g.DELETE("/api/v1/ai-snippets/{id}", perm(handleDeleteAISnippet, "ai:manage"))
// Macros.
g.GET("/api/v1/macros", auth(handleGetMacros))
@@ -220,7 +220,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Custom attributes.
g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes))
g.POST("/api/v1/custom-attributes", perm(handleCreateCustomAttribute, "custom_attributes:manage"))
g.GET("/api/v1/custom-attributes/{id}", perm(handleGetCustomAttribute, "custom_attributes:manage"))
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
@@ -241,8 +240,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/help-centers/{hc_id}/collections", perm(handleCreateCollection, "help_center:manage"))
g.PUT("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleUpdateCollection, "help_center:manage"))
g.DELETE("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleDeleteCollection, "help_center:manage"))
g.PUT("/api/v1/help-centers/{hc_id}/collections/reorder", perm(handleReorderCollections, "help_center:manage"))
g.PUT("/api/v1/collections/{id}/move", perm(handleMoveCollection, "help_center:manage"))
g.PUT("/api/v1/collections/{id}/toggle", perm(handleToggleCollection, "help_center:manage"))
// Articles.
@@ -250,9 +247,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/collections/{col_id}/articles/{id}", auth(handleGetArticle))
g.POST("/api/v1/collections/{col_id}/articles", perm(handleCreateArticle, "help_center:manage"))
g.PUT("/api/v1/collections/{col_id}/articles/{id}", perm(handleUpdateArticle, "help_center:manage"))
g.PUT("/api/v1/articles/{id}", perm(handleUpdateArticleByID, "help_center:manage"))
g.DELETE("/api/v1/collections/{col_id}/articles/{id}", perm(handleDeleteArticle, "help_center:manage"))
g.PUT("/api/v1/collections/{col_id}/articles/reorder", perm(handleReorderArticles, "help_center:manage"))
g.PUT("/api/v1/articles/{id}/move", perm(handleMoveArticle, "help_center:manage"))
g.PUT("/api/v1/articles/{id}/status", perm(handleUpdateArticleStatus, "help_center:manage"))
// CSAT.

548
cmd/helpcenter.go Normal file
View File

@@ -0,0 +1,548 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/helpcenter"
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// Help Centers
// handleGetHelpCenters returns all help centers from the database.
func handleGetHelpCenters(r *fastglue.Request) error {
app := r.Context.(*App)
helpCenters, err := app.helpcenter.GetAllHelpCenters()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenters)
}
// handleGetHelpCenter returns a specific help center by ID.
func handleGetHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
helpCenter, err := app.helpcenter.GetHelpCenterByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleCreateHelpCenter creates a new help center.
func handleCreateHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.HelpCenterCreateRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateHelpCenter(r, &req); err != nil {
return err
}
helpCenter, err := app.helpcenter.CreateHelpCenter(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleUpdateHelpCenter updates an existing help center.
func handleUpdateHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.HelpCenterUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateHelpCenter(r, &req); err != nil {
return err
}
helpCenter, err := app.helpcenter.UpdateHelpCenter(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(helpCenter)
}
// handleDeleteHelpCenter deletes a help center.
func handleDeleteHelpCenter(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteHelpCenter(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// Collections
// handleGetCollections returns all collections for a help center.
func handleGetCollections(r *fastglue.Request) error {
var (
app = r.Context.(*App)
helpCenterID, _ = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
err error
)
if helpCenterID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`help_center_id`"), nil, envelope.InputError)
}
// Check for locale filter
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
var collections []hcmodels.Collection
if locale != "" {
collections, err = app.helpcenter.GetCollectionsByHelpCenterAndLocale(helpCenterID, locale)
} else {
collections, err = app.helpcenter.GetCollectionsByHelpCenter(helpCenterID)
}
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collections)
}
// handleGetCollection returns a specific collection by ID.
func handleGetCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
collection, err := app.helpcenter.GetCollectionByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleCreateCollection creates a new collection.
func handleCreateCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.CollectionCreateRequest{}
helpCenterID, err = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
)
if helpCenterID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`help_center_id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCollection(r, &req); err != nil {
return err
}
// Generate slug.
req.Slug = stringutil.GenerateSlug(req.Name, true)
collection, err := app.helpcenter.CreateCollection(helpCenterID, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleUpdateCollection updates an existing collection.
func handleUpdateCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.CollectionUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateCollection(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Name, true)
collection, err := app.helpcenter.UpdateCollection(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// handleDeleteCollection deletes a collection.
func handleDeleteCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteCollection(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleToggleCollection toggles the published status of a collection.
func handleToggleCollection(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
collection, err := app.helpcenter.ToggleCollectionPublished(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(collection)
}
// Articles
// handleGetArticles returns all articles for a collection.
func handleGetArticles(r *fastglue.Request) error {
var (
app = r.Context.(*App)
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
err error
)
if collectionID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`collection_id`"), nil, envelope.InputError)
}
// Check for locale filter
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
var articles []hcmodels.Article
if locale != "" {
articles, err = app.helpcenter.GetArticlesByCollectionAndLocale(collectionID, locale)
} else {
articles, err = app.helpcenter.GetArticlesByCollection(collectionID)
}
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(articles)
}
// handleGetArticle returns a specific article by ID.
func handleGetArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
article, err := app.helpcenter.GetArticleByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleCreateArticle creates a new article.
func handleCreateArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleCreateRequest{}
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
)
if collectionID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`collection_id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.CreateArticle(collectionID, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleUpdateArticle updates an existing article.
func handleUpdateArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.UpdateArticle(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleUpdateArticleByID updates an existing article by its ID (allows collection changes).
func handleUpdateArticleByID(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.ArticleUpdateRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateArticle(r, &req); err != nil {
return err
}
// Generate slug
req.Slug = stringutil.GenerateSlug(req.Title, true)
if req.Status == "" {
req.Status = hcmodels.ArticleStatusDraft
}
article, err := app.helpcenter.UpdateArticle(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleDeleteArticle deletes an article.
func handleDeleteArticle(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.helpcenter.DeleteArticle(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateArticleStatus updates the status of an article.
func handleUpdateArticleStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req = helpcenter.UpdateStatusRequest{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if req.Status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
}
article, err := app.helpcenter.UpdateArticleStatus(id, req.Status)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(article)
}
// handleGetHelpCenterTree returns the complete tree structure for a help center.
func handleGetHelpCenterTree(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Get locale from query parameter (optional)
locale := string(r.RequestCtx.QueryArgs().Peek("locale"))
tree, err := app.helpcenter.GetHelpCenterTree(id, locale)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(tree)
}
func validateHelpCenter(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.HelpCenterCreateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Slug == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil, envelope.InputError)
}
if v.PageTitle == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil, envelope.InputError)
}
if v.DefaultLocale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`default_locale`"), nil, envelope.InputError)
}
case *helpcenter.HelpCenterUpdateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Slug == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil, envelope.InputError)
}
if v.PageTitle == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil, envelope.InputError)
}
if v.DefaultLocale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`default_locale`"), nil, envelope.InputError)
}
}
return nil
}
func validateCollection(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.CollectionCreateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
case *helpcenter.CollectionUpdateRequest:
if v.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
}
return nil
}
func validateArticle(r *fastglue.Request, req any) error {
app := r.Context.(*App)
switch v := req.(type) {
case *helpcenter.ArticleCreateRequest:
if v.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil, envelope.InputError)
}
if v.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
case *helpcenter.ArticleUpdateRequest:
if v.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil, envelope.InputError)
}
if v.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
if v.Locale == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`locale`"), nil, envelope.InputError)
}
}
return nil
}

View File

@@ -820,7 +820,7 @@ func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager {
}
// initAI inits AI manager.
func initAI(db *sqlx.DB, i18n *i18n.I18n, conversationStore *conversation.Manager, helpCenterStore *helpcenter.Manager, userStore *user.Manager) *ai.Manager {
func initAI(db *sqlx.DB, i18n *i18n.I18n, conversationStore *conversation.Manager, helpCenterStore *helpcenter.Manager) *ai.Manager {
lo := initLogger("ai")
embeddingCfg := ai.EmbeddingConfig{
@@ -831,6 +831,12 @@ func initAI(db *sqlx.DB, i18n *i18n.I18n, conversationStore *conversation.Manage
Timeout: ko.Duration("ai.embedding.timeout"),
}
chunkingCfg := ai.ChunkingConfig{
MaxTokens: ko.Int("ai.embedding.chunking.max_tokens"),
MinTokens: ko.Int("ai.embedding.chunking.min_tokens"),
OverlapTokens: ko.Int("ai.embedding.chunking.overlap_tokens"),
}
completionCfg := ai.CompletionConfig{
Provider: ko.String("ai.completion.provider"),
URL: ko.String("ai.completion.url"),
@@ -846,7 +852,7 @@ func initAI(db *sqlx.DB, i18n *i18n.I18n, conversationStore *conversation.Manage
Capacity: ko.Int("ai.worker.capacity"),
}
m, err := ai.New(embeddingCfg, completionCfg, workerCfg, conversationStore, helpCenterStore, userStore, ai.Opts{
m, err := ai.New(embeddingCfg, chunkingCfg, completionCfg, workerCfg, conversationStore, helpCenterStore, ai.Opts{
DB: db,
Lo: lo,
I18n: i18n,

View File

@@ -210,7 +210,7 @@ func main() {
autoassigner = initAutoAssigner(team, user, conversation)
rateLimiter = initRateLimit(rdb)
helpcenter = initHelpCenter(db, i18n)
ai = initAI(db, i18n, conversation, helpcenter, user)
ai = initAI(db, i18n, conversation, helpcenter)
)
wsHub.SetConversationStore(conversation)

108
cmd/snippets.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// snippetReq represents the request payload for snippets creation and updates.
type snippetReq struct {
Content string `json:"content"`
Enabled bool `json:"enabled"`
}
// validateSnippetReq validates the snippet request payload.
func validateSnippetReq(r *fastglue.Request, snippetData *snippetReq) error {
var app = r.Context.(*App)
if snippetData.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
}
return nil
}
// handleGetAISnippets returns all AI snippets from the database.
func handleGetAISnippets(r *fastglue.Request) error {
var app = r.Context.(*App)
snippets, err := app.ai.GetKnowledgeBaseItems()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippets)
}
// handleGetAISnippet returns a single AI snippet by ID.
func handleGetAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
snippet, err := app.ai.GetKnowledgeBaseItem(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleCreateAISnippet creates a new AI snippet in the database.
func handleCreateAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
snippetData snippetReq
)
if err := r.Decode(&snippetData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateSnippetReq(r, &snippetData); err != nil {
return err
}
snippet, err := app.ai.CreateKnowledgeBaseItem("snippet", snippetData.Content, snippetData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleUpdateAISnippet updates an existing AI snippet in the database.
func handleUpdateAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
snippetData snippetReq
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&snippetData, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := validateSnippetReq(r, &snippetData); err != nil {
return err
}
snippet, err := app.ai.UpdateKnowledgeBaseItem(id, "snippet", snippetData.Content, snippetData.Enabled)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(snippet)
}
// handleDeleteAISnippet deletes an AI snippet from the database.
func handleDeleteAISnippet(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.ai.DeleteKnowledgeBaseItem(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -124,6 +124,35 @@ unsnooze_interval = "5m"
evaluation_interval = "5m"
[rate_limit]
[rate_limit.widget]
enabled = true
requests_per_minute = 100
[rate_limit.widget]
enabled = true
requests_per_minute = 100
[ai]
[ai.embedding]
provider = "openai"
url = "https://api.openai.com/v1/embeddings"
api_key = "secret"
model = "text-embedding-3-small"
timeout = "20s"
[ai.embedding.chunking]
# Maximum tokens per chunk (increase for larger context models)
max_tokens = 2000
# Minimum tokens per chunk (smaller chunks may lack context)
min_tokens = 400
# Overlap tokens between chunks for context continuity
overlap_tokens = 150
[ai.completion]
provider = "openai"
url = "https://api.openai.com/v1/chat/completions"
api_key = "secret"
model = "gpt-oss:20b"
temperature = 0.2
max_tokens = 1000
timeout = "30s"
[ai.worker]
workers = 50
capacity = 10000

View File

@@ -47,7 +47,6 @@ const createCustomAttribute = (data) =>
'Content-Type': 'application/json'
}
})
const getCustomAttribute = (id) => http.get(`/api/v1/custom-attributes/${id}`)
const updateCustomAttribute = (id, data) =>
http.put(`/api/v1/custom-attributes/${id}`, data, {
headers: {
@@ -460,17 +459,7 @@ const updateCollection = (helpCenterId, id, data) => http.put(`/api/v1/help-cent
}
})
const deleteCollection = (helpCenterId, id) => http.delete(`/api/v1/help-centers/${helpCenterId}/collections/${id}`)
const moveCollection = (id, data) => http.put(`/api/v1/collections/${id}/move`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const toggleCollection = (id) => http.put(`/api/v1/collections/${id}/toggle`)
const reorderCollections = (helpCenterId, data) => http.put(`/api/v1/help-centers/${helpCenterId}/collections/reorder`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getArticles = (collectionId, params) => http.get(`/api/v1/collections/${collectionId}/articles`, { params })
const getArticle = (id) => http.get(`/api/v1/collections/*/articles/${id}`)
@@ -484,22 +473,17 @@ const updateArticle = (collectionId, id, data) => http.put(`/api/v1/collections/
'Content-Type': 'application/json'
}
})
const updateArticleByID = (id, data) => http.put(`/api/v1/articles/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteArticle = (collectionId, id) => http.delete(`/api/v1/collections/${collectionId}/articles/${id}`)
const moveArticle = (id, data) => http.put(`/api/v1/articles/${id}/move`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateArticleStatus = (id, data) => http.put(`/api/v1/articles/${id}/status`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const reorderArticles = (collectionId, data) => http.put(`/api/v1/collections/${collectionId}/articles/reorder`, data, {
headers: {
'Content-Type': 'application/json'
}
})
// AI Assistants
const getAIAssistants = () => http.get('/api/v1/ai-assistants')
@@ -518,22 +502,22 @@ const updateAIAssistant = (id, data) =>
})
const deleteAIAssistant = (id) => http.delete(`/api/v1/ai-assistants/${id}`)
// AI Custom Answers
const getAICustomAnswers = () => http.get('/api/v1/ai-custom-answers')
const getAICustomAnswer = (id) => http.get(`/api/v1/ai-custom-answers/${id}`)
const createAICustomAnswer = (data) =>
http.post('/api/v1/ai-custom-answers', data, {
// AI Snippets
const getAISnippets = () => http.get('/api/v1/ai-snippets')
const getAISnippet = (id) => http.get(`/api/v1/ai-snippets/${id}`)
const createAISnippet = (data) =>
http.post('/api/v1/ai-snippets', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAICustomAnswer = (id, data) =>
http.put(`/api/v1/ai-custom-answers/${id}`, data, {
const updateAISnippet = (id, data) =>
http.put(`/api/v1/ai-snippets/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteAICustomAnswer = (id) => http.delete(`/api/v1/ai-custom-answers/${id}`)
const deleteAISnippet = (id) => http.delete(`/api/v1/ai-snippets/${id}`)
export default {
@@ -615,12 +599,12 @@ export default {
createAIAssistant,
updateAIAssistant,
deleteAIAssistant,
// AI Custom Answers
getAICustomAnswers,
getAICustomAnswer,
createAICustomAnswer,
updateAICustomAnswer,
deleteAICustomAnswer,
// AI Snippets
getAISnippets,
getAISnippet,
createAISnippet,
updateAISnippet,
deleteAISnippet,
createInbox,
updateInbox,
deleteInbox,
@@ -671,7 +655,6 @@ export default {
createCustomAttribute,
updateCustomAttribute,
deleteCustomAttribute,
getCustomAttribute,
getContactNotes,
createContactNote,
deleteContactNote,
@@ -697,15 +680,12 @@ export default {
createCollection,
updateCollection,
deleteCollection,
moveCollection,
toggleCollection,
reorderCollections,
getArticles,
getArticle,
createArticle,
updateArticle,
updateArticleByID,
deleteArticle,
moveArticle,
updateArticleStatus,
reorderArticles
}

View File

@@ -4,12 +4,12 @@
:editor="editor"
:tippy-options="{ duration: 100 }"
v-if="editor"
class="bg-background p-1 box will-change-transform"
class="bg-background p-2 box will-change-transform max-w-fit"
>
<div class="flex space-x-1 items-center">
<div class="flex gap-1 items-center justify-start whitespace-nowrap">
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center">
<Button size="sm" variant="ghost" class="flex items-center justify-center" title="AI Prompts">
<span class="flex items-center">
<span class="text-medium">AI</span>
<Bot size="14" class="ml-1" />
@@ -27,11 +27,43 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Heading Dropdown for Article Mode -->
<DropdownMenu v-if="editorType === 'article'">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center" title="Heading Options">
<span class="flex items-center">
<Type size="14" />
<span class="ml-1 text-xs font-medium">{{ getCurrentHeadingText() }}</span>
<ChevronDown class="w-3 h-3 ml-1" />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @select="setParagraph" title="Set Paragraph">
<span class="font-normal">Paragraph</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(1)" title="Set Heading 1">
<span class="text-xl font-bold">Heading 1</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(2)" title="Set Heading 2">
<span class="text-lg font-bold">Heading 2</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(3)" title="Set Heading 3">
<span class="text-base font-semibold">Heading 3</span>
</DropdownMenuItem>
<DropdownMenuItem @select="() => setHeading(4)" title="Set Heading 4">
<span class="text-sm font-semibold">Heading 4</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBold().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }"
title="Bold"
>
<Bold size="14" />
</Button>
@@ -40,6 +72,7 @@
variant="ghost"
@click.prevent="editor?.chain().focus().toggleItalic().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }"
title="Italic"
>
<Italic size="14" />
</Button>
@@ -48,6 +81,7 @@
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBulletList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
title="Bullet List"
>
<List size="14" />
</Button>
@@ -57,6 +91,7 @@
variant="ghost"
@click.prevent="editor?.chain().focus().toggleOrderedList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
title="Ordered List"
>
<ListOrdered size="14" />
</Button>
@@ -65,9 +100,32 @@
variant="ghost"
@click.prevent="openLinkModal"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }"
title="Insert Link"
>
<LinkIcon size="14" />
</Button>
<!-- Additional tools for Article Mode -->
<template v-if="editorType === 'article'">
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleCodeBlock().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('codeBlock') }"
title="Code Block"
>
<Code size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBlockquote().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('blockquote') }"
title="Blockquote"
>
<Quote size="14" />
</Button>
</template>
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded">
<Input
v-model="linkUrl"
@@ -75,10 +133,10 @@
placeholder="Enter link URL"
class="border p-1 text-sm w-[200px]"
/>
<Button size="sm" @click="setLink">
<Button size="sm" @click="setLink" title="Set Link">
<Check size="14" />
</Button>
<Button size="sm" @click="unsetLink">
<Button size="sm" @click="unsetLink" title="Unset Link">
<X size="14" />
</Button>
</div>
@@ -100,7 +158,10 @@ import {
ListOrdered,
Link as LinkIcon,
Check,
X
X,
Type,
Code,
Quote
} from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import {
@@ -136,6 +197,11 @@ const props = defineProps({
aiPrompts: {
type: Array,
default: () => []
},
editorType: {
type: String,
default: 'conversation',
validator: (value) => ['conversation', 'article'].includes(value)
}
})
@@ -189,17 +255,39 @@ const CustomTableHeader = TableHeader.extend({
const isInternalUpdate = ref(false)
const editor = useEditor({
extensions: [
StarterKit.configure(),
// Configure extensions based on editor type
const getExtensions = () => {
const baseExtensions = [
StarterKit.configure({
heading: props.editorType === 'article' ? { levels: [1, 2, 3, 4] } : false
}),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link,
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
],
Link
]
// Add table extensions
if (props.editorType === 'article') {
baseExtensions.push(
CustomTable.configure({ resizable: true }),
TableRow,
CustomTableCell,
CustomTableHeader
)
} else {
baseExtensions.push(
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
)
}
return baseExtensions
}
const editor = useEditor({
extensions: getExtensions(),
autofocus: props.autoFocus,
content: htmlContent.value,
editorProps: {
@@ -273,6 +361,32 @@ const unsetLink = () => {
editor.value?.chain().focus().unsetLink().run()
showLinkInput.value = false
}
// Heading functions for article mode
const setHeading = (level) => {
editor.value?.chain().focus().toggleHeading({ level }).run()
}
const setParagraph = () => {
editor.value?.chain().focus().setParagraph().run()
}
const getCurrentHeadingLevel = () => {
if (!editor.value) return null
for (let level = 1; level <= 4; level++) {
if (editor.value.isActive('heading', { level })) {
return level
}
}
return null
}
const getCurrentHeadingText = () => {
const level = getCurrentHeadingLevel()
if (level) return `H${level}`
if (editor.value?.isActive('paragraph')) return 'P'
return 'T'
}
</script>
<style lang="scss">

View File

@@ -289,7 +289,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey">
<SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild>
<router-link :to="child.href">
<span>{{ t(child.titleKey) }}</span>
<span>{{ t(child.titleKey, child.isTitleKeyPlural === true ? 2 : 1) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>

View File

@@ -92,9 +92,9 @@ export const adminNavItems = [
permission: 'ai:manage'
},
{
titleKey: 'globals.terms.customAnswer',
titleKey: 'globals.terms.snippet',
isTitleKeyPlural: true,
href: '/admin/ai/custom-answers',
href: '/admin/ai/snippets',
permission: 'ai:manage'
},
]

View File

@@ -30,19 +30,17 @@
</FormItem>
</FormField>
<!-- Profile Picture URL (Optional) -->
<!-- Avatar url -->
<FormField v-slot="{ componentField }" name="avatar_url">
<FormItem>
<FormLabel>{{ t('ai.assistant.profilePictureUrl') }}</FormLabel>
<FormLabel>{{ t('globals.terms.avatar') }} {{ t('globals.terms.url') }}</FormLabel>
<FormControl>
<Input
type="url"
:placeholder="t('ai.assistant.profilePicturePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormDescription>{{ t('ai.assistant.profilePictureDescription') }}</FormDescription>
<FormMessage />
<FormMessage></FormMessage>
</FormItem>
</FormField>

View File

@@ -1,126 +0,0 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<!-- Question Field -->
<FormField v-slot="{ componentField }" name="question">
<FormItem>
<FormLabel>{{ t('globals.terms.question') }} <span class="text-red-500">*</span></FormLabel>
<FormControl>
<Textarea
:placeholder="t('ai.customAnswer.questionPlaceholder')"
v-bind="componentField"
rows="3"
/>
</FormControl>
<FormDescription>{{ t('ai.customAnswer.questionDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Answer Field -->
<FormField v-slot="{ componentField }" name="answer">
<FormItem>
<FormLabel>{{ t('globals.terms.answer') }} <span class="text-red-500">*</span></FormLabel>
<FormControl>
<Textarea
:placeholder="t('ai.customAnswer.answerPlaceholder')"
v-bind="componentField"
rows="6"
/>
</FormControl>
<FormDescription>{{ t('ai.customAnswer.answerDescription') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Enabled Field -->
<FormField v-slot="{ value, handleChange }" name="enabled" type="checkbox">
<FormItem class="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>{{ t('globals.terms.enabled') }}</FormLabel>
<FormDescription>{{ t('ai.customAnswer.enabledDescription') }}</FormDescription>
</div>
</FormItem>
</FormField>
<!-- Submit Button -->
<div class="flex gap-4">
<Button type="submit" class="min-w-[120px]" :disabled="formLoading">
<Spinner v-if="formLoading" class="mr-2 h-4 w-4" />
{{ t('globals.buttons.save') }}
</Button>
<Button type="button" variant="outline" @click="$emit('cancel')">
{{ t('globals.buttons.cancel') }}
</Button>
</div>
</form>
</template>
<script setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { createFormSchema } from './formSchema.js'
const { t } = useI18n()
const props = defineProps({
customAnswer: {
type: Object,
default: null
},
formLoading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit', 'cancel'])
const formSchema = toTypedSchema(createFormSchema(t))
const { handleSubmit, setValues } = useForm({
validationSchema: formSchema,
initialValues: {
question: '',
answer: '',
enabled: true
}
})
const onSubmit = handleSubmit((values) => {
emit('submit', values)
})
// Watch for changes in customAnswer prop and update form values
watch(
() => props.customAnswer,
(newCustomAnswer) => {
if (newCustomAnswer) {
setValues({
question: newCustomAnswer.question || '',
answer: newCustomAnswer.answer || '',
enabled: newCustomAnswer.enabled !== undefined ? newCustomAnswer.enabled : true
})
}
},
{ immediate: true }
)
</script>

View File

@@ -1,39 +0,0 @@
import * as z from 'zod'
export const createFormSchema = (t) => z.object({
question: z
.string({
required_error: t('globals.messages.required'),
})
.min(10, {
message: t('form.error.minmax', {
min: 10,
max: 500,
})
})
.max(500, {
message: t('form.error.minmax', {
min: 10,
max: 500,
})
}),
answer: z
.string({
required_error: t('globals.messages.required'),
})
.min(10, {
message: t('form.error.minmax', {
min: 10,
max: 2000,
})
})
.max(2000, {
message: t('form.error.minmax', {
min: 10,
max: 2000,
})
}),
enabled: z.boolean().optional().default(true),
})

View File

@@ -0,0 +1,376 @@
<template>
<Sheet :open="isOpen" @update:open="$emit('update:open', $event)">
<SheetContent class="!max-w-[80vw] sm:!max-w-[80vw] h-full p-0 flex flex-col">
<div class="flex-1 flex flex-col min-h-0">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b bg-card/50">
<div>
<h2 class="text-lg font-semibold">
{{ article ? 'Edit Article' : 'Create Article' }}
</h2>
<p class="text-sm text-muted-foreground mt-1">
{{ article ? `Last updated ${formatDatetime(new Date(article.updated_at))}` : 'Create a new help article' }}
</p>
</div>
</div>
<!-- Content -->
<div class="flex-1 flex min-h-0">
<!-- Main Content Area (75%) -->
<div class="flex-1 flex flex-col p-6 space-y-6 overflow-y-auto">
<Spinner v-if="formLoading" />
<form v-else @submit="onSubmit" class="space-y-6 flex-1 flex flex-col">
<!-- Title -->
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Enter article title..."
v-bind="componentField"
class="text-xl font-semibold border-0 px-0 py-3 shadow-none focus-visible:ring-0 placeholder:text-muted-foreground/60"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Content Editor -->
<FormField v-slot="{ componentField }" name="content">
<FormItem class="flex-1 flex flex-col">
<FormControl class="flex-1">
<div class="flex-1 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="t('editor.newLine')"
editorType="article"
class="min-h-[400px] border-0 px-0 shadow-none focus-visible:ring-0"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button (Hidden - controlled by sidebar) -->
<button type="submit" class="hidden" ref="submitButton"></button>
</form>
</div>
<!-- Sidebar (25%) -->
<div class="w-80 border-l bg-muted/20 p-6 overflow-y-auto">
<div class="space-y-6">
<!-- Publish Actions -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Actions
</h3>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="$emit('cancel')"
class="flex-1"
>
Cancel
</Button>
<Button
type="button"
size="sm"
@click="handleSubmit"
:disabled="isLoading"
class="flex-1"
>
<Loader2Icon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ submitLabel }}
</Button>
</div>
</div>
<!-- Status -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Status
</h3>
<FormField v-slot="{ componentField }" name="status">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Only published articles are visible to users
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Collection -->
<div v-if="availableCollections.length > 0" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Collection
</h3>
<FormField v-slot="{ componentField }" name="collection_id">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select collection" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="collection in availableCollections"
:key="collection.id"
:value="collection.id"
>
{{ collection.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Move this article to a different collection
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- AI Settings -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
AI Settings
</h3>
<FormField v-slot="{ componentField }" name="ai_enabled">
<FormItem class="flex flex-row items-start space-x-3 space-y-0 border rounded-lg p-3">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="componentField.onChange"
/>
</FormControl>
<div class="space-y-1 leading-none flex-1">
<FormLabel class="text-sm font-medium">
Allow AI assistants to use this article
</FormLabel>
<FormDescription class="text-xs">
Article must be published for this to take effect
</FormDescription>
</div>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Metadata -->
<div v-if="article" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Metadata
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Created</span>
<span>{{ formatDatetime(new Date(article.created_at)) }}</span>
</div>
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Updated</span>
<span>{{ formatDatetime(new Date(article.updated_at)) }}</span>
</div>
<div v-if="article.view_count !== undefined" class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Views</span>
<span>{{ article.view_count.toLocaleString() }}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-muted-foreground">ID</span>
<span class="font-mono text-xs">#{{ article.id }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
Sheet,
SheetContent,
} from '@shared-ui/components/ui/sheet'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { Loader2 as Loader2Icon } from 'lucide-vue-next'
import { createArticleFormSchema } from './articleFormSchema.js'
import { useI18n } from 'vue-i18n'
import { getTextFromHTML } from '../../../utils/strings.js'
import Editor from '@main/components/editor/TextEditor.vue'
import api from '../../../api'
import { handleHTTPError } from '../../../utils/http'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { formatDatetime } from '@shared-ui/utils/datetime.js'
const { t } = useI18n()
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
},
collectionId: {
type: Number,
default: null
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
},
locale: {
type: String,
default: 'en'
}
})
defineEmits(['update:open', 'cancel'])
const emitter = useEmitter()
const formLoading = ref(false)
const availableCollections = ref([])
const submitButton = ref(null)
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.article ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createArticleFormSchema(t)),
initialValues: {
title: props.article?.title || '',
content: props.article?.content || '',
status: props.article?.status || 'draft',
collection_id: props.article?.collection_id || props.collectionId || null,
sort_order: props.article?.sort_order || 0,
ai_enabled: props.article?.ai_enabled || false
}
})
onMounted(async () => {
await fetchAvailableCollections()
})
watch(
() => [props.article, props.collectionId, props.locale],
async (newValues) => {
const [newArticle, newCollectionId] = newValues
// Re-fetch available collections when article, collectionId, or locale changes
await fetchAvailableCollections()
if (newArticle && Object.keys(newArticle).length > 0) {
form.setValues({
title: newArticle.title || '',
content: newArticle.content || '',
status: newArticle.status || 'draft',
collection_id: newArticle.collection_id || newCollectionId || null,
sort_order: newArticle.sort_order || 0,
ai_enabled: newArticle.ai_enabled || false
})
}
},
{ immediate: true }
)
const fetchAvailableCollections = async () => {
try {
let helpCenterId = null
if (props.article?.collection_id) {
// Editing existing article - get its collection first to find help center
const { data: collection } = await api.getCollection(props.article.collection_id)
helpCenterId = collection.data.help_center_id
} else if (props.collectionId) {
// Creating new article - get help center from provided collection
const { data: collection } = await api.getCollection(props.collectionId)
helpCenterId = collection.data.help_center_id
}
if (helpCenterId) {
// Filter collections by current locale
const { data: collections } = await api.getCollections(helpCenterId, { locale: props.locale })
// Allow selecting all published collections for the current locale
availableCollections.value = collections.data.filter((c) => c.is_published)
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const onSubmit = form.handleSubmit(async (values) => {
const textContent = getTextFromHTML(values.content)
if (textContent.length === 0) {
values.content = ''
}
props.submitForm(values)
})
const handleSubmit = () => {
if (submitButton.value) {
submitButton.value.click()
}
}
</script>

View File

@@ -0,0 +1,352 @@
<template>
<Sheet :open="isOpen" @update:open="$emit('update:open', $event)">
<SheetContent class="!max-w-[60vw] sm:!max-w-[60vw] h-full p-0 flex flex-col">
<div class="flex-1 flex flex-col min-h-0">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b bg-card/50">
<div>
<h2 class="text-lg font-semibold">
{{ collection ? 'Edit Collection' : 'Create Collection' }}
</h2>
<p class="text-sm text-muted-foreground mt-1">
{{ collection ? `Last updated ${formatDatetime(new Date(collection.updated_at))}` : 'Create a new help collection' }}
</p>
</div>
</div>
<!-- Content -->
<div class="flex-1 flex min-h-0">
<!-- Main Content Area (70%) -->
<div class="flex-1 flex flex-col p-6 space-y-6 overflow-y-auto">
<Spinner v-if="formLoading" />
<form v-else @submit="onSubmit" class="space-y-6 flex-1 flex flex-col">
<!-- Name -->
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Enter collection name..."
v-bind="componentField"
class="text-xl font-semibold border-0 px-0 py-3 shadow-none focus-visible:ring-0 placeholder:text-muted-foreground/60"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem class="flex-1">
<FormControl>
<Textarea
placeholder="Describe what this collection contains..."
rows="6"
v-bind="componentField"
class="border-0 px-0 py-2 shadow-none focus-visible:ring-0 resize-none placeholder:text-muted-foreground/60"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit Button (Hidden - controlled by sidebar) -->
<button type="submit" class="hidden" ref="submitButton"></button>
</form>
</div>
<!-- Sidebar (30%) -->
<div class="w-72 border-l bg-muted/20 p-6 overflow-y-auto">
<div class="space-y-6">
<!-- Publish Actions -->
<div class="space-y-4">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Actions
</h3>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="$emit('cancel')"
class="flex-1"
>
Cancel
</Button>
<Button
type="button"
size="sm"
@click="handleSubmit"
:disabled="isLoading"
class="flex-1"
>
<Loader2Icon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ submitLabel }}
</Button>
</div>
</div>
<!-- Visibility -->
<div class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Visibility
</h3>
<FormField v-slot="{ componentField }" name="is_published">
<FormItem class="flex flex-row items-start space-x-3 space-y-0 border rounded-lg p-3">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="componentField.onChange"
/>
</FormControl>
<div class="space-y-1 leading-none flex-1">
<FormLabel class="text-sm font-medium">
Published
</FormLabel>
<FormDescription class="text-xs">
Published collections are visible to users
</FormDescription>
</div>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Parent Collection -->
<div v-if="availableParents.length > 0" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Parent Collection
</h3>
<FormField v-slot="{ componentField }" name="parent_id">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select parent (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">No parent (root level)</SelectItem>
<SelectItem v-for="parent in availableParents" :key="parent.id" :value="parent.id">
{{ parent.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">
Collections can be nested up to 3 levels deep
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Articles Count -->
<div v-if="collection && collection.articles" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Articles
</h3>
<div class="border rounded-lg p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Total Articles</span>
<Badge variant="outline">{{ collection.articles.length }}</Badge>
</div>
<p class="text-xs text-muted-foreground mt-2">
{{ collection.articles.filter(a => a.status === 'published').length }} published,
{{ collection.articles.filter(a => a.status === 'draft').length }} draft
</p>
</div>
</div>
<!-- Metadata -->
<div v-if="collection" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground uppercase tracking-wider">
Metadata
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Created</span>
<span>{{ formatDatetime(new Date(collection.created_at)) }}</span>
</div>
<div class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Updated</span>
<span>{{ formatDatetime(new Date(collection.updated_at)) }}</span>
</div>
<div v-if="collection.view_count !== undefined" class="flex justify-between py-2 border-b border-border/50">
<span class="text-muted-foreground">Views</span>
<span>{{ collection.view_count.toLocaleString() }}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-muted-foreground">ID</span>
<span class="font-mono text-xs">#{{ collection.id }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { Badge } from '@shared-ui/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
Sheet,
SheetContent,
} from '@shared-ui/components/ui/sheet'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { Loader2 as Loader2Icon } from 'lucide-vue-next'
import { createCollectionFormSchema } from './collectionFormSchema.js'
import { useI18n } from 'vue-i18n'
import api from '../../../api'
import { handleHTTPError } from '../../../utils/http'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { formatDatetime } from '@shared-ui/utils/datetime.js'
const { t } = useI18n()
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
collection: {
type: Object,
default: null
},
helpCenterId: {
type: Number,
required: true
},
parentId: {
type: Number,
default: null
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
},
locale: {
type: String,
default: 'en'
}
})
defineEmits(['update:open', 'cancel'])
const emitter = useEmitter()
const formLoading = ref(false)
const availableParents = ref([])
const submitButton = ref(null)
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.collection ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createCollectionFormSchema(t)),
initialValues: {
name: props.collection?.name || '',
description: props.collection?.description || '',
parent_id: props.collection?.parent_id || props.parentId || null,
is_published: props.collection?.is_published ?? true,
sort_order: props.collection?.sort_order || 0
}
})
onMounted(async () => {
await fetchAvailableParents()
})
watch(
() => props.collection,
(newValues) => {
if (newValues && Object.keys(newValues).length > 0) {
form.setValues({
name: newValues.name || '',
description: newValues.description || '',
parent_id: newValues.parent_id || null,
is_published: newValues.is_published ?? true,
sort_order: newValues.sort_order || 0
})
}
},
{ immediate: true }
)
watch(
() => props.locale,
async () => {
await fetchAvailableParents()
}
)
const fetchAvailableParents = async () => {
try {
// Filter collections by current locale
const { data } = await api.getCollections(props.helpCenterId, { locale: props.locale })
availableParents.value = data.data.filter((collection) => {
// Exclude self and children from parent options
if (props.collection && collection.id === props.collection.id) return false
if (props.collection && collection.parent_id === props.collection.id) return false
return true
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const onSubmit = form.handleSubmit(async (values) => {
props.submitForm(values)
})
const handleSubmit = () => {
if (submitButton.value) {
submitButton.value.click()
}
}
</script>

View File

@@ -0,0 +1,35 @@
<template>
<MenuCard @click="handleClick">
<template #title>
<BookOpen size="24" class="mr-2 text-primary" />
{{ helpCenter.name }}
</template>
<template #subtitle>
<p class="text-sm mb-3">{{ helpCenter.page_title }}</p>
</template>
<div class="mt-3 pt-3 border-t">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ helpCenter.view_count || 0 }} views</span>
</div>
</div>
</MenuCard>
</template>
<script setup>
import { defineEmits } from 'vue'
import MenuCard from '@shared-ui/components/ui/menu-card/MenuCard.vue'
import { BookOpen } from 'lucide-vue-next'
const props = defineProps({
helpCenter: {
type: Object,
required: true
}
})
const emit = defineEmits(['edit', 'delete', 'click'])
const handleClick = () => {
emit('click', props.helpCenter)
}
</script>

View File

@@ -0,0 +1,173 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ t('globals.terms.name') }} *</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter help center name"
v-bind="componentField"
@input="generateSlug"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="slug">
<FormItem>
<FormLabel>Slug *</FormLabel>
<FormControl>
<Input type="text" placeholder="help-center-slug" v-bind="componentField" />
</FormControl>
<FormDescription>
This will be used in the URL: /help/{{ form.values.slug || 'your-slug' }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="page_title">
<FormItem>
<FormLabel>Page Title *</FormLabel>
<FormControl>
<Input type="text" placeholder="Enter page title" v-bind="componentField" />
</FormControl>
<FormDescription> This will appear in the browser tab and search results </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="default_locale">
<FormItem>
<FormLabel>Default Language *</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select default language" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="language in LANGUAGES" :key="language.code" :value="language.code">
{{ language.nativeName }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
This will be the default language for new articles and collections
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" @click="$emit('cancel')"> Cancel </Button>
<Button type="submit" :isLoading="isLoading">
{{ submitLabel }}
</Button>
</div>
</form>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { LANGUAGES } from '@shared-ui/constants'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Spinner } from '@shared-ui/components/ui/spinner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@shared-ui/components/ui/form/index.js'
import { createHelpCenterFormSchema } from './helpCenterFormSchema.js'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
helpCenter: {
type: Object,
default: null
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
}
})
defineEmits(['cancel'])
const formLoading = ref(false)
const submitLabel = computed(() => {
return (
props.submitLabel ||
(props.helpCenter ? t('globals.messages.update') : t('globals.messages.create'))
)
})
const form = useForm({
validationSchema: toTypedSchema(createHelpCenterFormSchema(t)),
initialValues: {
name: props.helpCenter?.name || '',
slug: props.helpCenter?.slug || '',
page_title: props.helpCenter?.page_title || '',
default_locale: props.helpCenter?.default_locale || 'en'
}
})
const generateSlug = () => {
if (!props.helpCenter && form.values.name) {
form.setFieldValue(
'slug',
form.values.name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
)
}
}
const onSubmit = form.handleSubmit(async (values) => {
props.submitForm(values)
})
watch(
() => props.helpCenter,
(newValues) => {
if (newValues && Object.keys(newValues).length > 0) {
form.setValues({
name: newValues.name || '',
slug: newValues.slug || '',
page_title: newValues.page_title || '',
default_locale: newValues.default_locale || 'en'
})
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,112 @@
<template>
<DropdownMenu :modal="false">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
class="h-6 w-6 p-0"
@click.stop
>
<MoreHorizontalIcon class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuItem @click="handleEdit">
<PencilIcon class="mr-2 h-4 w-4" />
Edit {{ item.type === 'collection' ? 'Collection' : 'Article' }}
</DropdownMenuItem>
<template v-if="item.type === 'collection'">
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleCreateCollection">
<FolderPlusIcon class="mr-2 h-4 w-4" />
Add Collection
</DropdownMenuItem>
<DropdownMenuItem @click="handleCreateArticle">
<DocumentPlusIcon class="mr-2 h-4 w-4" />
Add Article
</DropdownMenuItem>
</template>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleToggleStatus">
<template v-if="item.type === 'collection'">
<EyeIcon v-if="!item.is_published" class="mr-2 h-4 w-4" />
<EyeSlashIcon v-else class="mr-2 h-4 w-4" />
{{ item.is_published ? 'Unpublish' : 'Publish' }}
</template>
<template v-else>
<EyeIcon v-if="item.status === 'draft'" class="mr-2 h-4 w-4" />
<EyeSlashIcon v-else class="mr-2 h-4 w-4" />
{{ item.status === 'published' ? 'Unpublish' : 'Publish' }}
</template>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@click="handleDelete"
class="text-destructive focus:text-destructive"
>
<TrashIcon class="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@shared-ui/components/ui/dropdown-menu'
import {
FilePlus as DocumentPlusIcon,
Eye as EyeIcon,
EyeOff as EyeSlashIcon,
FolderPlus as FolderPlusIcon,
MoreHorizontal as MoreHorizontalIcon,
Pencil as PencilIcon,
Trash as TrashIcon,
} from 'lucide-vue-next'
const props = defineProps({
item: {
type: Object,
required: true
}
})
const emit = defineEmits([
'create-collection',
'create-article',
'edit',
'delete',
'toggle-status'
])
const handleEdit = () => {
emit('edit', props.item)
}
const handleCreateCollection = () => {
emit('create-collection', props.item.id)
}
const handleCreateArticle = () => {
emit('create-article', props.item)
}
const handleDelete = () => {
emit('delete', props.item)
}
const handleToggleStatus = () => {
emit('toggle-status', props.item)
}
</script>

View File

@@ -0,0 +1,287 @@
<template>
<div>
<!-- Collection Node -->
<Collapsible v-if="item.type === 'collection'" v-model:open="isOpen">
<div
class="group tree-node"
:class="{
'tree-node--selected': isSelected,
'hover:shadow-sm': !isSelected
}"
@click="selectItem"
>
<div class="flex items-center gap-3">
<CollapsibleTrigger as-child @click.stop>
<ChevronRightIcon
class="h-4 w-4 transition-transform text-muted-foreground hover:text-foreground flex-shrink-0"
:class="{ 'rotate-90': isOpen }"
/>
</CollapsibleTrigger>
<div class="icon-container-folder">
<FolderIcon class="h-4.5 w-4.5 text-blue-600" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-sm font-semibold truncate text-foreground">
{{ item.name }}
</h4>
<span
v-if="!item.is_published"
class="text-[10px] font-medium bg-yellow-100 text-yellow-800 px-1.5 py-0.5 rounded"
>
Draft
</span>
</div>
<p v-if="item.description" class="text-xs text-muted-foreground leading-tight line-clamp-2 max-w-xs">
{{ item.description }}
</p>
</div>
<div class="hover-actions ml-2">
<Badge
v-if="item.articles && item.articles.length > 0"
variant="outline"
class="text-xs px-2 py-0.5 font-normal bg-card/50 text-muted-foreground"
>
{{ item.articles.length }} {{ item.articles.length === 1 ? 'article' : 'articles' }}
</Badge>
<TreeDropdown
:item="item"
@create-collection="$emit('create-collection', item.id)"
@create-article="$emit('create-article', item)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</div>
<!-- Child Collections and Articles -->
<CollapsibleContent>
<div class="ml-10 mt-2 pl-2 border-l border-border/20">
<!-- Empty no child content -->
<div
v-if="!childCollections.length && !articles.length"
class="text-sm text-muted-foreground bg-muted/10 rounded-md py-3 px-4 text-center italic"
>
<FolderOpenIcon class="h-4 w-4 mx-auto mb-1.5 opacity-60" />
{{ $t('globals.messages.empty') }}
</div>
<!-- Articles -->
<div class="space-y-1.5">
<div
v-for="element in articles"
:key="element.id"
class="group tree-node--article"
:class="{
'tree-node--selected':
selectedItem?.id === element.id && selectedItem?.type === 'article'
}"
@click="selectArticle(element)"
>
<div class="flex items-center gap-2">
<div class="icon-container-article">
<DocumentTextIcon class="h-4 w-4 text-green-600" />
</div>
<div class="flex-1 min-w-0">
<h5 class="text-sm font-medium truncate text-foreground">
{{ element.title }}
</h5>
<p
v-if="element.description"
class="text-xs text-muted-foreground truncate mt-0.5"
>
{{ element.description }}
</p>
</div>
<div class="hover-actions--compact">
<Badge
:variant="getArticleStatusVariant(element.status)"
class="text-[11px] px-1.5 py-0.5 font-normal"
v-if="element.status"
>
{{ element.status.charAt(0).toUpperCase() + element.status.slice(1) }}
</Badge>
<TreeDropdown
:item="{ ...element, type: 'article' }"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</div>
</div>
<!-- Child Collections -->
<div class="space-y-1.5">
<TreeNode
v-for="element in childCollections"
:key="element.id"
:item="{ ...element, type: 'collection' }"
:selected-item="selectedItem"
:level="level + 1"
@select="$emit('select', $event)"
@create-collection="$emit('create-collection', $event)"
@create-article="$emit('create-article', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<!-- Article Node (when at root level) -->
<div
v-else
class="group tree-node--article"
:class="{
'tree-node--selected': isSelected,
'hover:shadow-xs': !isSelected
}"
@click="selectItem"
>
<div class="flex items-center gap-2">
<div class="icon-container-article">
<DocumentTextIcon class="h-4 w-4 text-green-600" />
</div>
<div class="flex-1 min-w-0">
<h5 class="text-sm font-medium truncate text-foreground">
{{ item.title }}
</h5>
<p v-if="item.description" class="text-xs text-muted-foreground truncate mt-0.5">
{{ item.description }}
</p>
</div>
<div class="hover-actions--compact">
<Badge
:variant="getArticleStatusVariant(item.status)"
class="text-[11px] px-1.5 py-0.5 font-normal"
>
{{ item.status }}
</Badge>
<TreeDropdown
:item="item"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Badge } from '@shared-ui/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@shared-ui/components/ui/collapsible'
import {
ChevronRight as ChevronRightIcon,
FileText as DocumentTextIcon,
Folder as FolderIcon,
FolderOpen as FolderOpenIcon
} from 'lucide-vue-next'
import TreeDropdown from './TreeDropdown.vue'
const props = defineProps({
item: {
type: Object,
required: true
},
selectedItem: {
type: Object,
default: null
},
level: {
type: Number,
default: 0
}
})
const emit = defineEmits([
'select',
'create-collection',
'create-article',
'edit',
'delete',
'toggle-status'
])
const isOpen = ref(true)
const isSelected = computed(() => {
if (!props.selectedItem) return false
return props.selectedItem.id === props.item.id && props.selectedItem.type === props.item.type
})
const childCollections = computed(() => props.item.children || [])
const articles = computed(() => props.item.articles || [])
const selectItem = () => {
emit('select', props.item)
}
const selectArticle = (article) => {
emit('select', { ...article, type: 'article' })
}
const getArticleStatusVariant = (status) => {
switch (status) {
case 'published':
return 'default'
case 'draft':
return 'secondary'
default:
return 'secondary'
}
}
</script>
<style scoped>
.tree-node {
@apply border border-transparent hover:border-border hover:bg-muted/20 rounded-lg p-3 transition-all duration-200 cursor-pointer;
}
.tree-node--article {
@apply border border-transparent hover:border-border hover:bg-muted/20 rounded-md p-2.5 transition-all duration-200 cursor-pointer;
}
.tree-node--selected {
@apply bg-accent/10 border-border shadow-sm ring-1 ring-accent/20;
}
.icon-container-folder {
@apply flex items-center justify-center w-9 h-9 rounded-lg bg-blue-50 border border-blue-100/70;
}
.icon-container-article {
@apply flex items-center justify-center w-7 h-7 rounded-md bg-green-50 border border-green-100/70;
}
.hover-actions {
@apply flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-150;
}
.hover-actions--compact {
@apply flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="space-y-3">
<TreeNode
v-for="element in collections"
:key="element.id"
:item="element"
:selected-item="selectedItem"
:level="0"
@select="$emit('select', $event)"
@create-collection="$emit('create-collection', $event)"
@create-article="$emit('create-article', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@toggle-status="$emit('toggle-status', $event)"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import TreeNode from './TreeNode.vue'
const props = defineProps({
data: {
type: Array,
required: true
},
selectedItem: {
type: Object,
default: null
}
})
defineEmits([
'select',
'create-collection',
'create-article',
'edit',
'delete',
'toggle-status'
])
const collections = computed(() => props.data.map((item) => ({ ...item, type: 'collection' })))
</script>

View File

@@ -0,0 +1,10 @@
import * as z from 'zod'
export const createArticleFormSchema = (t) => z.object({
title: z.string().min(1, t('globals.messages.required')),
content: z.string().min(1, t('globals.messages.required')),
status: z.enum(['draft', 'published']).default('draft'),
collection_id: z.number().min(1, t('globals.messages.required')),
sort_order: z.number().default(0),
ai_enabled: z.boolean().default(false),
})

View File

@@ -0,0 +1,9 @@
import * as z from 'zod'
export const createCollectionFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
description: z.string().optional(),
parent_id: z.number().nullable().optional(),
is_published: z.boolean().default(true),
sort_order: z.number().default(0),
})

View File

@@ -0,0 +1,11 @@
import * as z from 'zod'
export const createHelpCenterFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
slug: z
.string()
.min(1, t('globals.messages.required'))
.regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'),
page_title: z.string().min(1, t('globals.messages.required')),
default_locale: z.string().min(1, t('globals.messages.required')),
})

View File

@@ -12,6 +12,31 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="help_center_id">
<FormItem>
<FormLabel>{{ $t('admin.inbox.helpCenter') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="t('admin.inbox.helpCenter.placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">{{ $t('globals.terms.none') }}</SelectItem>
<SelectItem
v-for="helpCenter in helpCenters"
:key="helpCenter.id"
:value="helpCenter.id.toString()"
>
{{ helpCenter.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{ $t('admin.inbox.helpCenter.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="from">
<FormItem>
<FormLabel>{{ $t('globals.terms.fromEmailAddress') }}</FormLabel>
@@ -85,11 +110,7 @@
<FormItem>
<FormLabel>{{ $t('admin.inbox.mailbox') }}</FormLabel>
<FormControl>
<Input
type="text"
placeholder="INBOX"
v-bind="componentField"
/>
<Input type="text" placeholder="INBOX" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.mailbox.description') }}
@@ -349,10 +370,11 @@
</template>
<script setup>
import { watch, computed } from 'vue'
import { watch, computed, ref, onMounted } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
import api from '@main/api'
import {
FormControl,
FormField,
@@ -393,10 +415,13 @@ const props = defineProps({
})
const { t } = useI18n()
const helpCenters = ref([])
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: {
name: '',
help_center_id: 0,
from: '',
enabled: true,
csat_enabled: false,
@@ -446,4 +471,13 @@ watch(
},
{ deep: true, immediate: true }
)
onMounted(async () => {
try {
const { data } = await api.getHelpCenters()
helpCenters.value = data.data
} catch (error) {
console.error('Error fetching help centers:', error)
}
})
</script>

View File

@@ -28,6 +28,31 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="help_center_id">
<FormItem>
<FormLabel>{{ $t('admin.inbox.helpCenter') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="t('admin.inbox.helpCenter.placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">{{ $t('globals.terms.none') }}</SelectItem>
<SelectItem
v-for="helpCenter in helpCenters"
:key="helpCenter.id"
:value="helpCenter.id"
>
{{ helpCenter.name }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{ $t('admin.inbox.helpCenter.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
@@ -57,7 +82,9 @@
<FormField v-slot="{ componentField }" name="config.brand_name">
<FormItem>
<FormLabel>{{ $t('globals.terms.brand') + ' ' + $t('globals.terms.name').toLowerCase() }}</FormLabel>
<FormLabel>{{
$t('globals.terms.brand') + ' ' + $t('globals.terms.name').toLowerCase()
}}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
@@ -249,9 +276,6 @@
rows="2"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.chatIntroduction.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -344,9 +368,6 @@
rows="2"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.noticeBanner.text.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -472,7 +493,7 @@
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.secretKey') }}</FormLabel>
<FormControl>
<Input type="password" v-bind="componentField"/>
<Input type="password" v-bind="componentField" />
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.secretKey.description')
@@ -583,6 +604,54 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="config.visitors.require_contact_info">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.requireContactInfo') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">
{{ $t('admin.inbox.livechat.requireContactInfo.disabled') }}
</SelectItem>
<SelectItem value="optional">
{{ $t('admin.inbox.livechat.requireContactInfo.optional') }}
</SelectItem>
<SelectItem value="required">
{{ $t('admin.inbox.livechat.requireContactInfo.required') }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.requireContactInfo.visitors.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-if="form.values.config?.visitors?.require_contact_info !== 'disabled'"
v-slot="{ componentField }"
name="config.visitors.contact_info_message"
>
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.contactInfoMessage') }}</FormLabel>
<FormControl>
<Textarea
v-bind="componentField"
placeholder="Please provide your contact information so we can assist you better."
rows="2"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.contactInfoMessage.visitors.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Users Settings -->
@@ -653,10 +722,11 @@
</template>
<script setup>
import { watch, computed, ref } from 'vue'
import { watch, computed, ref, onMounted } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './livechatFormSchema.js'
import api from '@main/api'
import {
FormControl,
FormField,
@@ -680,7 +750,6 @@ import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { Plus, X } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import PreChatFormConfig from './PreChatFormConfig.vue'
import api from '@/api'
const props = defineProps({
initialValues: {
@@ -706,11 +775,13 @@ const activeTab = ref('general')
const selectedUserTab = ref('visitors')
const externalLinks = ref([])
const customAttributes = ref([])
const helpCenters = ref([])
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: {
name: '',
help_center_id: 0,
enabled: true,
secret: '',
csat_enabled: false,
@@ -823,6 +894,10 @@ const fetchCustomAttributes = async () => {
}
const onSubmit = form.handleSubmit(async (values) => {
if (values.help_center_id === 0 || values.help_center_id === '') {
values.help_center_id = null
}
// Transform trusted_domains from textarea to array
if (values.config.trusted_domains) {
values.config.trusted_domains = values.config.trusted_domains
@@ -863,4 +938,14 @@ watch(
},
{ deep: true, immediate: true }
)
// Fetch help centers on component mount
onMounted(async () => {
try {
const { data } = await api.getHelpCenters()
helpCenters.value = data.data
} catch (error) {
console.error('Error fetching help centers:', error)
}
})
</script>

View File

@@ -3,6 +3,7 @@ import { isGoDuration } from '../../../utils/strings'
export const createFormSchema = (t) => z.object({
name: z.string().min(1, t('globals.messages.required')),
help_center_id: z.number().optional(),
from: z.string().min(1, t('globals.messages.required')),
enabled: z.boolean().optional(),
csat_enabled: z.boolean().optional(),

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
export const createFormSchema = (t) => z.object({
name: z.string().min(1, { message: t('globals.messages.required') }),
help_center_id: z.number().optional(),
enabled: z.boolean(),
csat_enabled: z.boolean(),
secret: z.string(),

View File

@@ -0,0 +1,108 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<!-- Type Field (Hidden, fixed to 'snippet') -->
<FormField v-slot="{ componentField }" name="type">
<input type="hidden" v-bind="componentField" />
</FormField>
<!-- Content Field -->
<FormField v-slot="{ componentField }" name="content">
<FormItem>
<FormLabel>{{ t('globals.terms.content') }} <span class="text-red-500">*</span></FormLabel>
<FormControl>
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
editorType="article"
class="border rounded-md p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Enabled Field -->
<FormField v-slot="{ value, handleChange }" name="enabled" type="checkbox">
<FormItem class="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>{{ t('globals.terms.enabled') }}</FormLabel>
<FormDescription>{{ t('ai.snippet.enabledDescription') }}</FormDescription>
</div>
</FormItem>
</FormField>
<!-- Submit Button -->
<Button type="submit" :disabled="formLoading">
<Spinner v-if="formLoading" />
{{ t('globals.messages.save') }}
</Button>
</form>
</template>
<script setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import { Spinner } from '@shared-ui/components/ui/spinner'
import Editor from '@main/components/editor/TextEditor.vue'
import { createFormSchema } from './formSchema.js'
const { t } = useI18n()
const props = defineProps({
snippet: {
type: Object,
default: null
},
formLoading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit'])
const formSchema = toTypedSchema(createFormSchema(t))
const { handleSubmit, setValues } = useForm({
validationSchema: formSchema,
initialValues: {
type: 'snippet',
content: '',
enabled: true
}
})
const onSubmit = handleSubmit((values) => {
emit('submit', values)
})
// Watch for changes in snippet prop and update form values
watch(
() => props.snippet,
(newSnippet) => {
if (newSnippet) {
setValues({
type: newSnippet.type || 'snippet',
content: newSnippet.content || '',
enabled: newSnippet.enabled !== undefined ? newSnippet.enabled : true
})
}
},
{ immediate: true }
)
</script>

View File

@@ -1,33 +1,20 @@
import { h } from 'vue'
import CustomAnswerDataTableDropDown from '@/features/admin/custom-answers/dataTableDropdown.vue'
import SnippetDataTableDropDown from '@/features/admin/snippets/dataTableDropdown.vue'
import { format } from 'date-fns'
import { getTextFromHTML } from '@/utils/strings.js'
export const createColumns = (t) => [
{
accessorKey: 'question',
accessorKey: 'content',
header: function () {
return h('div', { class: 'text-left' }, t('globals.terms.question'))
return h('div', { class: 'text-center' }, t('globals.terms.content'))
},
cell: function ({ row }) {
const question = row.getValue('question')
const truncated = question.length > 80 ? question.substring(0, 80) + '...' : question
return h('div', {
class: 'text-left font-medium max-w-xs',
title: question // Show full text on hover
}, truncated)
}
},
{
accessorKey: 'answer',
header: function () {
return h('div', { class: 'text-left' }, t('globals.terms.answer'))
},
cell: function ({ row }) {
const answer = row.getValue('answer')
const truncated = answer.length > 100 ? answer.substring(0, 100) + '...' : answer
return h('div', {
class: 'text-left font-medium max-w-sm',
title: answer // Show full text on hover
const content = getTextFromHTML(row.getValue('content'))
const truncated = content.length > 30 ? content.substring(0, 30) + '...' : content
return h('div', {
class: 'font-medium text-center',
title: content
}, truncated)
}
},
@@ -70,12 +57,12 @@ export const createColumns = (t) => [
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const customAnswer = row.original
const snippet = row.original
return h(
'div',
{ class: 'relative' },
h(CustomAnswerDataTableDropDown, {
customAnswer
h(SnippetDataTableDropDown, {
snippet
})
)
}

View File

@@ -7,7 +7,7 @@
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editCustomAnswer(props.customAnswer.id)">{{
<DropdownMenuItem @click="editSnippet(props.snippet.id)">{{
$t('globals.messages.edit')
}}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{
@@ -20,7 +20,7 @@
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{ $t('ai.customAnswer.deleteConfirmation') }}</AlertDialogDescription>
<AlertDialogDescription>{{ $t('ai.snippet.deleteConfirmation') }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
@@ -63,7 +63,7 @@ const emit = useEmitter()
const router = useRouter()
const props = defineProps({
customAnswer: {
snippet: {
type: Object,
required: true,
default: () => ({
@@ -72,15 +72,15 @@ const props = defineProps({
}
})
function editCustomAnswer(id) {
router.push({ path: `/admin/ai/custom-answers/${id}/edit` })
function editSnippet(id) {
router.push({ path: `/admin/ai/snippets/${id}/edit` })
}
async function handleDelete() {
try {
await api.deleteAICustomAnswer(props.customAnswer.id)
await api.deleteAISnippet(props.snippet.id)
alertOpen.value = false
emitRefreshCustomAnswerList()
emitRefreshSnippetList()
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -89,9 +89,9 @@ async function handleDelete() {
}
}
const emitRefreshCustomAnswerList = () => {
const emitRefreshSnippetList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'ai_custom_answer'
model: 'ai_snippet'
})
}
</script>

View File

@@ -0,0 +1,24 @@
import * as z from 'zod'
export const createFormSchema = (t) => z.object({
type: z.literal('snippet'), // Fixed value for now, expandable later
content: z
.string({
required_error: t('globals.messages.required'),
})
.min(10, {
message: t('form.error.minmax', {
min: 10,
max: 5000,
})
})
.max(5000, {
message: t('form.error.minmax', {
min: 10,
max: 5000,
})
}),
enabled: z.boolean().optional().default(true),
})

View File

@@ -445,6 +445,81 @@ const routes = [
}
]
},
{
path: 'ai',
meta: { title: 'AI' },
children: [
{
path: 'assistants',
component: () => import('@main/views/admin/ai-assistants/AIAssistants.vue'),
meta: { title: 'AI Assistants' },
children: [
{
path: '',
name: 'ai-assistant-list',
component: () => import('@main/views/admin/ai-assistants/AIAssistantList.vue')
},
{
path: 'new',
name: 'new-ai-assistant',
component: () => import('@main/views/admin/ai-assistants/CreateAIAssistant.vue'),
meta: { title: 'Create AI Assistant' }
},
{
path: ':id/edit',
props: true,
name: 'edit-ai-assistant',
component: () => import('@main/views/admin/ai-assistants/EditAIAssistant.vue'),
meta: { title: 'Edit AI Assistant' }
}
]
},
{
path: 'snippets',
component: () => import('@main/views/admin/snippets/Snippets.vue'),
meta: { title: 'Snippets' },
children: [
{
path: '',
name: 'snippet-list',
component: () => import('@main/views/admin/snippets/SnippetList.vue')
},
{
path: 'new',
name: 'new-snippet',
component: () => import('@main/views/admin/snippets/CreateSnippet.vue'),
meta: { title: 'Create Snippet' }
},
{
path: ':id/edit',
props: true,
name: 'edit-snippet',
component: () => import('@main/views/admin/snippets/EditSnippet.vue'),
meta: { title: 'Edit Snippet' }
}
]
}
]
},
{
path: 'help-center',
component: () => import('@main/views/admin/help-center/HelpCenter.vue'),
meta: { title: 'Help Center' },
children: [
{
path: '',
name: 'help-center-list',
component: () => import('@main/views/admin/help-center/HelpCenterList.vue')
},
{
path: ':id/tree',
name: 'help-center-tree',
props: true,
component: () => import('@main/views/admin/help-center/HelpCenterTree.vue'),
meta: { title: 'Help Center' }
}
]
},
{
path: 'conversations',
meta: { title: 'Conversations' },

View File

@@ -1,51 +0,0 @@
<template>
<AdminPageWithHelp>
<template #content>
<router-view />
</template>
<template #help>
<div class="space-y-4">
<div>
<h3 class="font-semibold text-sm mb-2">{{ $t('ai.customAnswer.helpTitle') }}</h3>
<p class="text-sm text-gray-600 mb-3">{{ $t('ai.customAnswer.helpDescription') }}</p>
</div>
<div>
<h4 class="font-medium text-sm mb-2">{{ $t('ai.customAnswer.helpHowItWorks') }}</h4>
<ul class="text-sm text-gray-600 space-y-1 list-disc list-inside">
<li>{{ $t('ai.customAnswer.helpStep1') }}</li>
<li>{{ $t('ai.customAnswer.helpStep2') }}</li>
<li>{{ $t('ai.customAnswer.helpStep3') }}</li>
</ul>
</div>
<div>
<h4 class="font-medium text-sm mb-2">{{ $t('ai.customAnswer.helpBestPractices') }}</h4>
<ul class="text-sm text-gray-600 space-y-1 list-disc list-inside">
<li>{{ $t('ai.customAnswer.helpTip1') }}</li>
<li>{{ $t('ai.customAnswer.helpTip2') }}</li>
<li>{{ $t('ai.customAnswer.helpTip3') }}</li>
</ul>
</div>
<div class="p-3 bg-blue-50 rounded-lg">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700">{{ $t('ai.customAnswer.helpNote') }}</p>
</div>
</div>
</div>
</div>
</template>
</AdminPageWithHelp>
</template>
<script setup>
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
</script>

View File

@@ -0,0 +1,5 @@
<template>
<router-view />
</template>
<script setup></script>

View File

@@ -0,0 +1,222 @@
<template>
<Spinner v-if="loading" />
<div :class="{ 'transition-opacity duration-300 opacity-50': loading }">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-semibold">{{ $t('globals.terms.helpCenter') }}</h1>
<p class="text-muted-foreground mt-1">
Manage your help centers and knowledge base content
</p>
</div>
<Button @click="openCreateModal">
{{ $t('globals.messages.new', { name: $t('globals.terms.helpCenter') }) }}
</Button>
</div>
<div v-if="helpCenters.length === 0 && !loading" class="text-center py-12">
<div class="text-muted-foreground">
<BookOpenIcon class="mx-auto h-12 w-12 mb-4" />
<h3 class="text-lg font-medium mb-2">No help centers yet</h3>
<p class="mb-4">Create your first help center to get started with knowledge management.</p>
<Button @click="openCreateModal"> Create Help Center </Button>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<HelpCenterCard
v-for="helpCenter in helpCenters"
:key="helpCenter.id"
:help-center="helpCenter"
@click="goToTree(helpCenter.id)"
@edit="editHelpCenter"
@delete="deleteHelpCenter"
/>
</div>
</div>
<!-- Create/Edit Help Center Sheet -->
<Sheet :open="showCreateModal" @update:open="closeCreateModal">
<SheetContent class="sm:max-w-md">
<SheetHeader>
<SheetTitle>
{{ editingHelpCenter ? 'Edit Help Center' : 'Create Help Center' }}
</SheetTitle>
<SheetDescription>
{{
editingHelpCenter
? 'Update your help center details.'
: 'Create a new help center for your knowledge base.'
}}
</SheetDescription>
</SheetHeader>
<HelpCenterForm
:help-center="editingHelpCenter"
:submit-form="handleSave"
:is-loading="isSubmitting"
@cancel="closeCreateModal"
/>
</SheetContent>
</Sheet>
<!-- Delete Confirmation Dialog -->
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = false">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Help Center</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{{ deletingHelpCenter?.name }}"? This action cannot be
undone and will delete all collections and articles within this help center.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
@click="confirmDelete"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { Button } from '@shared-ui/components/ui/button'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@shared-ui/components/ui/sheet'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import { BookOpenIcon } from 'lucide-vue-next'
import HelpCenterCard from '../../../features/admin/help-center/HelpCenterCard.vue'
import HelpCenterForm from '../../../features/admin/help-center/HelpCenterForm.vue'
import api from '../../../api'
import { handleHTTPError } from '../../../utils/http'
import { useI18n } from 'vue-i18n'
const router = useRouter()
const emitter = useEmitter()
const { t } = useI18n()
const loading = ref(false)
const isSubmitting = ref(false)
const helpCenters = ref([])
const showCreateModal = ref(false)
const showDeleteDialog = ref(false)
const editingHelpCenter = ref(null)
const deletingHelpCenter = ref(null)
onMounted(() => {
fetchHelpCenters()
})
const fetchHelpCenters = async () => {
try {
loading.value = true
const { data } = await api.getHelpCenters()
helpCenters.value = data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
loading.value = false
}
}
const goToTree = (helpCenterId) => {
router.push({ name: 'help-center-tree', params: { id: helpCenterId } })
}
const openCreateModal = () => {
editingHelpCenter.value = null
showCreateModal.value = true
}
const editHelpCenter = (helpCenter) => {
editingHelpCenter.value = helpCenter
showCreateModal.value = true
}
const closeCreateModal = () => {
showCreateModal.value = false
editingHelpCenter.value = null
}
const handleSave = async (formData) => {
try {
isSubmitting.value = true
if (editingHelpCenter.value) {
await api.updateHelpCenter(editingHelpCenter.value.id, formData)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.helpCenter')
})
})
} else {
await api.createHelpCenter(formData)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: t('globals.messages.createdSuccessfully', {
name: t('globals.terms.helpCenter')
})
})
}
closeCreateModal()
fetchHelpCenters()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isSubmitting.value = false
}
}
const deleteHelpCenter = (helpCenter) => {
deletingHelpCenter.value = helpCenter
showDeleteDialog.value = true
}
const confirmDelete = async () => {
try {
await api.deleteHelpCenter(deletingHelpCenter.value.id)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: t('globals.messages.deletedSuccessfully', {
name: t('globals.terms.helpCenter')
})
})
showDeleteDialog.value = false
deletingHelpCenter.value = null
fetchHelpCenters()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
</script>

View File

@@ -0,0 +1,578 @@
<template>
<Spinner v-if="loading" />
<div v-else class="h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<Button variant="ghost" size="sm" @click="goBack">
<ArrowLeftIcon class="h-4 w-4" />
</Button>
<div class="h-6 w-px bg-border"></div>
<div>
<h1 class="text-2xl font-semibold">{{ helpCenter?.name }}</h1>
<p class="text-muted-foreground text-sm mt-1">Manage collections and articles</p>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Help Center Actions Dropdown -->
<DropdownMenu :modal="false">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm">
<MoreVerticalIcon class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="editHelpCenter">
<PencilIcon class="mr-2 h-4 w-4" />
Edit Help Center
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="deleteHelpCenter" class="text-destructive">
<TrashIcon class="mr-2 h-4 w-4" />
Delete Help Center
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div class="h-4 w-px bg-border"></div>
<Select v-model="selectedLocale" @update:modelValue="handleLocaleChange">
<SelectTrigger class="w-40">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="language in sortedLanguages"
:key="language.code"
:value="language.code"
>
<span class="flex items-center gap-2">
{{ language.nativeName }}
<span
v-if="language.code === helpCenter?.default_locale"
class="text-xs text-muted-foreground"
>(Default)</span
>
</span>
</SelectItem>
</SelectContent>
</Select>
<Button @click="openCreateCollectionModal">
<PlusIcon class="h-4 w-4" />
New Collection
</Button>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 min-h-0">
<!-- Enhanced Tree Panel -->
<div class="border rounded-lg shadow-sm p-6 h-full overflow-y-auto">
<!-- Empty State -->
<div v-if="treeData.length === 0 && !loading" class="text-center py-16">
<div
class="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-6"
>
<FolderIcon class="h-12 w-12 text-muted-foreground" />
</div>
<h3 class="text-xl font-semibold mb-3">No collections yet</h3>
<p class="text-muted-foreground mb-6 max-w-md mx-auto">
Create your first collection to organize your help articles and make them easily
discoverable by your customers.
</p>
<Button @click="openCreateCollectionModal" size="lg" class="px-8">
<PlusIcon class="h-5 w-5 mr-2" />
Create Collection
</Button>
</div>
<!-- Tree View -->
<TreeView
v-else
:data="treeData"
:selected-item="selectedItem"
@select="selectItem"
@create-collection="openCreateCollectionModal"
@create-article="openCreateArticleModal"
@edit="openEditSheet"
@delete="deleteItem"
@toggle-status="toggleStatus"
/>
</div>
</div>
</div>
<!-- Article Edit Sheet -->
<ArticleEditSheet
:is-open="showArticleEditSheet"
@update:open="showArticleEditSheet = $event"
:article="editingArticle"
:collection-id="editingArticle?.collection_id || createArticleCollectionId"
:locale="selectedLocale"
:submit-form="handleArticleSave"
:is-loading="isSubmittingArticle"
@cancel="closeEditSheet"
/>
<!-- Collection Edit Sheet -->
<CollectionEditSheet
:is-open="showCollectionEditSheet"
@update:open="showCollectionEditSheet = $event"
:collection="editingCollection"
:help-center-id="parseInt(id)"
:parent-id="createCollectionParentId"
:locale="selectedLocale"
:submit-form="handleCollectionSave"
:is-loading="isSubmittingCollection"
@cancel="closeEditSheet"
/>
<!-- Help Center Edit Sheet -->
<Sheet :open="showHelpCenterEditSheet" @update:open="showHelpCenterEditSheet = false">
<SheetContent class="sm:max-w-md">
<SheetHeader>
<SheetTitle>Edit Help Center</SheetTitle>
<SheetDescription> Update your help center details. </SheetDescription>
</SheetHeader>
<HelpCenterForm
:help-center="editingHelpCenter"
:submit-form="handleHelpCenterSave"
:is-loading="isSubmittingHelpCenter"
@cancel="closeHelpCenterEditSheet"
/>
</SheetContent>
</Sheet>
<!-- Delete Confirmation Dialog -->
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = false">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle
>Delete
{{
deletingItem?.type === 'help_center' ? 'Help Center' : deletingItem?.type
}}</AlertDialogTitle
>
<AlertDialogDescription>
Are you sure you want to delete "{{ deletingItem?.name || deletingItem?.title }}"?
{{
deletingItem?.type === 'collection'
? 'This will also delete all articles within this collection.'
: deletingItem?.type === 'help_center'
? 'This will permanently delete the entire help center including all collections and articles. This action cannot be undone.'
: ''
}}
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
@click="confirmDelete"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { LANGUAGES } from '@shared-ui/constants'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { Button } from '@shared-ui/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import {
ArrowLeft as ArrowLeftIcon,
Folder as FolderIcon,
Plus as PlusIcon,
MoreVertical as MoreVerticalIcon,
Pencil as PencilIcon,
Trash as TrashIcon
} from 'lucide-vue-next'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@shared-ui/components/ui/sheet'
import TreeView from '../../../features/admin/help-center/TreeView.vue'
import ArticleEditSheet from '../../../features/admin/help-center/ArticleEditSheet.vue'
import CollectionEditSheet from '../../../features/admin/help-center/CollectionEditSheet.vue'
import HelpCenterForm from '../../../features/admin/help-center/HelpCenterForm.vue'
import api from '../../../api'
import { handleHTTPError } from '../../../utils/http'
import { useI18n } from 'vue-i18n'
const props = defineProps({
id: {
type: String,
required: true
}
})
const router = useRouter()
const emitter = useEmitter()
const { t } = useI18n()
const loading = ref(true)
const isSubmittingCollection = ref(false)
const isSubmittingArticle = ref(false)
const isSubmittingHelpCenter = ref(false)
const helpCenter = ref(null)
const treeData = ref([])
const selectedItem = ref(null)
const selectedLocale = ref('en') // Will be updated when help center is fetched
const showDeleteDialog = ref(false)
const showArticleEditSheet = ref(false)
const showCollectionEditSheet = ref(false)
const showHelpCenterEditSheet = ref(false)
const editingArticle = ref(null)
const editingCollection = ref(null)
const editingHelpCenter = ref(null)
const createCollectionParentId = ref(null)
const createArticleCollectionId = ref(null)
const deletingItem = ref(null)
// Computed property to sort languages with default locale first
const sortedLanguages = computed(() => {
if (!helpCenter.value?.default_locale) {
return LANGUAGES
}
const defaultLocale = helpCenter.value.default_locale
const defaultLang = LANGUAGES.find((lang) => lang.code === defaultLocale)
const otherLangs = LANGUAGES.filter((lang) => lang.code !== defaultLocale)
return defaultLang ? [defaultLang, ...otherLangs] : LANGUAGES
})
onMounted(async () => {
await fetchHelpCenter()
await fetchTree()
})
const fetchHelpCenter = async () => {
try {
const { data } = await api.getHelpCenter(props.id)
helpCenter.value = data.data
// Set the selected locale to the help center's default locale
if (helpCenter.value.default_locale && selectedLocale.value === 'en') {
selectedLocale.value = helpCenter.value.default_locale
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const fetchTree = async () => {
try {
loading.value = true
const { data } = await api.getHelpCenterTree(props.id, { locale: selectedLocale.value })
treeData.value = data.data.tree
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
loading.value = false
}
}
const handleLocaleChange = () => {
selectedItem.value = null
fetchHelpCenter()
fetchTree()
}
const goBack = () => {
router.push({ name: 'help-center-list' })
}
const selectItem = (item) => {
selectedItem.value = item
openEditSheet(item)
}
const openEditSheet = (item) => {
if (item.type === 'article') {
editingArticle.value = item
editingCollection.value = null
showArticleEditSheet.value = true
} else if (item.type === 'collection') {
editingCollection.value = item
editingArticle.value = null
showCollectionEditSheet.value = true
}
}
const closeEditSheet = () => {
showArticleEditSheet.value = false
showCollectionEditSheet.value = false
editingArticle.value = null
editingCollection.value = null
selectedItem.value = null
createCollectionParentId.value = null
createArticleCollectionId.value = null
}
const closeHelpCenterEditSheet = () => {
showHelpCenterEditSheet.value = false
editingHelpCenter.value = null
}
// Help Center operations
const editHelpCenter = () => {
editingHelpCenter.value = helpCenter.value
showHelpCenterEditSheet.value = true
}
const deleteHelpCenter = () => {
deletingItem.value = { ...helpCenter.value, type: 'help_center' }
showDeleteDialog.value = true
}
const handleHelpCenterSave = async (formData) => {
isSubmittingHelpCenter.value = true
try {
await api.updateHelpCenter(props.id, formData)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: 'Help center updated successfully'
})
closeHelpCenterEditSheet()
await fetchHelpCenter()
await fetchTree()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isSubmittingHelpCenter.value = false
}
}
// Collection operations
const openCreateCollectionModal = (parentId = null) => {
// Handle case where parentId might be an event object
let actualParentId = null
if (parentId && typeof parentId === 'object' && 'target' in parentId) {
actualParentId = null
} else if (typeof parentId === 'number' || typeof parentId === 'string') {
actualParentId = parentId
}
editingCollection.value = null
createCollectionParentId.value = actualParentId
showCollectionEditSheet.value = true
}
const handleCollectionSave = async (formData) => {
if (formData.parent_id === 0) {
formData.parent_id = null
}
isSubmittingCollection.value = true
try {
const isEditing = !!editingCollection.value
const payload = {
...formData,
locale: selectedLocale.value
}
if (isEditing) {
const targetId = editingCollection.value.id
await api.updateCollection(props.id, targetId, payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: 'Collection updated successfully'
})
} else {
if (createCollectionParentId.value !== null) {
payload.parent_id = createCollectionParentId.value
}
await api.createCollection(props.id, payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: 'Collection created successfully'
})
}
closeEditSheet()
fetchTree()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isSubmittingCollection.value = false
}
}
// Article operations
const openCreateArticleModal = (collection) => {
editingArticle.value = null
createArticleCollectionId.value = collection.id
showArticleEditSheet.value = true
}
const handleArticleSave = async (formData) => {
isSubmittingArticle.value = true
try {
const isEditing = !!editingArticle.value
const payload = {
...formData,
locale: selectedLocale.value
}
if (isEditing) {
const targetArticle = editingArticle.value
// Check if collection is being changed
const isCollectionChanged = formData.collection_id !== targetArticle.collection_id
if (isCollectionChanged) {
// Use the new endpoint that allows collection changes
await api.updateArticleByID(targetArticle.id, payload)
} else {
// Use the original endpoint for same-collection updates
await api.updateArticle(targetArticle.collection_id, targetArticle.id, payload)
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.article')
})
})
} else {
await api.createArticle(createArticleCollectionId.value, payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: t('globals.messages.createdSuccessfully', {
name: t('globals.terms.article')
})
})
}
closeEditSheet()
fetchTree()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isSubmittingArticle.value = false
}
}
// Delete operations
const deleteItem = (item) => {
deletingItem.value = item
showDeleteDialog.value = true
}
const confirmDelete = async () => {
try {
if (deletingItem.value.type === 'collection') {
await api.deleteCollection(props.id, deletingItem.value.id)
} else if (deletingItem.value.type === 'help_center') {
await api.deleteHelpCenter(deletingItem.value.id)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: 'Help center deleted successfully'
})
// Navigate back to help center list after deletion
router.push({ name: 'help-center-list' })
return
} else {
await api.deleteArticle(deletingItem.value.collection_id, deletingItem.value.id)
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: t('globals.messages.deletedSuccessfully', {
name: t(`globals.terms.${deletingItem.value.type}`)
})
})
if (selectedItem.value?.id === deletingItem.value.id) {
selectedItem.value = null
}
showDeleteDialog.value = false
deletingItem.value = null
fetchTree()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
// Status operations
const toggleStatus = async (item) => {
try {
if (item.type === 'collection') {
await api.toggleCollection(item.id)
} else {
const newStatus = item.status === 'published' ? 'draft' : 'published'
await api.updateArticleStatus(item.id, { status: newStatus })
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'success',
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.status')
})
})
fetchTree()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
</script>

View File

@@ -43,6 +43,10 @@ const breadcrumbLinks = [
const submitForm = (values) => {
let payload
if (values.help_center_id === 0) {
values.help_center_id = null
}
if (inbox.value.channel === 'email') {
payload = {

View File

@@ -55,12 +55,16 @@
<MenuCard
v-for="channel in channels"
:key="channel.title"
:onClick="channel.onClick"
:title="channel.title"
:subTitle="channel.subTitle"
:icon="channel.icon"
class="w-full max-w-sm cursor-pointer"
class="w-full max-w-sm"
@click="channel.onClick"
>
<template #title>
<component :is="channel.icon" size="24" class="mr-2 text-primary" />
{{ channel.title }}
</template>
<template #subtitle>
<p class="text-sm mb-3">{{ channel.subTitle }}</p>
</template>
</MenuCard>
</div>
@@ -86,10 +90,10 @@
<script setup>
import { ref } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import MenuCard from '@shared-ui/components/ui/menu-card/MenuCard.vue'
import { useRouter } from 'vue-router'
import { CustomBreadcrumb } from '@shared-ui/components/ui/breadcrumb/index.js'
import { Check, Mail, MessageCircle } from 'lucide-vue-next'
import MenuCard from '@main/components/layout/MenuCard.vue'
import {
Stepper,
StepperDescription,
@@ -167,6 +171,9 @@ const goInboxList = () => {
}
const submitForm = (values) => {
if (values.help_center_id === 0) {
values.help_center_id = null
}
const channelName = selectedChannel.value.toLowerCase()
const payload = {
name: values.name,
@@ -181,6 +188,9 @@ const submitForm = (values) => {
}
const submitLiveChatForm = (values) => {
if (values.help_center_id === 0) {
values.help_center_id = null
}
const payload = {
name: values.name,
channel: 'livechat',

View File

@@ -2,12 +2,12 @@
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<CustomAnswerForm @submit="onSubmit" @cancel="onCancel" :formLoading="formLoading" />
<SnippetForm @submit="onSubmit" :formLoading="formLoading" />
</template>
<script setup>
import { ref } from 'vue'
import CustomAnswerForm from '@/features/admin/custom-answers/CustomAnswerForm.vue'
import SnippetForm from '@/features/admin/snippets/SnippetForm.vue'
import { handleHTTPError } from '../../../utils/http'
import { CustomBreadcrumb } from '@shared-ui/components/ui/breadcrumb'
import { useRouter } from 'vue-router'
@@ -21,33 +21,29 @@ const emitter = useEmitter()
const router = useRouter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: 'custom-answer-list', label: t('globals.terms.customAnswer', 2) },
{ path: 'snippet-list', label: t('globals.terms.snippet', 2) },
{
path: '',
label: t('globals.messages.new', {
name: t('globals.terms.customAnswer', 1).toLowerCase()
name: t('globals.terms.snippet', 1).toLowerCase()
})
}
]
const onSubmit = (values) => {
createNewCustomAnswer(values)
createNewSnippet(values)
}
const onCancel = () => {
router.push({ name: 'custom-answer-list' })
}
const createNewCustomAnswer = async (values) => {
const createNewSnippet = async (values) => {
try {
formLoading.value = true
await api.createAICustomAnswer(values)
await api.createAISnippet(values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.createdSuccessfully', {
name: t('globals.terms.customAnswer', 1)
name: t('globals.terms.snippet', 1)
})
})
router.push({ name: 'custom-answer-list' })
router.push({ name: 'snippet-list' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -57,4 +53,4 @@ const createNewCustomAnswer = async (values) => {
formLoading.value = false
}
}
</script>
</script>

View File

@@ -2,13 +2,12 @@
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<Spinner v-if="isLoading"/>
<CustomAnswerForm
<Spinner v-if="isLoading" />
<SnippetForm
v-else
:customAnswer="customAnswer"
@submit="onSubmit"
@cancel="onCancel"
:formLoading="formLoading"
:snippet="snippet"
@submit="onSubmit"
:formLoading="formLoading"
/>
</template>
@@ -18,13 +17,13 @@ import api from '../../../api'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import CustomAnswerForm from '@/features/admin/custom-answers/CustomAnswerForm.vue'
import SnippetForm from '@/features/admin/snippets/SnippetForm.vue'
import { CustomBreadcrumb } from '@shared-ui/components/ui/breadcrumb'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const customAnswer = ref({})
const snippet = ref({})
const { t } = useI18n()
const router = useRouter()
const isLoading = ref(false)
@@ -32,33 +31,29 @@ const formLoading = ref(false)
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: 'custom-answer-list', label: t('globals.terms.customAnswer', 2) },
{ path: 'snippet-list', label: t('globals.terms.snippet', 2) },
{
path: '',
label: t('globals.messages.edit', {
name: t('globals.terms.customAnswer', 1).toLowerCase()
name: t('globals.terms.snippet', 1).toLowerCase()
})
}
]
const onSubmit = (values) => {
updateCustomAnswer(values)
updateSnippet(values)
}
const onCancel = () => {
router.push({ name: 'custom-answer-list' })
}
const updateCustomAnswer = async (payload) => {
const updateSnippet = async (payload) => {
try {
formLoading.value = true
await api.updateAICustomAnswer(customAnswer.value.id, payload)
await api.updateAISnippet(snippet.value.id, payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.customAnswer', 1)
name: t('globals.terms.snippet', 1)
})
})
router.push({ name: 'custom-answer-list' })
router.push({ name: 'snippet-list' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -72,8 +67,8 @@ const updateCustomAnswer = async (payload) => {
onMounted(async () => {
try {
isLoading.value = true
const resp = await api.getAICustomAnswer(props.id)
customAnswer.value = resp.data.data
const resp = await api.getAISnippet(props.id)
snippet.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -90,4 +85,4 @@ const props = defineProps({
required: true
}
})
</script>
</script>

View File

@@ -2,10 +2,10 @@
<Spinner v-if="isLoading" />
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
<div class="flex justify-end mb-5">
<router-link :to="{ name: 'new-custom-answer' }">
<router-link :to="{ name: 'new-snippet' }">
<Button>{{
$t('globals.messages.new', {
name: $t('globals.terms.customAnswer', 1)
name: $t('globals.terms.snippet', 1)
})
}}</Button>
</router-link>
@@ -18,7 +18,7 @@
<script setup>
import { onMounted, ref } from 'vue'
import { createColumns } from '../../../features/admin/custom-answers/dataTableColumns.js'
import { createColumns } from '../../../features/admin/snippets/dataTableColumns.js'
import { Button } from '@shared-ui/components/ui/button'
import DataTable from '@main/components/datatable/DataTable.vue'
import { handleHTTPError } from '../../../utils/http'
@@ -36,14 +36,14 @@ const emitter = useEmitter()
onMounted(async () => {
getData()
emitter.on(EMITTER_EVENTS.REFRESH_LIST, (data) => {
if (data?.model === 'ai_custom_answer') getData()
if (data?.model === 'ai_snippet') getData()
})
})
const getData = async () => {
try {
isLoading.value = true
const response = await api.getAICustomAnswers()
const response = await api.getAISnippets()
data.value = response.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -0,0 +1,35 @@
<template>
<AdminPageWithHelp>
<template #content>
<router-view />
</template>
<template #help>
<div class="space-y-4">
<div>
<h3 class="font-semibold text-sm mb-2 text-foreground">{{ $t('ai.snippet.helpTitle') }}</h3>
<p class="text-sm text-muted-foreground mb-3">{{ $t('ai.snippet.helpDescription') }}</p>
</div>
<div>
<h4 class="font-medium text-sm mb-2 text-foreground">{{ $t('ai.snippet.helpHowItWorks') }}</h4>
<ul class="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>{{ $t('ai.snippet.helpStep1') }}</li>
<li>{{ $t('ai.snippet.helpStep2') }}</li>
</ul>
</div>
<div>
<h4 class="font-medium text-sm mb-2 text-foreground">{{ $t('ai.snippet.helpBestPractices') }}</h4>
<ul class="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>{{ $t('ai.snippet.helpTip1') }}</li>
</ul>
</div>
</div>
</template>
</AdminPageWithHelp>
</template>
<script setup>
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
</script>

View File

@@ -50,14 +50,40 @@
padding-left: 0.25rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 1.25rem;
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1rem;
}
h2 {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.875rem;
}
h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
h4 {
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 0.625rem;
}
h5 {
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
h6 {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
a {

View File

@@ -0,0 +1,58 @@
<template>
<Card class="menu-card" @click="$emit('click')">
<CardHeader class="pb-3">
<div class="menu-card-header">
<CardTitle class="menu-card-title">
<slot name="title">
{{ title }}
</slot>
</CardTitle>
</div>
</CardHeader>
<CardContent class="pt-0">
<slot name="subtitle">
<p v-if="subtitle" class="menu-card-subtitle">{{ subtitle }}</p>
</slot>
<slot />
</CardContent>
</Card>
</template>
<script setup>
import { Card, CardContent, CardHeader, CardTitle } from '../card'
defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
}
})
defineEmits(['click'])
</script>
<style scoped>
.menu-card {
@apply border rounded shadow-sm transition-colors cursor-pointer p-2 bg-background;
}
.menu-card:hover {
@apply border-gray-600;
}
.menu-card-header {
@apply flex items-center justify-between;
}
.menu-card-title {
@apply flex items-center text-lg font-medium;
}
.menu-card-subtitle {
@apply text-sm mb-3;
}
</style>

View File

@@ -0,0 +1 @@
export * from './languages.js'

View File

@@ -0,0 +1,93 @@
/**
* Language constants with their locale codes and display names
* These are commonly used languages for customer support platforms
*/
export const LANGUAGES = [
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
{ code: 'fr', name: 'French', nativeName: 'Français' },
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
{ code: 'it', name: 'Italian', nativeName: 'Italiano' },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português' },
{ code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
{ code: 'ko', name: 'Korean', nativeName: '한국어' },
{ code: 'zh', name: 'Chinese (Simplified)', nativeName: '中文 (简体)' },
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '中文 (繁體)' },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية' },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी' },
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
{ code: 'sv', name: 'Swedish', nativeName: 'Svenska' },
{ code: 'da', name: 'Danish', nativeName: 'Dansk' },
{ code: 'no', name: 'Norwegian', nativeName: 'Norsk' },
{ code: 'fi', name: 'Finnish', nativeName: 'Suomi' },
{ code: 'pl', name: 'Polish', nativeName: 'Polski' },
{ code: 'cs', name: 'Czech', nativeName: 'Čeština' },
{ code: 'sk', name: 'Slovak', nativeName: 'Slovenčina' },
{ code: 'hu', name: 'Hungarian', nativeName: 'Magyar' },
{ code: 'ro', name: 'Romanian', nativeName: 'Română' },
{ code: 'bg', name: 'Bulgarian', nativeName: 'Български' },
{ code: 'hr', name: 'Croatian', nativeName: 'Hrvatski' },
{ code: 'sr', name: 'Serbian', nativeName: 'Српски' },
{ code: 'sl', name: 'Slovenian', nativeName: 'Slovenščina' },
{ code: 'et', name: 'Estonian', nativeName: 'Eesti' },
{ code: 'lv', name: 'Latvian', nativeName: 'Latviešu' },
{ code: 'lt', name: 'Lithuanian', nativeName: 'Lietuvių' },
{ code: 'el', name: 'Greek', nativeName: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
{ code: 'he', name: 'Hebrew', nativeName: 'עברית' },
{ code: 'th', name: 'Thai', nativeName: 'ไทย' },
{ code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt' },
{ code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia' },
{ code: 'ms', name: 'Malay', nativeName: 'Bahasa Melayu' },
{ code: 'tl', name: 'Filipino', nativeName: 'Filipino' },
{ code: 'mr', name: 'Marathi', nativeName: 'मराठी' },
{ code: 'ta', name: 'Tamil', nativeName: 'தமிழ்' },
{ code: 'te', name: 'Telugu', nativeName: 'తెలుగు' },
{ code: 'bn', name: 'Bengali', nativeName: 'বাংলা' },
{ code: 'gu', name: 'Gujarati', nativeName: 'ગુજરાતી' },
{ code: 'kn', name: 'Kannada', nativeName: 'ಕನ್ನಡ' },
{ code: 'ml', name: 'Malayalam', nativeName: 'മലയാളം' },
{ code: 'pa', name: 'Punjabi', nativeName: 'ਪੰਜਾਬੀ' },
{ code: 'or', name: 'Odia', nativeName: 'ଓଡ଼ିଆ' },
{ code: 'as', name: 'Assamese', nativeName: 'অসমীয়া' },
{ code: 'ur', name: 'Urdu', nativeName: 'اردو' },
{ code: 'fa', name: 'Persian', nativeName: 'فارسی' },
{ code: 'sw', name: 'Swahili', nativeName: 'Kiswahili' },
{ code: 'af', name: 'Afrikaans', nativeName: 'Afrikaans' },
{ code: 'zu', name: 'Zulu', nativeName: 'isiZulu' },
{ code: 'xh', name: 'Xhosa', nativeName: 'isiXhosa' }
]
/**
* Get language by code
* @param {string} code - Language code
* @returns {Object|undefined} Language object or undefined if not found
*/
export const getLanguageByCode = (code) => {
return LANGUAGES.find(lang => lang.code === code)
}
/**
* Get language display name by code
* @param {string} code - Language code
* @param {boolean} useNative - Whether to use native name
* @returns {string} Language display name or the code if not found
*/
export const getLanguageName = (code, useNative = false) => {
const language = getLanguageByCode(code)
if (!language) return code
return useNative ? language.nativeName : language.name
}
/**
* Check if a language code exists
* @param {string} code - Language code to check
* @returns {boolean} True if language exists
*/
export const isValidLanguageCode = (code) => {
return LANGUAGES.some(lang => lang.code === code)
}

View File

@@ -25,4 +25,13 @@ export const formatDuration = (seconds, showSeconds = true) => {
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
return `${hours}h ${mins}m ${showSeconds ? `${secs}s` : ''}`
}
export const formatDatetime = (date, formatString = 'MMMM d, yyyy h:mm a') => {
try {
return format(date, formatString)
} catch (error) {
console.error('Error formatting date', error, 'date', date)
return ''
}
}

7
go.mod
View File

@@ -27,6 +27,7 @@ require (
github.com/knadh/stuffbin v1.3.0
github.com/lib/pq v1.10.9
github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1
github.com/pemistahl/lingua-go v1.4.0
github.com/pgvector/pgvector-go v0.3.0
github.com/redis/go-redis/v9 v9.5.5
github.com/rhnvrm/simples3 v0.9.1
@@ -35,12 +36,14 @@ require (
github.com/taion809/haikunator v0.0.0-20150324135039-4e414e676fd1
github.com/valyala/fasthttp v1.62.0
github.com/volatiletech/null/v9 v9.0.0
github.com/yuin/goldmark v1.7.13
github.com/zerodha/fastglue v1.8.0
github.com/zerodha/logf v0.5.5
github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0
github.com/zerodha/simplesessions/v3 v3.0.0
golang.org/x/crypto v0.38.0
golang.org/x/mod v0.24.0
golang.org/x/net v0.40.0
golang.org/x/oauth2 v0.21.0
)
@@ -72,11 +75,13 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

14
go.sum
View File

@@ -69,7 +69,9 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -154,6 +156,8 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -170,6 +174,8 @@ github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@@ -216,6 +222,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
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.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/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=
@@ -232,6 +240,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
@@ -290,7 +300,11 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -7,7 +7,6 @@
"globals.terms.article": "Article | Articles",
"globals.terms.collection": "Collection | Collections",
"globals.terms.aiAssistant": "AI Assistant | AI Assistants",
"globals.terms.customAnswer": "Custom answer | Custom answers",
"globals.terms.question": "Question | Questions",
"globals.terms.answer": "Answer | Answers",
"globals.terms.user": "User | Users",
@@ -133,6 +132,7 @@
"globals.terms.automationRule": "Automation Rule | Automation Rules",
"globals.terms.subject": "Subject | Subjects",
"globals.terms.today": "Today",
"globals.terms.none": "None",
"globals.terms.csat": "CSAT | CSATs",
"globals.terms.field": "Field | Fields",
"globals.terms.column": "Column | Columns",
@@ -192,7 +192,8 @@
"globals.terms.channel": "Channel",
"globals.terms.configure": "Configure",
"globals.terms.date": "Date",
"globals.terms.data": "Data | Datas",
"globals.terms.data": "Data | Data",
"globals.terms.snippet": "Snippet | Snippets",
"globals.terms.timestamp": "Timestamp | Timestamps",
"globals.terms.description": "Description | Descriptions",
"globals.terms.fromEmailAddress": "From email address | From email addresses",
@@ -514,9 +515,11 @@
"admin.inbox.skipTLSVerification": "Skip TLS Verification",
"admin.inbox.skipTLSVerification.description": "Skip hostname check on the TLS certificate.",
"admin.inbox.chooseChannel": "Choose a channel",
"admin.inbox.helpCenter.description": "Link a help center to inbox, articles from this helpcenter will be used for AI assistant responses if enabled.",
"admin.inbox.configureChannel": "Configure channel",
"admin.inbox.createEmailInbox": "Create Email Inbox",
"admin.inbox.livechatConfig": "Live Chat Configuration",
"admin.inbox.livechat.language.description": "Language for the live chat widget.",
"admin.inbox.livechat.tabs.general": "General",
"admin.inbox.livechat.tabs.appearance": "Appearance",
"admin.inbox.livechat.tabs.messages": "Messages",
@@ -541,7 +544,6 @@
"admin.inbox.livechat.greetingMessage": "Greeting Message",
"admin.inbox.livechat.introductionMessage": "Introduction Message",
"admin.inbox.livechat.chatIntroduction": "Chat Introduction",
"admin.inbox.livechat.chatIntroduction.description": "Default: Ask us anything, or share your feedback.",
"admin.inbox.livechat.chatReplyExpectationMessage": "Chat reply expectation message",
"admin.inbox.livechat.chatReplyExpectationMessage.description": "Message shown to customers during business hours about expected reply times",
"admin.inbox.livechat.officeHours": "Office Hours",
@@ -553,7 +555,6 @@
"admin.inbox.livechat.noticeBanner.enabled": "Enable Notice Banner",
"admin.inbox.livechat.noticeBanner.enabled.description": "Show a notice banner to visitors",
"admin.inbox.livechat.noticeBanner.text": "Notice Banner Text",
"admin.inbox.livechat.noticeBanner.text.description": "Default: Our response times are slower than usual. We're working hard to get to your message.",
"admin.inbox.livechat.colors": "Colors",
"admin.inbox.livechat.colors.primary": "Primary Color",
"admin.inbox.livechat.colors.background": "Background Color",
@@ -749,9 +750,7 @@
"ai.assistant.nameDescription": "The display name for this AI assistant",
"ai.assistant.emailPlaceholder": "support@company.com",
"ai.assistant.emailDescription": "Optional email address for the AI assistant",
"ai.assistant.profilePictureUrl": "Profile Picture URL",
"ai.assistant.profilePicturePlaceholder": "https://example.com/avatar.png",
"ai.assistant.profilePictureDescription": "Optional profile picture URL for the AI assistant",
"ai.assistant.productName": "Product Name",
"ai.assistant.productNamePlaceholder": "e.g., LibreDesk",
"ai.assistant.productNameDescription": "The name of the product this AI assistant supports",
@@ -777,23 +776,18 @@
"ai.assistant.resetToDefault": "Reset to Default",
"ai.assistant.enabledDescription": "Whether this AI assistant is active and can respond to inquiries",
"ai.assistant.deleteConfirmation": "This will permanently delete the AI assistant and all its configuration. This action cannot be undone.",
"ai.customAnswer.questionPlaceholder": "Enter a frequently asked question...",
"ai.customAnswer.questionDescription": "The question that customers typically ask",
"ai.customAnswer.answerPlaceholder": "Enter the answer to this question...",
"ai.customAnswer.answerDescription": "The exact answer that should be provided for this question",
"ai.customAnswer.enabledDescription": "Whether this custom answer is active and will be used by AI assistants",
"ai.customAnswer.deleteConfirmation": "This will permanently delete the custom answer. This action cannot be undone.",
"ai.customAnswer.helpTitle": "Custom Answers",
"ai.customAnswer.helpDescription": "Create high-confidence answers for frequently asked questions that will be prioritized over general knowledge base search.",
"ai.customAnswer.helpHowItWorks": "How It Works",
"ai.customAnswer.helpStep1": "Questions are matched using AI similarity search with 85% confidence threshold",
"ai.customAnswer.helpStep2": "High-confidence matches return exact custom answers instantly",
"ai.customAnswer.helpStep3": "Lower confidence falls back to help center article search",
"ai.customAnswer.helpBestPractices": "Best Practices",
"ai.customAnswer.helpTip1": "Write questions as customers would ask them",
"ai.customAnswer.helpTip2": "Keep answers concise and actionable",
"ai.customAnswer.helpTip3": "Test different question phrasings for better coverage",
"ai.customAnswer.helpNote": "Custom answers take priority over help center articles to ensure consistent, verified responses for critical questions.",
"ai.snippet.questionPlaceholder": "Enter a frequently asked question...",
"ai.snippet.answerPlaceholder": "Enter the answer to this question...",
"ai.snippet.enabledDescription": "Whether this snippet is active and will be used by AI assistants",
"ai.snippet.deleteConfirmation": "This will permanently delete the snippet. This action cannot be undone.",
"ai.snippet.helpTitle": "AI Snippets",
"ai.snippet.helpDescription": "Private knowledge snippets for AI assistants that won't appear in your public help center.",
"ai.snippet.helpHowItWorks": "How It Works",
"ai.snippet.helpStep1": "AI matches questions using similarity search across snippets and articles",
"ai.snippet.helpStep2": "Most relevant content is used regardless of source",
"ai.snippet.helpBestPractices": "Best Practices",
"ai.snippet.helpTip1": "Use for internal knowledge not suitable for public articles",
"ai.snippet.helpNote": "Snippets and help center articles have equal priority in AI responses. The most relevant content wins based on semantic similarity to the user's question.",
"replyBox.emailAddresess": "Email addresses separated by comma",
"replyBox.invalidEmailsIn": "Invalid email(s) in",
"contact.blockConfirm": "Are you sure you want to block this contact? They will no longer be able to interact with you.",

View File

@@ -6,14 +6,15 @@ import (
"embed"
"errors"
"fmt"
"sort"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
@@ -25,15 +26,13 @@ var (
//go:embed queries.sql
efs embed.FS
ErrInvalidAPIKey = errors.New("invalid API Key")
ErrApiKeyNotSet = errors.New("api Key not set")
ErrCustomAnswerNotFound = errors.New("custom answer not found")
ErrInvalidAPIKey = errors.New("invalid API Key")
ErrApiKeyNotSet = errors.New("api Key not set")
ErrKnowledgeBaseItemNotFound = errors.New("knowledge base item not found")
)
type ConversationStore interface {
SendReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, metaMap map[string]any) error
GetConversationMessages(conversationUUID string, types []string, privateMsgs *bool, page, pageSize int) ([]cmodels.Message, int, error)
RemoveConversationAssignee(uuid, typ string, actor umodels.User) error
UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error
UpdateConversationStatus(uuid string, statusID int, status, snoozeDur string, actor umodels.User) error
@@ -41,10 +40,7 @@ type ConversationStore interface {
type HelpCenterStore interface {
SearchKnowledgeBase(helpCenterID int, query string, locale string, threshold float64, limit int) ([]hcmodels.KnowledgeBaseResult, error)
}
type UserStore interface {
GetAIAssistant(id int) (umodels.User, error)
GetHelpCenterByID(id int) (hcmodels.HelpCenter, error)
}
type Manager struct {
@@ -53,6 +49,7 @@ type Manager struct {
lo *logf.Logger
i18n *i18n.I18n
embeddingCfg EmbeddingConfig
chunkingCfg ChunkingConfig
completionCfg CompletionConfig
workerCfg WorkerConfig
conversationCompletionsService *ConversationCompletionsService
@@ -67,6 +64,12 @@ type EmbeddingConfig struct {
Timeout time.Duration `json:"timeout"`
}
type ChunkingConfig struct {
MaxTokens int `json:"max_tokens"`
MinTokens int `json:"min_tokens"`
OverlapTokens int `json:"overlap_tokens"`
}
type CompletionConfig struct {
Provider string `json:"provider"`
URL string `json:"url"`
@@ -91,22 +94,21 @@ type Opts struct {
// queries contains prepared SQL queries.
type queries struct {
GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
GetPrompt *sqlx.Stmt `query:"get-prompt"`
GetPrompts *sqlx.Stmt `query:"get-prompts"`
SetOpenAIKey *sqlx.Stmt `query:"set-openai-key"`
// Custom Answers
GetAICustomAnswers *sqlx.Stmt `query:"get-ai-custom-answers"`
GetAICustomAnswer *sqlx.Stmt `query:"get-ai-custom-answer"`
InsertAICustomAnswer *sqlx.Stmt `query:"insert-ai-custom-answer"`
UpdateAICustomAnswer *sqlx.Stmt `query:"update-ai-custom-answer"`
DeleteAICustomAnswer *sqlx.Stmt `query:"delete-ai-custom-answer"`
// AI Search Functions
SearchCustomAnswers *sqlx.Stmt `query:"search-custom-answers"`
GetPrompt *sqlx.Stmt `query:"get-prompt"`
GetPrompts *sqlx.Stmt `query:"get-prompts"`
SetOpenAIKey *sqlx.Stmt `query:"set-openai-key"`
GetKnowledgeBaseItems *sqlx.Stmt `query:"get-knowledge-base-items"`
GetKnowledgeBaseItem *sqlx.Stmt `query:"get-knowledge-base-item"`
InsertKnowledgeBaseItem *sqlx.Stmt `query:"insert-knowledge-base-item"`
UpdateKnowledgeBaseItem *sqlx.Stmt `query:"update-knowledge-base-item"`
DeleteKnowledgeBaseItem *sqlx.Stmt `query:"delete-knowledge-base-item"`
InsertEmbedding *sqlx.Stmt `query:"insert-embedding"`
DeleteEmbeddingsBySource *sqlx.Stmt `query:"delete-embeddings-by-source"`
SearchKnowledgeBase *sqlx.Stmt `query:"search-knowledge-base"`
}
// New creates and returns a new instance of the Manager.
func New(embeddingCfg EmbeddingConfig, completionCfg CompletionConfig, workerCfg WorkerConfig, conversationStore ConversationStore, helpCenterStore HelpCenterStore, userStore UserStore, opts Opts) (*Manager, error) {
func New(embeddingCfg EmbeddingConfig, chunkingCfg ChunkingConfig, completionCfg CompletionConfig, workerCfg WorkerConfig, conversationStore ConversationStore, helpCenterStore HelpCenterStore, opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
@@ -118,6 +120,7 @@ func New(embeddingCfg EmbeddingConfig, completionCfg CompletionConfig, workerCfg
lo: opts.Lo,
i18n: opts.I18n,
embeddingCfg: embeddingCfg,
chunkingCfg: chunkingCfg,
completionCfg: completionCfg,
workerCfg: workerCfg,
helpCenterStore: helpCenterStore,
@@ -128,7 +131,6 @@ func New(embeddingCfg EmbeddingConfig, completionCfg CompletionConfig, workerCfg
manager,
conversationStore,
helpCenterStore,
userStore,
workerCfg.Workers,
workerCfg.Capacity,
opts.Lo,
@@ -309,172 +311,174 @@ func (m *Manager) handleProviderError(context string, err error) error {
return envelope.NewError(envelope.GeneralError, err.Error(), nil)
}
// Custom Answers CRUD
// Knowledge Base CRUD
// GetAICustomAnswers returns all AI custom answers
func (m *Manager) GetAICustomAnswers() ([]models.CustomAnswer, error) {
var customAnswers []models.CustomAnswer
if err := m.q.GetAICustomAnswers.Select(&customAnswers); err != nil {
m.lo.Error("error fetching custom answers", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "custom answers"), nil)
// GetKnowledgeBaseItems returns all knowledge base items
func (m *Manager) GetKnowledgeBaseItems() ([]models.KnowledgeBase, error) {
var items = make([]models.KnowledgeBase, 0)
if err := m.q.GetKnowledgeBaseItems.Select(&items); err != nil {
m.lo.Error("error fetching knowledge base items", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "knowledge base items"), nil)
}
return customAnswers, nil
return items, nil
}
// GetAICustomAnswer returns a specific AI custom answer by ID
func (m *Manager) GetAICustomAnswer(id int) (models.CustomAnswer, error) {
var customAnswer models.CustomAnswer
if err := m.q.GetAICustomAnswer.Get(&customAnswer, id); err != nil {
// GetKnowledgeBaseItem returns a specific knowledge base item by ID
func (m *Manager) GetKnowledgeBaseItem(id int) (models.KnowledgeBase, error) {
var item models.KnowledgeBase
if err := m.q.GetKnowledgeBaseItem.Get(&item, id); err != nil {
if err == sql.ErrNoRows {
return customAnswer, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "custom answer"), nil)
return item, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "knowledge base item"), nil)
}
m.lo.Error("error fetching custom answer", "error", err, "id", id)
return customAnswer, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "custom answer"), nil)
m.lo.Error("error fetching knowledge base item", "error", err, "id", id)
return item, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "knowledge base item"), nil)
}
return customAnswer, nil
return item, nil
}
// CreateAICustomAnswer creates a new AI custom answer with embeddings
func (m *Manager) CreateAICustomAnswer(question, answer string, enabled bool) (models.CustomAnswer, error) {
// Generate embeddings for the question
embedding, err := m.GetEmbeddings(question)
if err != nil {
m.lo.Error("error generating embeddings for custom answer", "error", err, "question", question)
return models.CustomAnswer{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "custom answer"), nil)
// CreateKnowledgeBaseItem creates a new knowledge base item and generates embeddings using chunking
func (m *Manager) CreateKnowledgeBaseItem(itemType, content string, enabled bool) (models.KnowledgeBase, error) {
// First, insert the knowledge base item for immediate availability
var item models.KnowledgeBase
if err := m.q.InsertKnowledgeBaseItem.Get(&item, itemType, content, enabled); err != nil {
m.lo.Error("error creating knowledge base item", "error", err, "type", itemType)
return item, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "knowledge base item"), nil)
}
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
m.lo.Info("knowledge base item created successfully", "id", item.ID, "type", itemType)
var customAnswer models.CustomAnswer
if err := m.q.InsertAICustomAnswer.Get(&customAnswer, question, answer, vector, enabled); err != nil {
m.lo.Error("error creating custom answer", "error", err, "question", question)
return customAnswer, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "custom answer"), nil)
}
// Generate embeddings asynchronously using chunking
go m.processKnowledgeBaseContent(item.ID, content)
m.lo.Info("custom answer created successfully", "id", customAnswer.ID, "question", question)
return customAnswer, nil
return item, nil
}
// UpdateAICustomAnswer updates an existing AI custom answer
func (m *Manager) UpdateAICustomAnswer(id int, question, answer string, enabled bool) (models.CustomAnswer, error) {
// Generate embeddings for the updated question
embedding, err := m.GetEmbeddings(question)
if err != nil {
m.lo.Error("error generating embeddings for custom answer update", "error", err, "id", id, "question", question)
return models.CustomAnswer{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "custom answer"), nil)
}
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
var customAnswer models.CustomAnswer
if err := m.q.UpdateAICustomAnswer.Get(&customAnswer, id, question, answer, vector, enabled); err != nil {
// UpdateKnowledgeBaseItem updates an existing knowledge base item and regenerates embeddings
func (m *Manager) UpdateKnowledgeBaseItem(id int, itemType, content string, enabled bool) (models.KnowledgeBase, error) {
// First, update the knowledge base item for immediate availability
var item models.KnowledgeBase
if err := m.q.UpdateKnowledgeBaseItem.Get(&item, id, itemType, content, enabled); err != nil {
if err == sql.ErrNoRows {
return customAnswer, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "custom answer"), nil)
return item, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "knowledge base item"), nil)
}
m.lo.Error("error updating custom answer", "error", err, "id", id)
return customAnswer, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "custom answer"), nil)
m.lo.Error("error updating knowledge base item", "error", err, "id", id)
return item, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "knowledge base item"), nil)
}
m.lo.Info("custom answer updated successfully", "id", id, "question", question)
return customAnswer, nil
m.lo.Info("knowledge base item updated successfully", "id", id, "type", itemType)
// Delete old embeddings and regenerate new ones asynchronously
go m.processKnowledgeBaseContent(id, content)
return item, nil
}
// DeleteAICustomAnswer deletes an AI custom answer
func (m *Manager) DeleteAICustomAnswer(id int) error {
result, err := m.q.DeleteAICustomAnswer.Exec(id)
if err != nil {
m.lo.Error("error deleting custom answer", "error", err, "id", id)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "custom answer"), nil)
// DeleteKnowledgeBaseItem deletes a knowledge base item and its embeddings
func (m *Manager) DeleteKnowledgeBaseItem(id int) error {
// Delete embeddings first
if _, err := m.q.DeleteEmbeddingsBySource.Exec("knowledge_base", id); err != nil {
m.lo.Error("error deleting embeddings for knowledge base item", "error", err, "id", id)
// Continue with deletion even if embedding deletion fails
}
rowsAffected, err := result.RowsAffected()
if err != nil {
m.lo.Error("error checking rows affected", "error", err, "id", id)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "custom answer"), nil)
// Delete the knowledge base item
if _, err := m.q.DeleteKnowledgeBaseItem.Exec(id); err != nil {
m.lo.Error("error deleting knowledge base item", "error", err, "id", id)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "knowledge base item"), nil)
}
if rowsAffected == 0 {
return envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "custom answer"), nil)
}
m.lo.Info("custom answer deleted successfully", "id", id)
return nil
}
// SmartSearch performs a two-tier search: custom answers first, then knowledge base fallback
func (m *Manager) SmartSearch(helpCenterID int, query string, locale string) (any, error) {
// SmartSearch performs unified search across knowledge base and help center articles
func (m *Manager) SmartSearch(helpCenterID int, query, locale string) ([]models.UnifiedKnowledgeResult, error) {
const (
// TODO: These can be made configurable?
customAnswerThreshold = 0.85 // High confidence for custom answers
knowledgeBaseThreshold = 0.25 // Lower threshold for knowledge base
maxResults = 6
threshold = 0.15
maxResults = 8
)
// Step 1: Search custom answers with high confidence
customAnswer, err := m.searchCustomAnswers(query, customAnswerThreshold)
if err != nil && err != ErrCustomAnswerNotFound {
// Search both knowledge base and help center concurrently with same threshold
knowledgeBaseResults, err := m.searchKnowledgeBaseItems(query, threshold, maxResults)
if err != nil && err != ErrKnowledgeBaseItemNotFound {
return nil, err
}
// If we found a high-confidence custom answer, return it
if customAnswer != nil {
m.lo.Info("found high-confidence custom answer", "similarity", customAnswer.Similarity, "query", query)
return customAnswer, nil
}
// Step 2: Search knowledge base with lower threshold
knowledgeResults, err := m.searchKnowledgeBase(helpCenterID, query, locale, knowledgeBaseThreshold, maxResults)
helpCenterResults, err := m.searchHelpCenter(helpCenterID, query, locale, threshold, maxResults)
if err != nil {
return nil, err
}
if len(knowledgeResults) > 0 {
m.lo.Info("found knowledge base results", "count", len(knowledgeResults), "top_similarity", knowledgeResults[0].Similarity, "query", query)
return knowledgeResults, nil
// Combine results from both sources
var allResults []models.UnifiedKnowledgeResult
// Convert knowledge base results to UnifiedKnowledgeResult format
for _, kb := range knowledgeBaseResults {
allResults = append(allResults, models.UnifiedKnowledgeResult{
SourceType: "knowledge_base",
SourceID: kb.ID,
Title: "",
Content: kb.Content,
HelpCenterID: nil, // Knowledge base items are not tied to help centers
Similarity: kb.Similarity,
})
}
// No results found
m.lo.Info("no results found in smart search", "query", query, "help_center_id", helpCenterID)
return []models.KnowledgeBaseResult{}, nil
// Add help center results
allResults = append(allResults, helpCenterResults...)
if len(allResults) == 0 {
m.lo.Info("no results found in smart search", "query", query)
return []models.UnifiedKnowledgeResult{}, nil
}
// Sort all results by similarity score (highest first)
sort.Slice(allResults, func(i, j int) bool {
return allResults[i].Similarity > allResults[j].Similarity
})
// Limit to maxResults
if len(allResults) > maxResults {
allResults = allResults[:maxResults]
}
m.lo.Info("found unified search results", "count", len(allResults), "top_similarity", allResults[0].Similarity, "query", query)
return allResults, nil
}
// searchCustomAnswers searches for custom answers with high confidence threshold
func (m *Manager) searchCustomAnswers(query string, threshold float64) (*models.CustomAnswerResult, error) {
// searchKnowledgeBaseItems searches for knowledge base items with the specified threshold and limit
func (m *Manager) searchKnowledgeBaseItems(query string, threshold float64, limit int) ([]models.KnowledgeBaseResult, error) {
// Generate embeddings for the search query
embedding, err := m.GetEmbeddings(query)
if err != nil {
m.lo.Error("error generating embeddings for custom answer search", "error", err, "query", query)
return nil, fmt.Errorf("generating embeddings for custom answer search: %w", err)
m.lo.Error("error generating embeddings for knowledge base search", "error", err, "query", query)
return nil, fmt.Errorf("generating embeddings for knowledge base search: %w", err)
}
var result models.CustomAnswerResult
var results []models.KnowledgeBaseResult
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
if err = m.q.SearchCustomAnswers.Get(&result, vector, threshold); err != nil {
if err = m.q.SearchKnowledgeBase.Select(&results, vector, threshold, limit); err != nil {
if err == sql.ErrNoRows {
return nil, ErrCustomAnswerNotFound
return []models.KnowledgeBaseResult{}, ErrKnowledgeBaseItemNotFound
}
m.lo.Error("error searching custom answers", "error", err, "query", query)
return nil, fmt.Errorf("searching custom answers: %w", err)
m.lo.Error("error searching knowledge base", "error", err, "query", query)
return nil, fmt.Errorf("searching knowledge base: %w", err)
}
return &result, nil
return results, nil
}
// searchKnowledgeBase searches knowledge base (articles) with the specified threshold and limit.
func (m *Manager) searchKnowledgeBase(helpCenterID int, query string, locale string, threshold float64, limit int) ([]models.KnowledgeBaseResult, error) {
// Use the helpcenter store to perform the search
// searchHelpCenter searches help center articles with the specified threshold and limit.
func (m *Manager) searchHelpCenter(helpCenterID int, query, locale string, threshold float64, limit int) ([]models.UnifiedKnowledgeResult, error) {
hcResults, err := m.helpCenterStore.SearchKnowledgeBase(helpCenterID, query, locale, threshold, limit)
if err != nil {
return nil, err
}
// Convert help center results to our KnowledgeBaseResult format
results := make([]models.KnowledgeBaseResult, len(hcResults))
// Convert help center results to our UnifiedKnowledgeResult format
results := make([]models.UnifiedKnowledgeResult, len(hcResults))
for i, hcResult := range hcResults {
results[i] = models.KnowledgeBaseResult{
results[i] = models.UnifiedKnowledgeResult{
SourceType: hcResult.SourceType,
SourceID: hcResult.SourceID,
Title: hcResult.Title,
@@ -486,3 +490,64 @@ func (m *Manager) searchKnowledgeBase(helpCenterID int, query string, locale str
return results, nil
}
// GetChunkConfig returns the configured chunking configuration
func (m *Manager) GetChunkConfig() stringutil.ChunkConfig {
return stringutil.ChunkConfig{
MaxTokens: m.chunkingCfg.MaxTokens,
MinTokens: m.chunkingCfg.MinTokens,
OverlapTokens: m.chunkingCfg.OverlapTokens,
TokenizerFunc: nil, // Use default tokenizer
PreserveBlocks: []string{"pre", "code", "table"},
Logger: m.lo,
}
}
// processKnowledgeBaseContent processes knowledge base content by chunking it and generating embeddings
// This function is designed to be called asynchronously to avoid blocking the main operation
func (m *Manager) processKnowledgeBaseContent(itemID int, content string) {
// First, delete any existing embeddings for this item
if _, err := m.q.DeleteEmbeddingsBySource.Exec("knowledge_base", itemID); err != nil {
m.lo.Error("error deleting existing embeddings in background", "error", err, "item_id", itemID)
// Continue with processing even if deletion fails
}
// Chunk the HTML content with configured parameters
chunks, err := stringutil.ChunkHTMLContent("", content, m.GetChunkConfig())
if err != nil {
m.lo.Error("error chunking HTML content", "error", err, "item_id", itemID)
return
}
if len(chunks) == 0 {
m.lo.Warn("no chunks generated for knowledge base item", "item_id", itemID)
return
}
// Process each chunk
for i, chunk := range chunks {
// Generate embeddings for the chunk text
embedding, err := m.GetEmbeddings(chunk.Text)
if err != nil {
m.lo.Error("error generating embeddings for chunk in background", "error", err, "item_id", itemID, "chunk", i)
continue // Skip this chunk but continue with others
}
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
// Create metadata for the chunk
meta := fmt.Sprintf(`{"chunk_index": %d, "total_chunks": %d, "has_heading": %t, "has_code": %t, "has_table": %t}`,
chunk.ChunkIndex, chunk.TotalChunks, chunk.HasHeading, chunk.HasCode, chunk.HasTable)
m.lo.Debug("ai knowledge base chunk metadata", "item_id", itemID, "chunk", i, "metadata", meta)
// Store the embedding in the centralized embeddings table
if _, err := m.q.InsertEmbedding.Exec("knowledge_base", itemID, chunk.Text, vector, meta); err != nil {
m.lo.Error("error storing embedding for chunk in background", "error", err, "item_id", itemID, "chunk", i)
continue // Skip this chunk but continue with others
}
}
m.lo.Info("knowledge base item embeddings processed successfully in background", "item_id", itemID, "chunks_processed", len(chunks))
}

View File

@@ -1,54 +1,44 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"text/template"
"time"
"github.com/abhinavxd/libredesk/internal/ai/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/zerodha/logf"
)
var (
baseSystemPrompt = `
Role Description:
You are %s - a knowledgeable and approachable support assistant dedicated exclusively to supporting %s product inquiries which is "%s".
Your scope is strictly limited to this product and related matters.
// systemPromptData holds the data for generating the AI system prompt
type systemPromptData struct {
AssistantName string
ProductName string
ProductDescription string
ToneInstruction string
LengthInstruction string
HandoffEnabled bool
}
Guidelines:
- If the user message is or contains gratitude/acknowledgment or brief positive feedback such as: thanks, thank you, cool, nice, great, awesome, perfect, good job, appreciate it,
sounds good, ok, okay, yep, yeah, works, solved, correct — respond only with: "Did that answer your question?" Do not apply out-of-scope, clarification, or additional-answer logic in that case.
- Every other response: Provide direct, helpful answers.
- Keep the conversation human-like and engaging, but focused on the product.
- Avoid speculating or providing unverified information. If you cannot answer from available knowledge, say so clearly.
- If the user asks something outside the product's scope, politely redirect: "I can only help with %s related questions."
- Detect user language and reply in the same language, never mix languages.
- Avoid filler phrases. Keep answers simple; do not assume the user is technical.
- If the question is too short or vague (and not caught by the gratitude rule), ask for clarification: "Could you please provide more details or clarify your question?"
- If the user replies positively to "Did that answer your question?" (examples: yes, yep, yeah, correct, works, solved, perfect), respond with exactly: "conversation_resolve" and stop.
- %s
- %s
type queryRefinementResponse struct {
OriginalLanguage string `json:"original_language"`
TranslatedQuery string `json:"translated_query"`
RefinedQuery string `json:"refined_query"`
ConfidenceScore float64 `json:"confidence_score"`
}
Special Response Commands:
- To request handoff to human agent, respond with exactly: "conversation_handoff"
- To mark conversation as resolved, respond with exactly: "conversation_resolve"
Execution Protocol:
- On each user message:
* First check for gratitude/acknowledgment or brief positive feedback; if present, reply only with "Did that answer your question?"
* If user confirms the question was answered, respond with "conversation_resolve".
* Detect language and respond in same language.
* If outside product scope, redirect appropriately.
* If vague, ask for clarification.
* Otherwise provide the direct answer.
`
)
type aiConversationResponse struct {
Reasoning string `json:"reasoning"`
Response string `json:"response"`
UserMessage string `json:"user_message"`
}
// getToneInstruction returns the tone instruction based on the tone setting
func getToneInstruction(tone string) string {
@@ -80,20 +70,12 @@ func getLengthInstruction(length string) string {
}
}
// buildSystemPrompt creates the final system prompt with tone and length instructions
func buildSystemPrompt(assistantName, productName, productDescription, tone, length string) string {
toneInstruction := getToneInstruction(tone)
lengthInstruction := getLengthInstruction(length)
return fmt.Sprintf(baseSystemPrompt, assistantName, productName, productDescription, productName, toneInstruction, lengthInstruction)
}
// ConversationCompletionsService handles AI-powered chat completions for customer support
type ConversationCompletionsService struct {
lo *logf.Logger
manager *Manager
conversationStore ConversationStore
helpCenterStore HelpCenterStore
userStore UserStore
requestQueue chan models.ConversationCompletionRequest
workers int
capacity int
@@ -105,7 +87,7 @@ type ConversationCompletionsService struct {
}
// NewConversationCompletionsService creates a new conversation completions service
func NewConversationCompletionsService(manager *Manager, conversationStore ConversationStore, helpCenterStore HelpCenterStore, userStore UserStore, workers, capacity int, lo *logf.Logger) *ConversationCompletionsService {
func NewConversationCompletionsService(manager *Manager, conversationStore ConversationStore, helpCenterStore HelpCenterStore, workers, capacity int, lo *logf.Logger) *ConversationCompletionsService {
ctx, cancel := context.WithCancel(context.Background())
return &ConversationCompletionsService{
@@ -113,7 +95,6 @@ func NewConversationCompletionsService(manager *Manager, conversationStore Conve
manager: manager,
conversationStore: conversationStore,
helpCenterStore: helpCenterStore,
userStore: userStore,
requestQueue: make(chan models.ConversationCompletionRequest, capacity),
workers: workers,
capacity: capacity,
@@ -163,6 +144,30 @@ func (s *ConversationCompletionsService) EnqueueRequest(req models.ConversationC
}
}
// buildSystemPrompt renders the final system prompt with tone and length instructions
func buildSystemPrompt(assistantName, productName, productDescription, tone, length string, handoffEnabled bool) (string, error) {
data := systemPromptData{
AssistantName: assistantName,
ProductName: productName,
ProductDescription: productDescription,
ToneInstruction: getToneInstruction(tone),
LengthInstruction: getLengthInstruction(length),
HandoffEnabled: handoffEnabled,
}
tmpl, err := template.New("systemPrompt").Parse(ConversationSystemPrompt)
if err != nil {
return "", fmt.Errorf("failed to parse system prompt template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute system prompt template: %w", err)
}
return buf.String(), nil
}
// worker processes completion requests from the queue
func (s *ConversationCompletionsService) worker() {
defer s.wg.Done()
@@ -182,56 +187,44 @@ func (s *ConversationCompletionsService) worker() {
// processCompletionRequest handles a single completion request
func (s *ConversationCompletionsService) processCompletionRequest(req models.ConversationCompletionRequest) {
if req.AIAssistantID == 0 {
s.lo.Warn("AI completion request without assistant ID, skipping", "conversation_uuid", req.ConversationUUID)
return
}
start := time.Now()
s.lo.Info("processing AI completion request", "conversation_uuid", req.ConversationUUID)
var (
aiAssistant umodels.User
messages = req.Messages
start = time.Now()
aiAssistantMeta umodels.AIAssistantMeta
)
aiAssistant, err := s.userStore.GetAIAssistant(req.AIAssistantID)
if err == nil {
// Parse AI assistant meta data
if len(aiAssistant.Meta) > 0 {
if err := json.Unmarshal(aiAssistant.Meta, &aiAssistantMeta); err != nil {
s.lo.Error("error parsing AI assistant meta", "error", err, "assistant_id", req.AIAssistantID)
return
}
}
} else {
s.lo.Error("error getting AI assistant", "error", err, "assistant_id", req.AIAssistantID)
s.lo.Info("processing AI completion request", "conversation_uuid", req.ConversationUUID)
if req.AIAssistant.ID == 0 {
s.lo.Warn("AI assistant not found, skipping AI completion", "conversation_uuid", req.ConversationUUID)
return
}
if !aiAssistant.Enabled {
s.lo.Warn("AI assistant is disabled, skipping AI completion", "assistant_id", req.AIAssistantID, "conversation_uuid", req.ConversationUUID)
if err := json.Unmarshal(req.AIAssistant.Meta, &aiAssistantMeta); err != nil {
s.lo.Error("error parsing AI assistant meta", "error", err, "assistant_id", req.AIAssistant.ID, "conversation_uuid", req.ConversationUUID, "meta", req.AIAssistant.Meta)
return
}
// Fetch conversation messages for preparing history and context
messages, err := s.getConversationMessages(req.ConversationUUID)
if err != nil {
s.lo.Error("error getting conversation messages", "error", err, "conversation_uuid", req.ConversationUUID)
if !req.AIAssistant.Enabled {
s.lo.Warn("AI assistant is disabled, skipping AI completion", "assistant_id", req.AIAssistant.ID, "conversation_uuid", req.ConversationUUID)
return
}
// Get the latest message from the contact
// Get the latest message from contact
latestContactMessage := s.getLatestContactMessage(messages)
// Build context from help center articles
context, err := s.buildHelpCenterContext(req, latestContactMessage)
// Build context from latest contact message
context, err := s.buildSearchContext(req, latestContactMessage)
if err != nil {
s.lo.Error("error building help center context", "error", err, "conversation_uuid", req.ConversationUUID)
// Continue without help center context
}
// Build chat messages array with proper roles
chatMessages := s.buildChatMessages(context, messages, aiAssistant, aiAssistantMeta)
chatMessages, err := s.buildChatMessages(context, messages, req.AIAssistant, aiAssistantMeta)
if err != nil {
s.lo.Error("failed to build chat messages", "error", err, "conversation_uuid", req.ConversationUUID)
return
}
// Send AI completion request to the provider
upstreamStartAt := time.Now()
@@ -248,39 +241,76 @@ func (s *ConversationCompletionsService) processCompletionRequest(req models.Con
var (
handoffRequested bool
resolved bool
finalResponse string
reasoning string
)
// Check for conversation handoff
finalResponse := strings.TrimSpace(aiResponse)
// Try to parse as JSON first
cleanedResponse := stringutil.CleanJSONResponse(aiResponse)
var structuredResponse aiConversationResponse
if err := json.Unmarshal([]byte(cleanedResponse), &structuredResponse); err != nil {
// Fallback: treat as plain text response
s.lo.Debug("AI response not in JSON format, using as plain text",
"conversation_uuid", req.ConversationUUID,
"response", aiResponse)
finalResponse = strings.TrimSpace(aiResponse)
reasoning = ""
} else {
// Successfully parsed JSON
finalResponse = strings.TrimSpace(structuredResponse.Response)
reasoning = strings.TrimSpace(structuredResponse.Reasoning)
s.lo.Info("AI reasoning captured",
"conversation_uuid", req.ConversationUUID,
"reasoning", reasoning)
}
// Check for conversation handoff and resolution
switch finalResponse {
case "conversation_handoff":
s.lo.Info("AI requested conversation handoff", "conversation_uuid", req.ConversationUUID)
finalResponse = "Connecting you with one of our support agents who can better assist you."
if structuredResponse.UserMessage != "" {
finalResponse = structuredResponse.UserMessage
} else {
finalResponse = "Connecting you with one of our support agents who can better assist you."
}
handoffRequested = true
case "conversation_resolve":
s.lo.Info("AI requested conversation resolution", "conversation_uuid", req.ConversationUUID)
finalResponse = ""
resolved = true
default:
// Convert markdown to HTML for consistent formatting with TipTap editor output, since LLMs often use markdown for formatting in their responses.
// Requesting HTML directly was not consistent.
finalResponse = s.convertMarkdownToHTML(finalResponse)
}
// Send AI response
if finalResponse != "" {
err = s.conversationStore.SendReply(
// Prepare metadata with reasoning if available
metaMap := map[string]any{
"ai_generated": true,
"processing_time_ms": time.Since(start).Milliseconds(),
"ai_model": s.manager.completionCfg.Model,
"ai_provider": s.manager.completionCfg.Provider,
}
// Add reasoning if available
if reasoning != "" {
metaMap["ai_reasoning"] = reasoning
}
if err = s.conversationStore.SendReply(
nil, // No media attachments for AI responses
req.InboxID,
aiAssistant.ID,
req.AIAssistant.ID,
req.ContactID,
req.ConversationUUID,
finalResponse,
[]string{}, // to
[]string{}, // cc
[]string{}, // bcc
map[string]any{
"ai_generated": true,
"processing_time_ms": time.Since(start).Milliseconds(),
},
)
if err != nil {
metaMap,
); err != nil {
s.lo.Error("error sending AI response", "conversation_uuid", req.ConversationUUID, "error", err)
return
}
@@ -289,7 +319,7 @@ func (s *ConversationCompletionsService) processCompletionRequest(req models.Con
// If handoff is requested and enabled for this AI assistant, remove conversation assignee and optionally update team assignee if team ID is set
if handoffRequested && aiAssistantMeta.HandOff {
// First unassign the conversation from the AI assistant
if err := s.conversationStore.RemoveConversationAssignee(req.ConversationUUID, "user", aiAssistant); err != nil {
if err := s.conversationStore.RemoveConversationAssignee(req.ConversationUUID, "user", req.AIAssistant); err != nil {
s.lo.Error("error removing conversation assignee", "conversation_uuid", req.ConversationUUID, "error", err)
} else {
s.lo.Info("conversation assignee removed for handoff", "conversation_uuid", req.ConversationUUID)
@@ -297,7 +327,7 @@ func (s *ConversationCompletionsService) processCompletionRequest(req models.Con
// Set the handoff team if specified
if aiAssistantMeta.HandOffTeam > 0 {
if err := s.conversationStore.UpdateConversationTeamAssignee(req.ConversationUUID, aiAssistantMeta.HandOffTeam, aiAssistant); err != nil {
if err := s.conversationStore.UpdateConversationTeamAssignee(req.ConversationUUID, aiAssistantMeta.HandOffTeam, req.AIAssistant); err != nil {
s.lo.Error("error updating conversation team assignee", "conversation_uuid", req.ConversationUUID, "team_id", aiAssistantMeta.HandOffTeam, "error", err)
} else {
s.lo.Info("conversation handoff to team", "conversation_uuid", req.ConversationUUID, "team_id", aiAssistantMeta.HandOffTeam)
@@ -307,20 +337,29 @@ func (s *ConversationCompletionsService) processCompletionRequest(req models.Con
// Resolve the conversation if requested
if resolved {
if err := s.conversationStore.UpdateConversationStatus(req.ConversationUUID, 0, cmodels.StatusResolved, "", aiAssistant); err != nil {
if err := s.conversationStore.UpdateConversationStatus(req.ConversationUUID, 0, cmodels.StatusResolved, "", req.AIAssistant); err != nil {
s.lo.Error("error updating conversation status to resolved", "conversation_uuid", req.ConversationUUID, "error", err)
} else {
s.lo.Info("conversation marked as resolved", "conversation_uuid", req.ConversationUUID)
}
}
s.lo.Info("AI completion request processed successfully", "conversation_uuid", req.ConversationUUID, "processing_time", time.Since(start))
}
// getConversationMessages fetches messages for a conversation
func (s *ConversationCompletionsService) getConversationMessages(conversationUUID string) ([]cmodels.Message, error) {
messages, _, err := s.conversationStore.GetConversationMessages(conversationUUID, []string{cmodels.MessageOutgoing, cmodels.MessageIncoming}, nil, 1, 10)
return messages, err
// Log the reasoning if available
if reasoning != "" {
s.lo.Info("AI completion request processed successfully with reasoning",
"conversation_uuid", req.ConversationUUID,
"processing_time", time.Since(start),
"response_length", len(finalResponse),
"reasoning", reasoning,
"has_reasoning", true)
} else {
s.lo.Info("AI completion request processed successfully",
"conversation_uuid", req.ConversationUUID,
"processing_time", time.Since(start),
"response_length", len(finalResponse),
"response_type", "plain_text",
"has_reasoning", false)
}
}
// getLatestContactMessage returns the text content of the latest contact message
@@ -333,9 +372,9 @@ func (s *ConversationCompletionsService) getLatestContactMessage(messages []cmod
return ""
}
// buildHelpCenterContext searches for relevant content using smart search and builds context
func (s *ConversationCompletionsService) buildHelpCenterContext(req models.ConversationCompletionRequest, latestContactMessage string) (string, error) {
if s.helpCenterStore == nil || req.HelpCenterID == 0 {
// buildSearchContext performs context-aware search across knowledge sources and builds context
func (s *ConversationCompletionsService) buildSearchContext(req models.ConversationCompletionRequest, latestContactMessage string) (string, error) {
if s.helpCenterStore == nil {
return "", nil
}
@@ -344,48 +383,107 @@ func (s *ConversationCompletionsService) buildHelpCenterContext(req models.Conve
return "", nil
}
// Use smart search to find the best answer
result, err := s.manager.SmartSearch(req.HelpCenterID, latestContactMessage, req.Locale)
// Get target language from help center's default locale
locale := ""
if req.HelpCenterID.Valid {
helpCenter, err := s.helpCenterStore.GetHelpCenterByID(req.HelpCenterID.Int)
if err != nil {
s.lo.Error("error fetching help center for default locale", "error", err, "help_center_id", req.HelpCenterID.Int)
} else {
locale = helpCenter.DefaultLocale
}
}
// Default fallback
if locale == "" {
s.lo.Warn("no help center locale found for completions, defaulting to English", "conversation_uuid", req.ConversationUUID)
locale = "en"
}
// Attempt context-aware query refinement
searchQuery := latestContactMessage
var confidence float64 = 0.0
refinementResponse, err := s.refineSearchQuery(latestContactMessage, locale, req.Messages)
if err != nil {
s.lo.Error("query refinement failed", "error", err, "original_query", latestContactMessage)
return "", err
} else {
confidence = refinementResponse.ConfidenceScore
// Use refined query if confidence is above threshold (0.7)
if confidence >= 0.7 && refinementResponse.RefinedQuery != "" {
searchQuery = refinementResponse.RefinedQuery
s.lo.Info("using refined query for search",
"original", latestContactMessage,
"refined", refinementResponse.RefinedQuery,
"confidence", confidence,
"locale", locale)
} else {
// Low confidence refinement - use translated query if available
if refinementResponse.TranslatedQuery != "" {
searchQuery = refinementResponse.TranslatedQuery
s.lo.Info("low confidence refinement, using translated query",
"original", latestContactMessage,
"translated", refinementResponse.TranslatedQuery,
"refined", refinementResponse.RefinedQuery,
"confidence", confidence)
} else {
// Both refinement and translation failed
searchQuery = latestContactMessage
s.lo.Warn("low confidence refinement and no translation, using original query",
"original", latestContactMessage,
"refined", refinementResponse.RefinedQuery,
"confidence", confidence)
}
}
}
result, err := s.manager.SmartSearch(req.HelpCenterID.Int, searchQuery, locale)
if err != nil {
return "", err
}
if result == nil {
if len(result) == 0 {
s.lo.Warn("no relevant help center content found",
"conversation_uuid", req.ConversationUUID,
"original_query", latestContactMessage,
"search_query", searchQuery,
"confidence", confidence)
return "", nil
}
// Build context based on result type
// Build context based on unified search results
var contextBuilder strings.Builder
// Check if it's a custom answer (high confidence)
if customAnswer, ok := result.(*models.CustomAnswerResult); ok {
contextBuilder.WriteString("High-confidence custom answer:\n\n")
contextBuilder.WriteString(fmt.Sprintf("Q: %s\n", customAnswer.Question))
contextBuilder.WriteString(fmt.Sprintf("A: %s\n\n", customAnswer.Answer))
contextBuilder.WriteString("Please use this exact answer as it's a verified response for this type of question.")
return contextBuilder.String(), nil
}
// Otherwise, it's knowledge base results
if knowledgeResults, ok := result.([]models.KnowledgeBaseResult); ok && len(knowledgeResults) > 0 {
if len(result) > 0 {
contextBuilder.WriteString("Relevant knowledge base content:\n\n")
for i, item := range knowledgeResults {
contextBuilder.WriteString(fmt.Sprintf("%d. %s\n", i+1, item.Title))
for i, item := range result {
// Different handling for snippets vs articles
if item.SourceType == "snippet" {
contextBuilder.WriteString(fmt.Sprintf("%d. [SNIPPET] %s\n", i+1, item.Title))
} else {
contextBuilder.WriteString(fmt.Sprintf("%d. [ARTICLE] %s\n", i+1, item.Title))
}
if item.Content != "" {
contextBuilder.WriteString(fmt.Sprintf(" %s\n\n", item.Content))
}
}
s.lo.Info("found relevant help center content",
"conversation_uuid", req.ConversationUUID,
"results_count", len(result),
"search_query", searchQuery,
"refinement_confidence", confidence)
return contextBuilder.String(), nil
}
s.lo.Warn("no relevant help center content found", "conversation_uuid", req.ConversationUUID, "query", latestContactMessage)
return "", nil
}
// buildChatMessages creates a properly structured chat messages array for AI completion
func (s *ConversationCompletionsService) buildChatMessages(helpCenterContext string, messages []cmodels.Message, senderUser umodels.User, aiAssistantMeta umodels.AIAssistantMeta) []models.ChatMessage {
func (s *ConversationCompletionsService) buildChatMessages(helpCenterContext string, messages []cmodels.Message, senderUser umodels.User, aiAssistantMeta umodels.AIAssistantMeta) ([]models.ChatMessage, error) {
var chatMessages []models.ChatMessage
// 1. Add system prompt with dynamic assistant name and product
@@ -413,9 +511,13 @@ func (s *ConversationCompletionsService) buildChatMessages(helpCenterContext str
}
// Inject help center context into the system prompt if present
systemPrompt := buildSystemPrompt(assistantName, productName, productDescription, answerTone, answerLength)
systemPrompt, err := buildSystemPrompt(assistantName, productName, productDescription, answerTone, answerLength, aiAssistantMeta.HandOff)
if err != nil {
return nil, fmt.Errorf("failed to build system prompt: %w", err)
}
if helpCenterContext != "" {
systemPrompt += "\n\nKnowledge base context (for reference):\n" + helpCenterContext
systemPrompt += "\n\nNote: If the knowledge base content is in a different language than the customer's question, you may use it as reference but always respond in the customer's language."
}
chatMessages = append(chatMessages, models.ChatMessage{
@@ -443,5 +545,108 @@ func (s *ConversationCompletionsService) buildChatMessages(helpCenterContext str
})
}
return chatMessages
return chatMessages, nil
}
// convertMarkdownToHTML converts markdown content to HTML using stringutil and removes single paragraph wrapping
func (s *ConversationCompletionsService) convertMarkdownToHTML(markdown string) string {
htmlContent, err := stringutil.MarkdownToHTML(markdown)
if err != nil {
s.lo.Error("error converting markdown to HTML", "error", err, "markdown", markdown)
// Return original markdown as fallback
return markdown
}
// Remove wrapping <p> tags if the content is a single paragraph
// This prevents double paragraph wrapping in the chat UI
htmlContent = strings.TrimSpace(htmlContent)
if strings.HasPrefix(htmlContent, "<p>") && strings.HasSuffix(htmlContent, "</p>") && strings.Count(htmlContent, "<p>") == 1 {
htmlContent = htmlContent[3 : len(htmlContent)-4]
}
return htmlContent
}
// prepareConversationContext formats the last N messages for the LLM prompt
func (s *ConversationCompletionsService) prepareConversationContext(messages []cmodels.Message, maxMessages int, maxContentLength int) string {
var contextBuilder strings.Builder
// Get the last N messages (excluding the very latest one which is the current query)
messageCount := 0
for i := 1; i < len(messages) && messageCount < maxMessages; i++ {
msg := messages[i]
// Skip private messages
if msg.Private {
continue
}
// Determine role
role := "ASSISTANT"
if msg.SenderType == cmodels.SenderTypeContact {
role = "USER"
}
// Truncate content if too long
content := msg.TextContent
if len(content) > maxContentLength {
content = content[:maxContentLength] + "... [truncated]"
}
contextBuilder.WriteString(fmt.Sprintf("%s: %s\n", role, content))
messageCount++
}
if contextBuilder.Len() == 0 {
return "No prior conversation context."
}
return strings.TrimSpace(contextBuilder.String())
}
// refineSearchQuery performs context-aware query refinement using LLM
func (s *ConversationCompletionsService) refineSearchQuery(query, targetLanguage string, messages []cmodels.Message) (queryRefinementResponse, error) {
// Prepare conversation context
conversationContext := s.prepareConversationContext(messages, 3, 200)
// Build the refinement prompt
prompt := fmt.Sprintf(QueryRefinementPrompt, targetLanguage, targetLanguage, conversationContext, query)
// Create chat messages for LLM call
chatMessages := []models.ChatMessage{
{
Role: "system",
Content: QueryRefinementSystemMessage,
},
{
Role: "user",
Content: prompt,
},
}
// Call LLM for refinement
response, err := s.manager.ChatCompletion(chatMessages)
if err != nil {
s.lo.Error("error calling LLM for query refinement", "error", err, "query", query)
return queryRefinementResponse{}, fmt.Errorf("LLM call failed: %w", err)
}
// Parse JSON response with safety net for markdown-wrapped responses
cleanedResponse := stringutil.CleanJSONResponse(response)
var refinementResponse queryRefinementResponse
if err := json.Unmarshal([]byte(cleanedResponse), &refinementResponse); err != nil {
s.lo.Error("error parsing LLM refinement response", "error", err,
"original_response", response,
"cleaned_response", cleanedResponse)
return queryRefinementResponse{}, fmt.Errorf("failed to parse LLM response: %w", err)
}
s.lo.Debug("query refinement completed",
"original_query", query,
"refined_query", refinementResponse.RefinedQuery,
"translated_query", refinementResponse.TranslatedQuery,
"confidence", refinementResponse.ConfidenceScore,
"target_language", targetLanguage)
return refinementResponse, nil
}

View File

@@ -4,6 +4,8 @@ import (
"time"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/volatiletech/null/v9"
)
type Provider struct {
@@ -27,13 +29,12 @@ type Prompt struct {
// ConversationCompletionRequest represents a request for AI conversation completion
type ConversationCompletionRequest struct {
Messages []cmodels.Message `json:"messages"`
InboxID int `json:"inbox_id"`
ContactID int `json:"contact_id"`
ConversationUUID string `json:"conversation_uuid"`
HelpCenterID int `json:"help_center_id"`
Locale string `json:"locale"`
AIAssistantID int `json:"ai_assistant_id"`
Messages []cmodels.Message
InboxID int
ContactID int
ConversationUUID string
AIAssistant umodels.User
HelpCenterID null.Int
}
// ChatMessage represents a single message in a chat
@@ -53,28 +54,28 @@ type PromptPayload struct {
UserPrompt string `json:"user_prompt"`
}
// CustomAnswer represents an AI custom answer record
type CustomAnswer struct {
// KnowledgeBase represents an AI knowledge base record
type KnowledgeBase struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Question string `db:"question" json:"question"`
Answer string `db:"answer" json:"answer"`
Type string `db:"type" json:"type"`
Content string `db:"content" json:"content"`
Enabled bool `db:"enabled" json:"enabled"`
}
// CustomAnswerResult represents a custom answer with similarity score
type CustomAnswerResult struct {
// KnowledgeBaseResult represents a knowledge base entry with similarity score
type KnowledgeBaseResult struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Question string `db:"question" json:"question"`
Answer string `db:"answer" json:"answer"`
Type string `db:"type" json:"type"`
Content string `db:"content" json:"content"`
Similarity float64 `db:"similarity" json:"similarity"`
}
// KnowledgeBaseResult represents a unified search result from knowledge base
type KnowledgeBaseResult struct {
// UnifiedKnowledgeResult represents a unified search result from knowledge base
type UnifiedKnowledgeResult struct {
SourceType string `db:"source_type" json:"source_type"`
SourceID int `db:"source_id" json:"source_id"`
Title string `db:"title" json:"title"`

View File

@@ -1,6 +1,3 @@
-- name: get-default-provider
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
-- name: get-prompt
SELECT id, key, title, content FROM ai_prompts where key = $1;
@@ -16,41 +13,60 @@ SET config = jsonb_set(
)
WHERE provider = 'openai';
-- name: get-ai-custom-answers
SELECT id, created_at, updated_at, question, answer, enabled
FROM ai_custom_answers
-- name: get-knowledge-base-items
SELECT id, created_at, updated_at, type, content, enabled
FROM ai_knowledge_base
ORDER BY created_at DESC;
-- name: get-ai-custom-answer
SELECT id, created_at, updated_at, question, answer, enabled
FROM ai_custom_answers
-- name: get-knowledge-base-item
SELECT id, created_at, updated_at, type, content, enabled
FROM ai_knowledge_base
WHERE id = $1;
-- name: insert-ai-custom-answer
INSERT INTO ai_custom_answers (question, answer, embedding, enabled)
VALUES ($1, $2, $3::vector, $4)
RETURNING id, created_at, updated_at, question, answer, enabled;
-- name: insert-knowledge-base-item
INSERT INTO ai_knowledge_base (type, content, enabled)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at, type, content, enabled;
-- name: update-ai-custom-answer
UPDATE ai_custom_answers
SET question = $2, answer = $3, embedding = $4::vector, enabled = $5, updated_at = NOW()
-- name: update-knowledge-base-item
UPDATE ai_knowledge_base
SET type = $2, content = $3, enabled = $4, updated_at = NOW()
WHERE id = $1
RETURNING id, created_at, updated_at, question, answer, enabled;
RETURNING id, created_at, updated_at, type, content, enabled;
-- name: delete-ai-custom-answer
DELETE FROM ai_custom_answers WHERE id = $1;
-- name: delete-knowledge-base-item
DELETE FROM ai_knowledge_base WHERE id = $1;
-- name: search-custom-answers
SELECT
-- name: insert-embedding
INSERT INTO embeddings (source_type, source_id, chunk_text, embedding, meta)
VALUES ($1, $2, $3, $4::vector, $5)
RETURNING id;
-- name: delete-embeddings-by-source
DELETE FROM embeddings
WHERE source_type = $1 AND source_id = $2;
-- name: search-knowledge-base
WITH knowledge_results AS (
SELECT
kb.id,
kb.created_at,
kb.updated_at,
kb.type,
kb.content,
(1 - (e.embedding <=> $1::vector)) as similarity
FROM ai_knowledge_base kb
JOIN embeddings e ON e.source_type = 'knowledge_base' AND e.source_id = kb.id
WHERE kb.enabled = true
AND (1 - (e.embedding <=> $1::vector)) >= $2
)
SELECT DISTINCT ON (id)
id,
created_at,
updated_at,
question,
answer,
1 - (embedding <=> $1::vector) AS similarity
FROM ai_custom_answers
WHERE enabled = true
AND embedding IS NOT NULL
AND 1 - (embedding <=> $1::vector) >= $2 -- confidence threshold
ORDER BY embedding <=> $1::vector
LIMIT 1;
type,
content,
similarity
FROM knowledge_results
ORDER BY id, similarity DESC
LIMIT $3;

View File

@@ -0,0 +1,112 @@
package ai
// ConversationSystemPrompt is the base template for AI assistant conversation completion
var ConversationSystemPrompt = `
**Identity & Role:**
You are {{.AssistantName}}, a {{.ProductName}} support assistant. Your expertise is exclusively focused on the {{.ProductName}} platform, which is "{{.ProductDescription}}". You are NOT a general AI assistant - you are a product expert whose knowledge is strictly limited to {{.ProductName}} features, setup, troubleshooting, and best practices.
**Your Areas of Expertise:**
- {{.ProductName}} platform features and functionality
- Troubleshooting {{.ProductName}}-related issues
- Customer support and service questions
**Response Guidelines:**
- Base ALL answers on the provided knowledge base context below
{{if .HandoffEnabled}}
- If the customer requests to connect to human or requests a hand off to a human, respond with exactly: "conversation_handoff"
{{else}}
- When customers request to speak with a human or requests a hand off to a human: "I understand you'd like to speak with a human agent, you might want to contact our support team directly for further assistance."
{{end}}
- Never invent information or provide answers from outside your {{.ProductName}} expertise
- If the knowledge base doesn't contain the answer, you MUST state: "I don't have that information in my knowledge base. You may need to contact support directly for further assistance."
- Detect user language and always respond in the same language
- Be concise, human, and helpful with short sentences
**Conversation Flow:**
- For gratitude/acknowledgments (thanks, ok, cool, great, etc.), respond ONLY with: "Did that answer your question?"
- If user confirms positively (yes, solved, works, etc.), respond with exactly: "conversation_resolve"
**Strictly Off-Limits:**
For ANY question outside {{.ProductName}} scope (programming, general knowledge, jokes, other products), you MUST respond with: "I'm a {{.ProductName}} support specialist, so I can only help with {{.ProductName}}-related questions. Is there something specific about {{.ProductName}} I can help you with today?"
**Examples:**
User: "Tell me a joke" → "I'm a {{.ProductName}} support specialist, so I can only help with {{.ProductName}}-related questions. Is there something specific about {{.ProductName}} I can help you with today?"
User: "Write Python code" → "I'm a {{.ProductName}} support specialist, so I can only help with {{.ProductName}}-related questions. Is there something specific about {{.ProductName}} I can help you with today?"
User: "What's the weather?" → "I'm a {{.ProductName}} support specialist, so I can only help with {{.ProductName}}-related questions. Is there something specific about {{.ProductName}} I can help you with today?"
{{.ToneInstruction}}
{{.LengthInstruction}}
**Response Format:**
Your response MUST be a JSON object with the following structure:
{
"reasoning": "Brief explanation of your thought process and why you chose this response",
"response": "Your actual response to the customer OR a special command",
"user_message": "A translated, user-facing message. ONLY use this field when the 'response' field contains a special command like 'conversation_handoff'"
}
**Special Commands:**
These commands can be used to control the conversation flow and are never to be mixed with regular responses.
{{if .HandoffEnabled}}- Human handoff: Put "conversation_handoff" in the response field
{{end}}- Mark resolved: Put "conversation_resolve" in the response field
**JSON Response Guidelines:**
- Always provide reasoning for transparency and debugging
- If you cannot provide reasoning, use empty string ""
- Do NOT wrap the JSON in markdown code blocks
- For special commands (conversation_handoff, conversation_resolve), put them in the response field
- When using special commands, include a user_message in the customer's language
- Ensure the JSON is valid and properly formatted
**Example of a Handoff Response:**
{
"reasoning": "The user is asking to speak to a person.",
"response": "conversation_handoff",
"user_message": "Te estoy conectando con uno de nuestros agentes de soporte que podrán ayudarte mejor."
}
`
// TranslationPrompt is the template for translating customer queries
var TranslationPrompt = `Translate the following customer support query to %s for knowledge base search purposes.
CRITICAL INSTRUCTIONS:
- Do not translate technical terms, product names, or brand names
- Do not translate text in backticks, quotes, or code blocks
- Preserve all technical jargon exactly as written
- Return only the translated text, no explanations or additional text
- If the query contains code, error messages, or technical commands, leave them unchanged
Original query: %s
Translation:`
// QueryRefinementPrompt is the template for context-aware query refinement
var QueryRefinementPrompt = `You are an intelligent assistant for customer support. Process this query:
1. Detect the language of the user's query
2. If not in %s, translate it to %s
3. Refine the query for help center search using conversation context
4. Provide a confidence score (0.0-1.0) for your refinement
Conversation Context:
%s
User Query: "%s"
Confidence Guidelines:
- 0.9-1.0: Very clear context, unambiguous refinement
- 0.7-0.8: Good context, reasonable refinement
- 0.5-0.6: Some context, uncertain refinement
- 0.3-0.4: Minimal context, speculative refinement
- 0.0-0.2: No useful context, cannot refine reliably
Output JSON format:
{
"original_language": "detected language code",
"translated_query": "query in target language",
"refined_query": "context-aware refined query (max 20 words)",
"confidence_score": 0.0
}`
// QueryRefinementSystemMessage is the system message for query refinement requests
var QueryRefinementSystemMessage = "You are a precise query refinement assistant for customer support. Focus on technical accuracy and context understanding."

View File

@@ -1442,29 +1442,3 @@ func (m *Manager) calculateBusinessHoursInfo(conversation models.Conversation) (
return businessHoursID, utcOffset
}
// enqueueAICompletion enqueues an AI completion request for the conversation
func (m *Manager) enqueueAICompletion(messages []models.Message, conversationUUID string, inboxID, contactID, aiAssistantID, helpCenterID int, locale string) {
if m.aiStore == nil {
m.lo.Warn("AI store not configured, skipping AI completion request")
return
}
req := aimodels.ConversationCompletionRequest{
Messages: messages,
InboxID: inboxID,
ContactID: contactID,
ConversationUUID: conversationUUID,
HelpCenterID: helpCenterID,
Locale: locale,
AIAssistantID: aiAssistantID,
}
err := m.aiStore.EnqueueConversationCompletion(req)
if err != nil {
m.lo.Error("error enqueuing AI completion request", "error", err)
return
}
m.lo.Info("AI completion request enqueued", "conversation_uuid", conversationUUID)
}

View File

@@ -12,6 +12,7 @@ import (
"strings"
"time"
aimodels "github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/attachment"
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/conversation/models"
@@ -553,37 +554,8 @@ func (m *Manager) InsertMessage(message *models.Message) error {
m.webhookStore.TriggerEvent(wmodels.EventMessageCreated, updatedMessage)
}
// Enqueue message for AI completion if the conversation is assigned to an `ai_assistant`.
conversation, err := m.GetConversation(updatedMessage.ConversationID, "")
if err != nil {
m.lo.Error("error fetching conversation for AI completion", "conversation_id", updatedMessage.ConversationID, "error", err)
return nil
}
if conversation.AssignedUserID.Int > 0 {
assigneUser, err := m.userStore.Get(conversation.AssignedUserID.Int, "", "")
if err != nil {
m.lo.Error("error fetching assignee user for AI completion", "assignee_user_id", conversation.AssignedUserID.Int, "error", err)
return nil
}
if assigneUser.IsAiAssistant() {
if updatedMessage.Type == models.MessageIncoming && updatedMessage.SenderType == models.SenderTypeContact {
messages, _, err := m.GetConversationMessages(message.ConversationUUID, []string{models.MessageIncoming, models.MessageOutgoing}, nil, 1, 50)
if err != nil {
m.lo.Error("error fetching conversation messages", "conversation_uuid", message.ConversationUUID, "error", err)
} else {
m.enqueueAICompletion(
messages,
message.ConversationUUID,
conversation.InboxID,
conversation.ContactID,
assigneUser.ID,
1, // TODO: Fill this from inbox.
"en", // TODO: Fill this from somewhere?
)
}
}
}
}
// Handle AI completion for AI assistant conversations.
m.enqueueMessageForAICompletion(updatedMessage, message)
return nil
}
@@ -1088,3 +1060,67 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
}
return nil
}
// enqueueMessageForAICompletion enqueues message for AI completion if the conversation is assigned to an AI assistant
// and if the inbox has help center attached.
func (m *Manager) enqueueMessageForAICompletion(updatedMessage models.Message, message *models.Message) {
if m.aiStore == nil {
m.lo.Warn("AI store not configured, skipping AI completion request")
return
}
// Only process incoming messages from contacts.
if updatedMessage.Type != models.MessageIncoming || updatedMessage.SenderType != models.SenderTypeContact {
return
}
conversation, err := m.GetConversation(updatedMessage.ConversationID, "")
if err != nil {
m.lo.Error("error fetching conversation for AI completion", "conversation_id", updatedMessage.ConversationID, "error", err)
return
}
// Get the attached help center to the inbox.
inbox, err := m.inboxStore.GetDBRecord(conversation.InboxID)
if err != nil {
m.lo.Error("error fetching inbox for AI completion", "inbox_id", conversation.InboxID, "error", err)
return
}
// Check if conversation is assigned to a user and inbox has help center attached.
if conversation.AssignedUserID.Int <= 0 || !inbox.HelpCenterID.Valid {
return
}
// Check if assignee is an AI assistant and is enabled.
assigneUser, err := m.userStore.Get(conversation.AssignedUserID.Int, "", "")
if err != nil {
m.lo.Error("error fetching assignee user for AI completion", "assignee_user_id", conversation.AssignedUserID.Int, "error", err)
return
}
if !assigneUser.IsAiAssistant() || !assigneUser.Enabled {
return
}
messages, _, err := m.GetConversationMessages(message.ConversationUUID, []string{models.MessageIncoming, models.MessageOutgoing}, nil, 1, 50)
if err != nil {
m.lo.Error("error fetching conversation message history for AI completion", "conversation_uuid", message.ConversationUUID, "error", err)
return
}
req := aimodels.ConversationCompletionRequest{
Messages: messages,
InboxID: conversation.InboxID,
ContactID: conversation.ContactID,
ConversationUUID: conversation.UUID,
AIAssistant: assigneUser,
HelpCenterID: inbox.HelpCenterID,
}
if err := m.aiStore.EnqueueConversationCompletion(req); err != nil {
m.lo.Error("error enqueuing AI completion request", "error", err)
return
}
m.lo.Info("AI completion request enqueued", "conversation_uuid", conversation.UUID)
}

View File

@@ -0,0 +1,770 @@
// Package helpcenter handles the management of help centers, collections, and articles.
package helpcenter
import (
"embed"
"encoding/json"
"fmt"
"time"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/helpcenter/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/pgvector/pgvector-go"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Request structs for help center operations
type HelpCenterCreateRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
PageTitle string `json:"page_title"`
DefaultLocale string `json:"default_locale"`
}
type HelpCenterUpdateRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
PageTitle string `json:"page_title"`
DefaultLocale string `json:"default_locale"`
}
type CollectionCreateRequest struct {
Slug string `json:"slug"`
ParentID *int `json:"parent_id"`
Locale string `json:"locale"`
Name string `json:"name"`
Description *string `json:"description"`
SortOrder int `json:"sort_order"`
IsPublished bool `json:"is_published"`
}
type CollectionUpdateRequest struct {
Slug string `json:"slug"`
ParentID *int `json:"parent_id"`
Locale string `json:"locale"`
Name string `json:"name"`
Description *string `json:"description"`
SortOrder int `json:"sort_order"`
IsPublished bool `json:"is_published"`
}
type ArticleCreateRequest struct {
Slug string `json:"slug"`
Locale string `json:"locale"`
Title string `json:"title"`
Content string `json:"content"`
SortOrder int `json:"sort_order"`
Status string `json:"status"`
AIEnabled bool `json:"ai_enabled"`
}
type ArticleUpdateRequest struct {
Slug string `json:"slug"`
Locale string `json:"locale"`
Title string `json:"title"`
Content string `json:"content"`
SortOrder int `json:"sort_order"`
Status string `json:"status"`
AIEnabled bool `json:"ai_enabled"`
CollectionID *int `json:"collection_id,omitempty"`
}
type UpdateStatusRequest struct {
Status string `json:"status"`
}
type AIStore interface {
GetEmbeddings(text string) ([]float32, error)
GetChunkConfig() stringutil.ChunkConfig
}
type Manager struct {
q queries
db *sqlx.DB
lo *logf.Logger
i18n *i18n.I18n
ai AIStore
}
// Opts contains options for initializing the Manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
I18n *i18n.I18n
}
// queries contains prepared SQL queries.
type queries struct {
// Help Centers
GetAllHelpCenters *sqlx.Stmt `query:"get-all-help-centers"`
GetHelpCenterByID *sqlx.Stmt `query:"get-help-center-by-id"`
GetHelpCenterBySlug *sqlx.Stmt `query:"get-help-center-by-slug"`
InsertHelpCenter *sqlx.Stmt `query:"insert-help-center"`
UpdateHelpCenter *sqlx.Stmt `query:"update-help-center"`
DeleteHelpCenter *sqlx.Stmt `query:"delete-help-center"`
// Collections
GetCollectionsByHelpCenter *sqlx.Stmt `query:"get-collections-by-help-center"`
GetCollectionsByHelpCenterAndLocale *sqlx.Stmt `query:"get-collections-by-help-center-and-locale"`
GetCollectionByID *sqlx.Stmt `query:"get-collection-by-id"`
InsertCollection *sqlx.Stmt `query:"insert-collection"`
UpdateCollection *sqlx.Stmt `query:"update-collection"`
ToggleCollectionPublished *sqlx.Stmt `query:"toggle-collection-published"`
DeleteCollection *sqlx.Stmt `query:"delete-collection"`
// Articles
GetArticlesByCollection *sqlx.Stmt `query:"get-articles-by-collection"`
GetArticlesByCollectionAndLocale *sqlx.Stmt `query:"get-articles-by-collection-and-locale"`
GetArticleByID *sqlx.Stmt `query:"get-article-by-id"`
InsertArticle *sqlx.Stmt `query:"insert-article"`
UpdateArticle *sqlx.Stmt `query:"update-article"`
MoveArticle *sqlx.Stmt `query:"move-article"`
UpdateArticleStatus *sqlx.Stmt `query:"update-article-status"`
DeleteArticle *sqlx.Stmt `query:"delete-article"`
SearchArticlesByVector *sqlx.Stmt `query:"search-articles-by-vector"`
UpdateArticleEmbedding *sqlx.Stmt `query:"update-article-embedding"`
SearchKnowledgeBase *sqlx.Stmt `query:"search-knowledge-base"`
DeleteEmbeddingsBySource *sqlx.Stmt `query:"delete-embeddings-by-source"`
InsertEmbedding *sqlx.Stmt `query:"insert-embedding"`
GetHelpCenterTreeData *sqlx.Stmt `query:"get-help-center-tree-data"`
}
// New creates and returns a new instance of the Manager.
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
db: opts.DB,
lo: opts.Lo,
i18n: opts.I18n,
}, nil
}
func (m *Manager) SetAIStore(ai AIStore) {
m.ai = ai
}
// Help Centers
// GetAllHelpCenters retrieves all help centers.
func (m *Manager) GetAllHelpCenters() ([]models.HelpCenter, error) {
var helpCenters = make([]models.HelpCenter, 0)
if err := m.q.GetAllHelpCenters.Select(&helpCenters); err != nil {
m.lo.Error("error fetching help centers", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "help centers"), nil)
}
return helpCenters, nil
}
// GetHelpCenterByID retrieves a help center by ID.
func (m *Manager) GetHelpCenterByID(id int) (models.HelpCenter, error) {
var hc models.HelpCenter
if err := m.q.GetHelpCenterByID.Get(&hc, id); err != nil {
m.lo.Error("error fetching help center", "error", err, "id", id)
return hc, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "help center"), nil)
}
return hc, nil
}
// GetHelpCenterBySlug retrieves a help center by slug.
func (m *Manager) GetHelpCenterBySlug(slug string) (models.HelpCenter, error) {
var hc models.HelpCenter
if err := m.q.GetHelpCenterBySlug.Get(&hc, slug); err != nil {
m.lo.Error("error fetching help center by slug", "error", err, "slug", slug)
return hc, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "help center"), nil)
}
return hc, nil
}
// CreateHelpCenter creates a new help center.
func (m *Manager) CreateHelpCenter(req HelpCenterCreateRequest) (models.HelpCenter, error) {
// Set default locale to 'en' if not provided
defaultLocale := req.DefaultLocale
if defaultLocale == "" {
defaultLocale = "en"
}
var hc models.HelpCenter
if err := m.q.InsertHelpCenter.Get(&hc, req.Name, req.Slug, req.PageTitle, defaultLocale); err != nil {
if dbutil.IsUniqueViolationError(err) {
return hc, envelope.NewError(envelope.ConflictError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", "help center slug"), nil)
}
m.lo.Error("error creating help center", "error", err)
return hc, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "help center"), nil)
}
return hc, nil
}
// UpdateHelpCenter updates a help center.
func (m *Manager) UpdateHelpCenter(id int, req HelpCenterUpdateRequest) (models.HelpCenter, error) {
// Set default locale to 'en' if not provided
defaultLocale := req.DefaultLocale
if defaultLocale == "" {
defaultLocale = "en"
}
var hc models.HelpCenter
if err := m.q.UpdateHelpCenter.Get(&hc, id, req.Name, req.Slug, req.PageTitle, defaultLocale); err != nil {
if dbutil.IsUniqueViolationError(err) {
return hc, envelope.NewError(envelope.ConflictError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", "help center slug"), nil)
}
m.lo.Error("error updating help center", "error", err, "id", id)
return hc, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "help center"), nil)
}
return hc, nil
}
// DeleteHelpCenter deletes a help center by ID.
func (m *Manager) DeleteHelpCenter(id int) error {
if _, err := m.q.DeleteHelpCenter.Exec(id); err != nil {
m.lo.Error("error deleting help center", "error", err, "id", id)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "help center"), nil)
}
return nil
}
// Collections
// GetCollectionsByHelpCenter retrieves all collections for a help center.
func (m *Manager) GetCollectionsByHelpCenter(helpCenterID int) ([]models.Collection, error) {
var collections = make([]models.Collection, 0)
if err := m.q.GetCollectionsByHelpCenter.Select(&collections, helpCenterID); err != nil {
m.lo.Error("error fetching collections", "error", err, "help_center_id", helpCenterID)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "collections"), nil)
}
return collections, nil
}
// GetCollectionsByHelpCenterAndLocale retrieves collections for a help center and locale.
func (m *Manager) GetCollectionsByHelpCenterAndLocale(helpCenterID int, locale string) ([]models.Collection, error) {
var collections = make([]models.Collection, 0)
if err := m.q.GetCollectionsByHelpCenterAndLocale.Select(&collections, helpCenterID, locale); err != nil {
m.lo.Error("error fetching collections by locale", "error", err, "help_center_id", helpCenterID, "locale", locale)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "collections"), nil)
}
return collections, nil
}
// GetCollectionByID retrieves a collection by ID.
func (m *Manager) GetCollectionByID(id int) (models.Collection, error) {
var collection models.Collection
if err := m.q.GetCollectionByID.Get(&collection, id); err != nil {
m.lo.Error("error fetching collection", "error", err, "id", id)
return collection, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "collection"), nil)
}
return collection, nil
}
// CreateCollection creates a new collection.
func (m *Manager) CreateCollection(helpCenterID int, req CollectionCreateRequest) (models.Collection, error) {
// Validate depth if parent_id is provided
if req.ParentID != nil {
if err := m.validateCollectionDepth(*req.ParentID); err != nil {
return models.Collection{}, err
}
}
var collection models.Collection
if err := m.q.InsertCollection.Get(&collection, helpCenterID, req.Slug, req.ParentID, req.Locale, req.Name, req.Description, req.SortOrder, req.IsPublished); err != nil {
if dbutil.IsUniqueViolationError(err) {
return collection, envelope.NewError(envelope.ConflictError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", "collection slug"), nil)
}
m.lo.Error("error creating collection", "error", err)
return collection, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "collection"), nil)
}
return collection, nil
}
// UpdateCollection updates a collection.
func (m *Manager) UpdateCollection(id int, req CollectionUpdateRequest) (models.Collection, error) {
// Validate depth if parent_id is provided and changing
if req.ParentID != nil {
if err := m.validateCollectionDepth(*req.ParentID); err != nil {
return models.Collection{}, err
}
}
var collection models.Collection
if err := m.q.UpdateCollection.Get(&collection, id, req.Slug, req.ParentID, req.Locale, req.Name, req.Description, req.SortOrder, req.IsPublished); err != nil {
if dbutil.IsUniqueViolationError(err) {
return collection, envelope.NewError(envelope.ConflictError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", "collection slug"), nil)
}
m.lo.Error("error updating collection", "error", err, "id", id)
return collection, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "collection"), nil)
}
return collection, nil
}
// ToggleCollectionPublished toggles the published status of a collection.
func (m *Manager) ToggleCollectionPublished(id int) (models.Collection, error) {
var collection models.Collection
if err := m.q.ToggleCollectionPublished.Get(&collection, id); err != nil {
m.lo.Error("error toggling collection published status", "error", err, "id", id)
return collection, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "collection"), nil)
}
return collection, nil
}
// DeleteCollection deletes a collection by ID.
func (m *Manager) DeleteCollection(id int) error {
if _, err := m.q.DeleteCollection.Exec(id); err != nil {
m.lo.Error("error deleting collection", "error", err, "id", id)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "collection"), nil)
}
return nil
}
// ReorderCollections updates the sort order of multiple collections.
func (m *Manager) ReorderCollections(orders map[int]int) error {
tx, err := m.db.Begin()
if err != nil {
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "collections"), nil)
}
defer tx.Rollback()
for id, order := range orders {
if _, err := tx.Exec("UPDATE collections SET sort_order = $1, updated_at = NOW() WHERE id = $2", order, id); err != nil {
m.lo.Error("error updating collection sort order", "error", err, "id", id, "order", order)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "collections"), nil)
}
}
if err := tx.Commit(); err != nil {
m.lo.Error("error committing collection reorder transaction", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "collections"), nil)
}
return nil
}
// Articles
// GetArticlesByCollection retrieves all articles for a collection.
func (m *Manager) GetArticlesByCollection(collectionID int) ([]models.Article, error) {
var articles = make([]models.Article, 0)
if err := m.q.GetArticlesByCollection.Select(&articles, collectionID); err != nil {
m.lo.Error("error fetching articles", "error", err, "collection_id", collectionID)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "articles"), nil)
}
return articles, nil
}
// GetArticlesByCollectionAndLocale retrieves articles for a collection and locale.
func (m *Manager) GetArticlesByCollectionAndLocale(collectionID int, locale string) ([]models.Article, error) {
var articles = make([]models.Article, 0)
if err := m.q.GetArticlesByCollectionAndLocale.Select(&articles, collectionID, locale); err != nil {
m.lo.Error("error fetching articles by locale", "error", err, "collection_id", collectionID, "locale", locale)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "articles"), nil)
}
return articles, nil
}
// GetArticleByID retrieves an article by ID.
func (m *Manager) GetArticleByID(id int) (models.Article, error) {
var article models.Article
if err := m.q.GetArticleByID.Get(&article, id); err != nil {
m.lo.Error("error fetching article", "error", err, "id", id)
return article, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", "article"), nil)
}
return article, nil
}
// CreateArticle creates a new article.
func (m *Manager) CreateArticle(collectionID int, req ArticleCreateRequest) (models.Article, error) {
// Validate status
if !isValidArticleStatus(req.Status) {
return models.Article{}, envelope.NewError(envelope.InputError, "Invalid article status", nil)
}
var article models.Article
if err := m.q.InsertArticle.Get(&article, collectionID, req.Slug, req.Locale, req.Title, req.Content, req.SortOrder, req.Status, req.AIEnabled); err != nil {
if dbutil.IsUniqueViolationError(err) {
return article, envelope.NewError(envelope.ConflictError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", "article slug"), nil)
}
m.lo.Error("error creating article", "error", err)
return article, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "article"), nil)
}
// Generate and save embeddings in a goroutine only if AI enabled
if req.AIEnabled {
go m.generateAndSaveEmbedding(article.ID, req.Title, req.Content)
}
return article, nil
}
// UpdateArticle updates an article.
func (m *Manager) UpdateArticle(id int, req ArticleUpdateRequest) (models.Article, error) {
// Validate status
if !isValidArticleStatus(req.Status) {
return models.Article{}, envelope.NewError(envelope.InputError, "Invalid article status", nil)
}
var article models.Article
// Check if collection_id is being changed
if req.CollectionID != nil {
// If collection is being moved, use MoveArticle first
if err := m.q.MoveArticle.Get(&article, id, *req.CollectionID, req.SortOrder); err != nil {
m.lo.Error("error moving article to new collection", "error", err, "id", id, "collection_id", *req.CollectionID)
return article, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "article"), nil)
}
}
// Update the article with other fields
if err := m.q.UpdateArticle.Get(&article, id, req.Slug, req.Locale, req.Title, req.Content, req.SortOrder, req.Status, req.AIEnabled); err != nil {
if dbutil.IsUniqueViolationError(err) {
return article, envelope.NewError(envelope.ConflictError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", "article slug"), nil)
}
m.lo.Error("error updating article", "error", err, "id", id)
return article, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "article"), nil)
}
// Handle embeddings based on AI enabled status
if req.AIEnabled {
// Generate and save embeddings in a goroutine
go m.generateAndSaveEmbedding(article.ID, req.Title, req.Content)
} else {
// Remove embeddings if AI is disabled
go m.removeEmbeddings(article.ID)
}
return article, nil
}
// UpdateArticleStatus updates the status of an article.
func (m *Manager) UpdateArticleStatus(id int, status string) (models.Article, error) {
// Validate status
if !isValidArticleStatus(status) {
return models.Article{}, envelope.NewError(envelope.InputError, "Invalid article status", nil)
}
var article models.Article
if err := m.q.UpdateArticleStatus.Get(&article, id, status); err != nil {
m.lo.Error("error updating article status", "error", err, "id", id)
return article, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "article"), nil)
}
return article, nil
}
// DeleteArticle deletes an article by ID.
func (m *Manager) DeleteArticle(id int) error {
if _, err := m.q.DeleteArticle.Exec(id); err != nil {
m.lo.Error("error deleting article", "error", err, "id", id)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "article"), nil)
}
return nil
}
// ReorderArticles updates the sort order of multiple articles.
func (m *Manager) ReorderArticles(orders map[int]int) error {
tx, err := m.db.Begin()
if err != nil {
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "articles"), nil)
}
defer tx.Rollback()
for id, order := range orders {
if _, err := tx.Exec("UPDATE articles SET sort_order = $1, updated_at = NOW() WHERE id = $2", order, id); err != nil {
m.lo.Error("error updating article sort order", "error", err, "id", id, "order", order)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "articles"), nil)
}
}
if err := tx.Commit(); err != nil {
m.lo.Error("error committing article reorder transaction", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "articles"), nil)
}
return nil
}
// SearchArticlesByVector performs vector similarity search on articles.
func (m *Manager) SearchArticlesByVector(helpCenterID int, embedding []float32, locale string, limit int) ([]models.ArticleSearchResult, error) {
var results = make([]models.ArticleSearchResult, 0)
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
if err := m.q.SearchArticlesByVector.Select(&results, helpCenterID, vector, locale, limit); err != nil {
m.lo.Error("error searching articles by vector", "error", err, "help_center_id", helpCenterID)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "articles"), nil)
}
return results, nil
}
// SearchArticles performs semantic search on articles by converting query to embeddings.
func (m *Manager) SearchArticles(helpCenterID int, query string, locale string, limit int) ([]models.ArticleSearchResult, error) {
// Generate embeddings for the search query
embedding, err := m.ai.GetEmbeddings(query)
if err != nil {
m.lo.Error("error generating embeddings for search query", "error", err, "query", query)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "articles"), nil)
}
// Perform vector search
return m.SearchArticlesByVector(helpCenterID, embedding, locale, limit)
}
// UpdateArticleEmbedding updates the embedding for an article.
func (m *Manager) UpdateArticleEmbedding(id int, embedding []float32) error {
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
if _, err := m.q.UpdateArticleEmbedding.Exec(id, vector); err != nil {
m.lo.Error("error updating article embedding", "error", err, "id", id)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "article"), nil)
}
return nil
}
// GetHelpCenterTree returns the complete tree structure for a help center
func (m *Manager) GetHelpCenterTree(helpCenterID int, locale string) (models.TreeResponse, error) {
// Get the help center info first
helpCenter, err := m.GetHelpCenterByID(helpCenterID)
if err != nil {
return models.TreeResponse{}, err
}
// Get all tree data
rows, err := m.q.GetHelpCenterTreeData.Query(helpCenterID, locale)
if err != nil {
m.lo.Error("error fetching tree data", "error", err, "help_center_id", helpCenterID)
return models.TreeResponse{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "tree data"), nil)
}
defer rows.Close()
// Parse the combined data
collections := make(map[int]*models.TreeCollection)
var collectionOrder []int
for rows.Next() {
var (
itemType string
id int
createdAt time.Time
updatedAt time.Time
helpCenterID int
slug string
parentID *int
locale string
name string
description *string
sortOrder int
isPublished *bool
collectionID *int
title *string
content *string
status *string
viewCount *int
aiEnabled *bool
)
err := rows.Scan(&itemType, &id, &createdAt, &updatedAt, &helpCenterID, &slug, &parentID, &locale, &name, &description, &sortOrder, &isPublished, &collectionID, &title, &content, &status, &viewCount, &aiEnabled)
if err != nil {
m.lo.Error("error scanning tree data", "error", err)
return models.TreeResponse{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "tree data"), nil)
}
if itemType == "collection" {
collection := &models.TreeCollection{
Collection: models.Collection{
ID: id,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
HelpCenterID: helpCenterID,
Slug: slug,
ParentID: parentID,
Locale: locale,
Name: name,
Description: description,
SortOrder: sortOrder,
IsPublished: *isPublished,
},
Articles: make([]models.Article, 0),
Children: make([]models.TreeCollection, 0),
}
collections[id] = collection
collectionOrder = append(collectionOrder, id)
} else if itemType == "article" && collectionID != nil {
article := models.Article{
ID: id,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
CollectionID: *collectionID,
Slug: slug,
Locale: locale,
Title: *title,
Content: *content,
SortOrder: sortOrder,
Status: *status,
ViewCount: *viewCount,
AIEnabled: *aiEnabled,
}
if collection, exists := collections[*collectionID]; exists {
collection.Articles = append(collection.Articles, article)
}
}
}
// Build the tree structure
var buildTree func(parentID *int) []models.TreeCollection
buildTree = func(parentID *int) []models.TreeCollection {
children := make([]models.TreeCollection, 0)
for _, col := range collections {
if (col.ParentID == nil && parentID == nil) || (col.ParentID != nil && parentID != nil && *col.ParentID == *parentID) {
// Recursively build children
col.Children = buildTree(&col.ID)
children = append(children, *col)
}
}
return children
}
tree := buildTree(nil)
response := models.TreeResponse{
HelpCenter: helpCenter,
Tree: tree,
}
return response, nil
}
// SearchKnowledgeBase searches knowledge base (articles + knowledge sources) with fallback threshold
func (m *Manager) SearchKnowledgeBase(helpCenterID int, query string, locale string, threshold float64, limit int) ([]models.KnowledgeBaseResult, error) {
// Generate embeddings for the search query
embedding, err := m.ai.GetEmbeddings(query)
if err != nil {
m.lo.Error("error generating embeddings for knowledge base search", "error", err, "query", query)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "knowledge base"), nil)
}
var results = make([]models.KnowledgeBaseResult, 0)
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
if err := m.q.SearchKnowledgeBase.Select(&results, helpCenterID, vector, locale, threshold, limit); err != nil {
m.lo.Error("error searching knowledge base", "error", err, "query", query, "help_center_id", helpCenterID)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "knowledge base"), nil)
}
return results, nil
}
// Helper functions
// validateCollectionDepth checks if a parent collection would violate the 3-level depth limit.
func (m *Manager) validateCollectionDepth(parentID int) error {
// Traverse up the parent chain to determine depth
depth := 1
currentID := parentID
for currentID != 0 {
parent, err := m.GetCollectionByID(currentID)
if err != nil {
return err
}
if parent.ParentID == nil {
break
}
currentID = *parent.ParentID
depth++
if depth > 3 {
return envelope.NewError(envelope.InputError, "Collections can only be nested up to 3 levels deep", nil)
}
}
fmt.Printf("Collection depth is valid: %d\n", depth)
return nil
}
// isValidArticleStatus checks if the given status is valid.
func isValidArticleStatus(status string) bool {
return status == models.ArticleStatusDraft ||
status == models.ArticleStatusPublished
}
// generateAndSaveEmbedding generates embeddings for an article and saves them asynchronously.
func (m *Manager) generateAndSaveEmbedding(articleID int, title, content string) {
// Chunk HTML content into semantically meaningful pieces using configured parameters
chunks, err := stringutil.ChunkHTMLContent(title, content, m.ai.GetChunkConfig())
if err != nil {
m.lo.Error("error chunking HTML content", "error", err, "article_id", articleID)
return
}
// First, remove any existing embeddings for this article
if _, err := m.q.DeleteEmbeddingsBySource.Exec("help_article", articleID); err != nil {
m.lo.Error("error removing existing embeddings", "error", err, "article_id", articleID)
// Continue anyway to insert new embeddings
}
// Generate embeddings for each chunk
successfulChunks := 0
fmt.Printf("Found %d chunks to process\n", len(chunks))
for _, chunk := range chunks {
// TODO: Remove after debugging.
fmt.Println("Processing chunk")
fmt.Printf("Chunk -> %s\n", chunk.Text)
fmt.Println("Processing END =====================================================================================")
// Generate embeddings using AI store
embedding, err := m.ai.GetEmbeddings(chunk.Text)
if err != nil {
m.lo.Error("error generating chunk embedding", "error", err, "article_id", articleID, "chunk", chunk.ChunkIndex)
continue
}
// Prepare metadata for the chunk
metadata := map[string]any{
"chunk_index": chunk.ChunkIndex,
"total_chunks": chunk.TotalChunks,
"has_heading": chunk.HasHeading,
"has_code": chunk.HasCode,
"has_table": chunk.HasTable,
}
// Convert metadata to JSON
metadataJSON, err := json.Marshal(metadata)
if err != nil {
m.lo.Error("error marshaling chunk metadata", "error", err, "article_id", articleID, "chunk", chunk.ChunkIndex)
metadataJSON = []byte(`{}`)
}
// Convert []float32 to pgvector.Vector for PostgreSQL
vector := pgvector.NewVector(embedding)
// Insert chunk embedding record
if _, err := m.q.InsertEmbedding.Exec("help_article", articleID, chunk.Text, vector, string(metadataJSON)); err != nil {
m.lo.Error("error saving chunk embedding", "error", err, "article_id", articleID, "chunk", chunk.ChunkIndex)
continue
}
successfulChunks++
}
m.lo.Info("article chunked and embeddings generated", "article_id", articleID, "total_chunks", len(chunks), "successful_chunks", successfulChunks)
}
// removeEmbeddings removes embeddings for an article asynchronously.
func (m *Manager) removeEmbeddings(articleID int) {
if _, err := m.q.DeleteEmbeddingsBySource.Exec("help_article", articleID); err != nil {
m.lo.Error("error removing embeddings", "error", err, "article_id", articleID)
return
}
m.lo.Info("embeddings removed", "article_id", articleID)
}

View File

@@ -0,0 +1,83 @@
package models
import "time"
type HelpCenter struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Slug string `db:"slug" json:"slug"`
PageTitle string `db:"page_title" json:"page_title"`
ViewCount int `db:"view_count" json:"view_count"`
DefaultLocale string `db:"default_locale" json:"default_locale"`
}
type Collection struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
HelpCenterID int `db:"help_center_id" json:"help_center_id"`
Slug string `db:"slug" json:"slug"`
ParentID *int `db:"parent_id" json:"parent_id"`
Locale string `db:"locale" json:"locale"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsPublished bool `db:"is_published" json:"is_published"`
}
type Article struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
CollectionID int `db:"collection_id" json:"collection_id"`
Slug string `db:"slug" json:"slug"`
Locale string `db:"locale" json:"locale"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
SortOrder int `db:"sort_order" json:"sort_order"`
Status string `db:"status" json:"status"`
ViewCount int `db:"view_count" json:"view_count"`
AIEnabled bool `db:"ai_enabled" json:"ai_enabled"`
}
// ArticleStatus constants
const (
ArticleStatusDraft = "draft"
ArticleStatusPublished = "published"
)
// CollectionWithDepth is used for hierarchy validation
type CollectionWithDepth struct {
Collection
Depth int `json:"depth"`
}
// Tree structures for nested API response
type TreeCollection struct {
Collection
Articles []Article `json:"articles"`
Children []TreeCollection `json:"children"`
}
type TreeResponse struct {
HelpCenter HelpCenter `json:"help_center"`
Tree []TreeCollection `json:"tree"`
}
// ArticleSearchResult represents an article with similarity score for vector search
type ArticleSearchResult struct {
Article
Similarity float64 `db:"similarity" json:"similarity"`
}
// KnowledgeBaseResult represents a knowledge base search result
type KnowledgeBaseResult struct {
SourceType string `db:"source_type" json:"source_type"`
SourceID int `db:"source_id" json:"source_id"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
HelpCenterID *int `db:"help_center_id" json:"help_center_id"`
Similarity float64 `db:"similarity" json:"similarity"`
}

View File

@@ -0,0 +1,428 @@
-- Help Centers
-- name: get-all-help-centers
SELECT
id,
created_at,
updated_at,
name,
slug,
page_title,
view_count,
default_locale
FROM
help_centers
ORDER BY created_at DESC;
-- name: get-help-center-by-id
SELECT
id,
created_at,
updated_at,
name,
slug,
page_title,
view_count,
default_locale
FROM
help_centers
WHERE
id = $1;
-- name: get-help-center-by-slug
SELECT
id,
created_at,
updated_at,
name,
slug,
page_title,
view_count,
default_locale
FROM
help_centers
WHERE
slug = $1;
-- name: insert-help-center
INSERT INTO
help_centers (name, slug, page_title, default_locale)
VALUES
($1, $2, $3, $4)
RETURNING *;
-- name: update-help-center
UPDATE
help_centers
SET
name = $2,
slug = $3,
page_title = $4,
default_locale = $5,
updated_at = NOW()
WHERE
id = $1
RETURNING *;
-- name: delete-help-center
DELETE FROM
help_centers
WHERE
id = $1;
-- Collections
-- name: get-collections-by-help-center
SELECT
id,
created_at,
updated_at,
help_center_id,
slug,
parent_id,
locale,
name,
description,
sort_order,
is_published
FROM
article_collections
WHERE
help_center_id = $1
ORDER BY sort_order ASC, created_at DESC;
-- name: get-collections-by-help-center-and-locale
SELECT
id,
created_at,
updated_at,
help_center_id,
slug,
parent_id,
locale,
name,
description,
sort_order,
is_published
FROM
article_collections
WHERE
help_center_id = $1 AND locale = $2
ORDER BY sort_order ASC, created_at DESC;
-- name: get-collection-by-id
SELECT
id,
created_at,
updated_at,
help_center_id,
slug,
parent_id,
locale,
name,
description,
sort_order,
is_published
FROM
article_collections
WHERE
id = $1;
-- name: insert-collection
INSERT INTO
article_collections (help_center_id, slug, parent_id, locale, name, description, sort_order, is_published)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: update-collection
UPDATE
article_collections
SET
slug = $2,
parent_id = $3,
locale = $4,
name = $5,
description = $6,
sort_order = $7,
is_published = $8,
updated_at = NOW()
WHERE
id = $1
RETURNING *;
-- name: move-collection
UPDATE
article_collections
SET
parent_id = $2,
sort_order = $3,
updated_at = NOW()
WHERE
id = $1
RETURNING *;
-- name: toggle-collection-published
UPDATE
article_collections
SET
is_published = NOT is_published,
updated_at = NOW()
WHERE
id = $1
RETURNING *;
-- name: delete-collection
DELETE FROM
article_collections
WHERE
id = $1;
-- Articles
-- name: get-articles-by-collection
SELECT
id,
created_at,
updated_at,
collection_id,
slug,
locale,
title,
content,
sort_order,
status,
view_count,
ai_enabled
FROM
help_articles
WHERE
collection_id = $1
ORDER BY sort_order ASC, created_at DESC;
-- name: get-articles-by-collection-and-locale
SELECT
id,
created_at,
updated_at,
collection_id,
slug,
locale,
title,
content,
sort_order,
status,
view_count,
ai_enabled
FROM
help_articles
WHERE
collection_id = $1 AND locale = $2
ORDER BY sort_order ASC, created_at DESC;
-- name: get-article-by-id
SELECT
id,
created_at,
updated_at,
collection_id,
slug,
locale,
title,
content,
sort_order,
status,
view_count,
ai_enabled
FROM
help_articles
WHERE
id = $1;
-- name: insert-article
INSERT INTO
help_articles (collection_id, slug, locale, title, content, sort_order, status, ai_enabled)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: update-article
UPDATE
help_articles
SET
slug = $2,
locale = $3,
title = $4,
content = $5,
sort_order = $6,
status = $7,
ai_enabled = $8,
updated_at = NOW()
WHERE
id = $1
RETURNING *;
-- name: move-article
UPDATE
help_articles
SET
collection_id = $2,
sort_order = $3,
updated_at = NOW()
WHERE
id = $1
RETURNING *;
-- name: update-article-status
UPDATE
help_articles
SET
status = $2,
updated_at = NOW()
WHERE
id = $1
RETURNING *;
-- name: delete-article
DELETE FROM
help_articles
WHERE
id = $1;
-- Statistics and hierarchy queries
-- Get complete tree data for a help center
-- name: get-help-center-tree-data
SELECT
'collection' as type,
c.id,
c.created_at,
c.updated_at,
c.help_center_id,
c.slug,
c.parent_id,
c.locale,
c.name,
c.description,
c.sort_order,
c.is_published,
NULL::INTEGER as collection_id,
NULL::TEXT as title,
NULL::TEXT as content,
NULL::TEXT as status,
NULL::INTEGER as view_count,
NULL::BOOLEAN as ai_enabled
FROM article_collections c
WHERE c.help_center_id = $1
AND ($2 = '' OR c.locale = $2)
UNION ALL
SELECT
'article' as type,
a.id,
a.created_at,
a.updated_at,
c.help_center_id,
a.slug,
NULL::INTEGER as parent_id,
a.locale,
a.title as name,
NULL::TEXT as description,
a.sort_order,
NULL::BOOLEAN as is_published,
a.collection_id,
a.title,
a.content,
a.status,
a.view_count,
a.ai_enabled
FROM help_articles a
JOIN article_collections c ON a.collection_id = c.id
WHERE c.help_center_id = $1
AND ($2 = '' OR a.locale = $2)
ORDER BY help_center_id, type DESC, parent_id NULLS FIRST, sort_order, name;
-- AI and Embeddings queries
-- name: search-articles-by-vector
SELECT
a.id,
a.created_at,
a.updated_at,
a.collection_id,
a.slug,
a.locale,
a.title,
a.content,
a.sort_order,
a.status,
a.view_count,
a.ai_enabled,
1 - (e.embedding <=> $2::vector) AS similarity
FROM help_articles a
JOIN article_collections c ON a.collection_id = c.id
JOIN embeddings e ON e.source_type = 'help_article' AND e.source_id = a.id
WHERE c.help_center_id = $1
AND a.status = 'published'
AND a.ai_enabled = true
AND e.embedding IS NOT NULL
ORDER BY
(CASE WHEN a.locale = $3 THEN 0 ELSE 1 END),
e.embedding <=> $2::vector
LIMIT $4;
-- name: update-article-embedding
UPDATE embeddings
SET embedding = $2::vector, updated_at = NOW()
WHERE source_type = 'help_article' AND source_id = $1;
-- name: search-knowledge-base
SELECT
'help_article' as source_type,
a.id as source_id,
a.title as title,
e.chunk_text as content,
c.help_center_id,
1 - (e.embedding <=> $2) AS similarity
FROM help_articles a
JOIN article_collections c ON a.collection_id = c.id
JOIN embeddings e ON e.source_type = 'help_article' AND e.source_id = a.id
WHERE a.status = 'published'
AND a.ai_enabled = true
AND e.embedding IS NOT NULL
AND c.help_center_id = $1
AND a.locale = $3
AND (1 - (e.embedding <=> $2)) >= $4
ORDER BY similarity DESC
LIMIT $5;
-- Embeddings management
-- name: insert-embedding
INSERT INTO embeddings (source_type, source_id, chunk_text, embedding, meta)
VALUES ($1, $2, $3, $4::vector, $5)
RETURNING *;
-- name: delete-embeddings-by-source
DELETE FROM embeddings
WHERE source_type = $1 AND source_id = $2;
-- name: has-articles-in-language
SELECT COUNT(*) > 0
FROM help_articles a
JOIN article_collections c ON a.collection_id = c.id
WHERE c.help_center_id = $1
AND a.locale = $2
AND a.status = 'published'
AND a.ai_enabled = true;

View File

@@ -175,7 +175,7 @@ func (m *Manager) GetAll() ([]imodels.Inbox, error) {
// Create creates an inbox in the DB.
func (m *Manager) Create(inbox imodels.Inbox) (imodels.Inbox, error) {
var createdInbox imodels.Inbox
if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, inbox.Config, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Secret); err != nil {
if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, inbox.Config, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Secret, inbox.HelpCenterID); err != nil {
m.lo.Error("error creating inbox", "error", err)
return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.inbox}"), nil)
}
@@ -325,7 +325,7 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) {
// Update the inbox in the DB.
var updatedInbox imodels.Inbox
if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, inbox.Config, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Enabled, inbox.Secret); err != nil {
if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, inbox.Config, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Enabled, inbox.Secret, inbox.HelpCenterID); err != nil {
m.lo.Error("error updating inbox", "error", err)
return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.inbox}"), nil)
}

View File

@@ -11,16 +11,17 @@ import (
// Inbox represents a inbox record in DB.
type Inbox struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Channel string `db:"channel" json:"channel"`
Enabled bool `db:"enabled" json:"enabled"`
CSATEnabled bool `db:"csat_enabled" json:"csat_enabled"`
From string `db:"from" json:"from"`
Config json.RawMessage `db:"config" json:"config"`
Secret null.String `db:"secret" json:"secret,omitempty"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Channel string `db:"channel" json:"channel"`
Enabled bool `db:"enabled" json:"enabled"`
CSATEnabled bool `db:"csat_enabled" json:"csat_enabled"`
From string `db:"from" json:"from"`
Config json.RawMessage `db:"config" json:"config"`
Secret null.String `db:"secret" json:"secret"`
HelpCenterID null.Int `db:"help_center_id" json:"help_center_id"`
}
// ClearPasswords masks all config passwords

View File

@@ -6,8 +6,8 @@ SELECT id, created_at, updated_at, name, channel, enabled from inboxes where del
-- name: insert-inbox
INSERT INTO inboxes
(channel, config, "name", "from", csat_enabled, secret)
VALUES($1, $2, $3, $4, $5, $6)
(channel, config, "name", "from", csat_enabled, secret, help_center_id)
VALUES($1, $2, $3, $4, $5, $6, $7)
RETURNING *
-- name: get-inbox
@@ -15,7 +15,7 @@ SELECT * from inboxes where id = $1 and deleted_at is NULL;
-- name: update
UPDATE inboxes
set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, enabled = $7, secret = $8, updated_at = now()
set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, enabled = $7, secret = $8, help_center_id = $9, updated_at = now()
where id = $1 and deleted_at is NULL
RETURNING *;

View File

@@ -18,6 +18,10 @@ func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
// Enable pgvector extension
`CREATE EXTENSION IF NOT EXISTS vector`,
// Create AI knowledge type enum
`DROP TYPE IF EXISTS "ai_knowledge_type" CASCADE;
CREATE TYPE "ai_knowledge_type" AS ENUM ('snippet')`,
// Create help_centers table
`CREATE TABLE IF NOT EXISTS help_centers (
id SERIAL PRIMARY KEY,
@@ -26,7 +30,8 @@ func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
page_title VARCHAR(255) NOT NULL,
view_count INTEGER DEFAULT 0
view_count INTEGER DEFAULT 0,
default_locale VARCHAR(10) DEFAULT 'en' NOT NULL
)`,
// Create article_collections table
@@ -60,7 +65,7 @@ func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
view_count INTEGER DEFAULT 0,
ai_enabled BOOLEAN DEFAULT false
);
@@ -68,14 +73,13 @@ func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
ON help_articles(collection_id, slug, locale);
`,
// Create AI custom answers table
`CREATE TABLE IF NOT EXISTS ai_custom_answers (
// Create AI knowledge base table
`CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
question TEXT NOT NULL,
answer TEXT NOT NULL,
embedding vector(1536) NOT NULL,
type ai_knowledge_type NOT NULL DEFAULT 'snippet',
content TEXT NOT NULL,
enabled BOOLEAN DEFAULT true
)`,
@@ -105,11 +109,13 @@ func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
`CREATE INDEX IF NOT EXISTS index_articles_locale ON help_articles(collection_id, locale, status)`,
`CREATE INDEX IF NOT EXISTS index_articles_ordering ON help_articles(collection_id, sort_order)`,
// Create index for ai_knowledge_base
`CREATE INDEX IF NOT EXISTS index_ai_knowledge_base_type_enabled ON ai_knowledge_base(type, enabled)`,
// Create index for embeddings
`CREATE INDEX IF NOT EXISTS index_embeddings_on_source_type_source_id ON embeddings(source_type, source_id)`,
// Create HNSW indexes for vector similarity search
`CREATE INDEX IF NOT EXISTS index_ai_custom_answers_embedding ON ai_custom_answers USING hnsw (embedding vector_cosine_ops)`,
// Create HNSW index for vector similarity search on embeddings table
`CREATE INDEX IF NOT EXISTS index_embeddings_embedding ON embeddings USING hnsw (embedding vector_cosine_ops)`,
}
@@ -180,5 +186,13 @@ func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
return err
}
// Add 'help_center_id' column to inboxes table if it does not exist
_, err = tx.Exec(`
ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS help_center_id INT REFERENCES help_centers(id);
`)
if err != nil {
return err
}
return tx.Commit()
}

View File

@@ -0,0 +1,454 @@
package stringutil
import (
"fmt"
"regexp"
"slices"
"strings"
"unicode/utf8"
"github.com/zerodha/logf"
"golang.org/x/net/html"
)
type HTMLChunk struct {
Text string
OriginalHTML string
ChunkIndex int
TotalChunks int
HasHeading bool
HasCode bool
HasTable bool
Metadata map[string]any
}
type ChunkConfig struct {
MaxTokens int
MinTokens int
OverlapTokens int
TokenizerFunc func(string) int
PreserveBlocks []string
Logger *logf.Logger
}
type htmlBoundary struct {
Type string
Content string
Priority int
Tokens int
}
// DefaultChunkConfig returns a ChunkConfig with sensible default values for HTML chunking.
func DefaultChunkConfig() ChunkConfig {
return ChunkConfig{
MaxTokens: 2000,
MinTokens: 400,
OverlapTokens: 75,
TokenizerFunc: defaultTokenizer,
PreserveBlocks: []string{"pre", "code", "table"},
Logger: nil,
}
}
// validate checks that the ChunkConfig has valid settings and sets defaults for missing values.
func (c *ChunkConfig) validate() error {
if c.MaxTokens <= c.MinTokens {
return fmt.Errorf("MaxTokens must be greater than MinTokens")
}
if c.OverlapTokens >= c.MinTokens {
return fmt.Errorf("OverlapTokens must be less than MinTokens")
}
if c.TokenizerFunc == nil {
c.TokenizerFunc = defaultTokenizer
}
return nil
}
// defaultTokenizer estimates token count from text using a conservative rune-based approach.
// It works reliably across different AI providers and languages.
func defaultTokenizer(text string) int {
// Rune-based tokenizer for plain text (HTML already stripped by callers)
// Works reliably across all AI providers and languages
// Count Unicode runes (actual characters) not bytes
textRunes := utf8.RuneCountInString(text)
// Conservative ratio: ~0.4 tokens per rune (pessimistic across all providers)
return int(float64(textRunes) * 0.4)
}
var (
headingRegex = regexp.MustCompile(`(?i)<h[1-6][^>]*>`)
codeRegex = regexp.MustCompile(`(?i)<(pre|code)[^>]*>`)
tableRegex = regexp.MustCompile(`(?i)<table[^>]*>`)
sentenceRegex = regexp.MustCompile(`[.!?]+[\s]+`)
)
// ChunkHTMLContent splits HTML content into semantically meaningful chunks suitable for AI processing.
// It preserves HTML structure boundaries while respecting token limits and creating overlapping chunks for better context continuity.
func ChunkHTMLContent(title, htmlContent string, config ...ChunkConfig) ([]HTMLChunk, error) {
cfg := DefaultChunkConfig()
if len(config) > 0 {
cfg = config[0]
}
if err := cfg.validate(); err != nil {
return nil, err
}
if strings.TrimSpace(htmlContent) == "" {
return []HTMLChunk{{
Text: buildEmbeddingText(title, ""),
OriginalHTML: htmlContent,
ChunkIndex: 0,
TotalChunks: 1,
Metadata: map[string]any{"empty": true},
}}, nil
}
// First we create all HTML boundaries
boundaries, err := parseHTMLBoundaries(htmlContent, cfg)
if err != nil {
return nil, fmt.Errorf("failed to parse HTML: %w", err)
}
// Now create chunks from the boundaries while making sure to respect token limits, create overlapping chunks, all heading elements
chunks := createChunks(boundaries, cfg)
result := make([]HTMLChunk, len(chunks))
for i, chunk := range chunks {
cleanText := HTML2Text(chunk.Content)
result[i] = HTMLChunk{
Text: buildEmbeddingText(title, cleanText),
OriginalHTML: chunk.Content,
ChunkIndex: i,
TotalChunks: len(chunks),
HasHeading: headingRegex.MatchString(chunk.Content),
HasCode: codeRegex.MatchString(chunk.Content),
HasTable: tableRegex.MatchString(chunk.Content),
Metadata: map[string]any{
"tokens": chunk.Tokens,
"priority": chunk.Priority,
},
}
}
return result, nil
}
// isBlockElement determines if a tag represents a block-level element.
func isBlockElement(tag string) bool {
blockElements := map[string]bool{
// Headings
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
// Paragraphs and divisions
"p": true, "div": true, "section": true, "article": true, "aside": true,
"header": true, "footer": true, "main": true, "nav": true,
// Lists
"ul": true, "ol": true, "li": true, "dl": true, "dt": true, "dd": true,
// Tables
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true, "td": true, "th": true,
// Form elements
"form": true, "fieldset": true, "legend": true,
// Other block elements
"blockquote": true, "pre": true, "code": true, "figure": true, "figcaption": true,
"address": true, "details": true, "summary": true, "hr": true,
}
return blockElements[tag]
}
// parseHTMLBoundaries extracts block-level HTML elements and creates boundaries for chunking.
func parseHTMLBoundaries(htmlContent string, cfg ChunkConfig) ([]htmlBoundary, error) {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return nil, err
}
var boundaries []htmlBoundary
var extract func(*html.Node, int) int
extract = func(n *html.Node, startPos int) int {
if n.Type == html.ElementNode {
// Get tag name
tag := strings.ToLower(n.Data)
// Render the HTML element
var content strings.Builder
html.Render(&content, n)
contentStr := content.String()
// Skip empty elements
cleanText := HTML2Text(contentStr)
if strings.TrimSpace(cleanText) == "" {
return startPos
}
// Create boundary for ALL block-level elements, not just high-priority ones
if isBlockElement(tag) {
boundaries = append(boundaries, htmlBoundary{
Type: tag,
Content: contentStr,
Priority: getPriority(tag),
Tokens: cfg.TokenizerFunc(cleanText),
})
return startPos + len(contentStr)
}
}
// Recursively process children to find boundaries / block elements
for c := n.FirstChild; c != nil; c = c.NextSibling {
startPos = extract(c, startPos)
}
return startPos
}
extract(doc, 0)
// Merge adjacent text nodes and small boundaries
return mergeBoundaries(boundaries, cfg), nil
}
// getPriority assigns priority levels to HTML tags for chunking decisions.
// Lower numbers indicate higher priority.
func getPriority(tag string) int {
switch tag {
case "h1", "h2":
return 1
case "h3", "h4", "h5", "h6", "pre", "code":
return 2
case "p", "table", "ul", "ol", "blockquote":
return 3
case "div", "section", "article", "figure":
return 4
default:
return 5
}
}
// isPreservedBlock checks if a block type should be preserved based on the configuration.
func isPreservedBlock(blockType string, preserveBlocks []string) bool {
return slices.Contains(preserveBlocks, blockType)
}
// mergeBoundaries combines adjacent HTML boundaries based on priority and token limits.
func mergeBoundaries(boundaries []htmlBoundary, cfg ChunkConfig) []htmlBoundary {
if len(boundaries) == 0 {
return boundaries
}
var merged []htmlBoundary
current := boundaries[0]
for i := 1; i < len(boundaries); i++ {
next := boundaries[i]
// Don't merge across high-priority boundaries (h1, h2)
if next.Priority == 1 {
merged = append(merged, current)
current = next
continue
}
// Don't merge if current is h1/h2 and has sufficient content
if current.Priority == 1 && current.Tokens >= cfg.MinTokens {
merged = append(merged, current)
current = next
continue
}
// For preserved blocks, don't merge with other content
if isPreservedBlock(current.Type, cfg.PreserveBlocks) || isPreservedBlock(next.Type, cfg.PreserveBlocks) {
merged = append(merged, current)
current = next
continue
}
// Merge adjacent boundaries if:
// 1. Combined tokens are under MinTokens, OR
// 2. Both are low-priority (p, div, li) and combined tokens < maxTokens
combinedTokens := current.Tokens + next.Tokens
shouldMerge := false
if combinedTokens < cfg.MinTokens {
shouldMerge = true
} else if current.Priority >= 3 && next.Priority >= 3 && combinedTokens < cfg.MaxTokens {
// Merge small adjacent low-priority elements (paragraphs, divs, etc.)
shouldMerge = true
}
if shouldMerge {
current.Content += next.Content
current.Tokens = combinedTokens
// Keep the higher priority (lower number)
current.Priority = min(current.Priority, next.Priority)
} else {
merged = append(merged, current)
current = next
}
}
merged = append(merged, current)
return merged
}
// truncateOversizedContent simply truncates content that exceeds max tokens.
// Logs a warning for admins to fix the content at the source.
func truncateOversizedContent(boundary htmlBoundary, cfg ChunkConfig) htmlBoundary {
text := HTML2Text(boundary.Content)
if cfg.TokenizerFunc(text) <= cfg.MaxTokens {
return boundary
}
// Cut text to fit max tokens
runes := []rune(text)
for i := 1; i <= len(runes); i++ {
truncated := string(runes[:len(runes)-i])
if cfg.TokenizerFunc(truncated) <= cfg.MaxTokens {
if cfg.Logger != nil {
cfg.Logger.Warn("Content truncated: exceeded max_tokens",
"type", boundary.Type,
"original_tokens", boundary.Tokens,
"truncated_tokens", cfg.TokenizerFunc(truncated))
}
boundary.Content = truncated
boundary.Tokens = cfg.TokenizerFunc(truncated)
return boundary
}
}
// If we can't truncate to fit, return empty
if cfg.Logger != nil {
cfg.Logger.Error("Content completely emptied: could not truncate to fit max_tokens",
"type", boundary.Type,
"original_tokens", boundary.Tokens,
"max_tokens", cfg.MaxTokens)
}
boundary.Content = ""
boundary.Tokens = 0
return boundary
}
// createChunks groups HTML boundaries into final chunks respecting token limits and overlap.
func createChunks(boundaries []htmlBoundary, cfg ChunkConfig) []htmlBoundary {
if len(boundaries) == 0 {
return boundaries
}
var chunks []htmlBoundary
var currentChunk htmlBoundary
currentChunk.Priority = 10 // Start with lowest priority
for _, boundary := range boundaries {
// Check if we should start a new chunk
shouldStartNewChunk := false
// Always start new chunk on h1/h2 headings (high priority) if current chunk has sufficient content
if boundary.Priority == 1 && currentChunk.Tokens >= cfg.MinTokens {
shouldStartNewChunk = true
}
// Check if adding this boundary would exceed MaxTokens
if currentChunk.Tokens+boundary.Tokens > cfg.MaxTokens {
// Only create a chunk if we have some content
if currentChunk.Content != "" {
shouldStartNewChunk = true
}
}
// If we need to start a new chunk, finalize the current one
if shouldStartNewChunk && currentChunk.Content != "" {
chunks = append(chunks, currentChunk)
// Start new chunk with overlap if appropriate
var overlapContent string
if !isPreservedBlock(boundary.Type, cfg.PreserveBlocks) && len(chunks) > 0 {
overlapContent = extractOverlap(currentChunk.Content, cfg)
}
currentChunk = htmlBoundary{
Content: overlapContent,
Tokens: cfg.TokenizerFunc(HTML2Text(overlapContent)),
Priority: 10, // Reset to lowest priority
}
}
// Add current boundary to the chunk
currentChunk.Content += boundary.Content
currentChunk.Tokens += boundary.Tokens
// Update chunk priority to highest priority element it contains
if boundary.Priority < currentChunk.Priority {
currentChunk.Priority = boundary.Priority
}
// Handle case where single boundary exceeds MaxTokens
if currentChunk.Tokens > cfg.MaxTokens && currentChunk.Content == boundary.Content {
// Single boundary is too large, truncate it
truncatedBoundary := truncateOversizedContent(boundary, cfg)
chunks = append(chunks, truncatedBoundary)
currentChunk = htmlBoundary{Priority: 10}
}
}
// Add the final chunk if it has content
if currentChunk.Content != "" {
chunks = append(chunks, currentChunk)
}
return chunks
}
// extractOverlap creates overlapping content from the end of a chunk for context continuity.
func extractOverlap(content string, cfg ChunkConfig) string {
cleanText := HTML2Text(content)
sentences := sentenceRegex.Split(cleanText, -1)
if len(sentences) <= 1 {
return ""
}
// Take last N sentences that fit in overlap tokens
var overlap []string
tokens := 0
for i := len(sentences) - 1; i >= 0 && tokens < cfg.OverlapTokens; i-- {
sentence := strings.TrimSpace(sentences[i])
if sentence == "" {
continue
}
sentTokens := cfg.TokenizerFunc(sentence)
if tokens+sentTokens <= cfg.OverlapTokens {
overlap = append([]string{sentence}, overlap...)
tokens += sentTokens
} else {
break
}
}
if len(overlap) == 0 {
return ""
}
return "<p>" + strings.Join(overlap, ". ") + ".</p>\n"
}
// buildEmbeddingText formats title and content text for AI embedding processing.
// It creates a structured format that helps AI models understand the semantic
// relationship between title and content. When title is empty, only the content
// is returned to avoid redundant "Title: " prefixes in embeddings.
func buildEmbeddingText(title, cleanText string) string {
title = strings.TrimSpace(title)
cleanText = strings.TrimSpace(cleanText)
// If no title is provided, return content as-is to avoid empty "Title: " prefixes
if title == "" {
return cleanText
}
// If no content is provided, return only the title
if cleanText == "" {
return title
}
// Both title and content are present, use structured format
return fmt.Sprintf("Title: %s\nContent: %s", title, cleanText)
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
package stringutil
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
@@ -14,6 +15,9 @@ import (
"time"
"github.com/k3a/html2text"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
const (
@@ -301,3 +305,44 @@ func ComputeRecipients(
return
}
// MarkdownToHTML converts markdown content to HTML using goldmark.
func MarkdownToHTML(markdown string) (string, error) {
// Create goldmark parser with safe configuration
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
)
var buf bytes.Buffer
if err := md.Convert([]byte(markdown), &buf); err != nil {
return "", fmt.Errorf("error converting markdown to HTML: %w", err)
}
return buf.String(), nil
}
// CleanJSONResponse removes markdown code blocks from LLM responses.
// This handles cases where LLMs wrap JSON in ```json ... ``` blocks despite explicit instructions.
func CleanJSONResponse(response string) string {
response = strings.TrimSpace(response)
// Handle ```json ... ``` blocks
if strings.HasPrefix(response, "```json") && strings.HasSuffix(response, "```") {
cleaned := strings.TrimPrefix(response, "```json")
cleaned = strings.TrimSuffix(cleaned, "```")
return strings.TrimSpace(cleaned)
}
// Handle generic ``` ... ``` blocks
if strings.HasPrefix(response, "```") && strings.HasSuffix(response, "```") {
cleaned := strings.TrimPrefix(response, "```")
cleaned = strings.TrimSuffix(cleaned, "```")
return strings.TrimSpace(cleaned)
}
return response
}

View File

@@ -183,16 +183,6 @@ func TestGenerateSlug(t *testing.T) {
input: "Hello---World",
expected: "hello-world",
},
{
name: "empty string",
input: "",
expected: "untitled",
},
{
name: "only special characters",
input: "!@#$%^&*()",
expected: "untitled",
},
{
name: "unicode characters",
input: "Hello World",

View File

@@ -10,6 +10,7 @@ DROP TYPE IF EXISTS "conversation_assignment_type" CASCADE; CREATE TYPE "convers
DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM ('email_outgoing', 'email_notification');
DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact', 'visitor', 'ai_assistant');
DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai');
DROP TYPE IF EXISTS "ai_knowledge_type" CASCADE; CREATE TYPE "ai_knowledge_type" AS ENUM ('snippet');
DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match');
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
@@ -84,6 +85,7 @@ CREATE TABLE inboxes (
config jsonb DEFAULT '{}'::jsonb NOT NULL,
"from" TEXT NULL,
secret TEXT NULL,
help_center_id INT REFERENCES help_centers(id),
CONSTRAINT constraint_inboxes_on_name CHECK (length("name") <= 140)
);
@@ -608,7 +610,8 @@ CREATE TABLE help_centers (
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
page_title VARCHAR(255) NOT NULL,
view_count INTEGER DEFAULT 0
view_count INT DEFAULT 0,
default_locale VARCHAR(10) DEFAULT 'en' NOT NULL
);
CREATE INDEX index_help_centers_on_slug ON help_centers(slug);
@@ -617,13 +620,13 @@ CREATE TABLE article_collections (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
help_center_id INTEGER NOT NULL REFERENCES help_centers(id) ON DELETE CASCADE,
help_center_id INT NOT NULL REFERENCES help_centers(id) ON DELETE CASCADE,
slug VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES article_collections(id) ON DELETE CASCADE,
parent_id INT REFERENCES article_collections(id) ON DELETE CASCADE,
locale VARCHAR(10) NOT NULL DEFAULT 'en',
name VARCHAR(255) NOT NULL,
description TEXT,
sort_order INTEGER DEFAULT 0,
sort_order INT DEFAULT 0,
is_published BOOLEAN DEFAULT false
);
CREATE INDEX index_article_collections_on_help_center_id ON article_collections(help_center_id);
@@ -637,14 +640,14 @@ CREATE TABLE help_articles (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
collection_id INTEGER NOT NULL REFERENCES article_collections(id) ON DELETE CASCADE,
collection_id INT NOT NULL REFERENCES article_collections(id) ON DELETE CASCADE,
slug VARCHAR(255) NOT NULL,
locale VARCHAR(10) NOT NULL DEFAULT 'en',
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
sort_order INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
view_count INTEGER DEFAULT 0,
view_count INT DEFAULT 0,
ai_enabled BOOLEAN DEFAULT false
);
CREATE INDEX index_help_articles_on_collection_id ON help_articles(collection_id);
@@ -652,17 +655,16 @@ CREATE INDEX index_help_articles_on_locale ON help_articles(collection_id, local
CREATE INDEX index_help_articles_on_ordering ON help_articles(collection_id, sort_order);
CREATE UNIQUE INDEX index_help_articles_slug_per_collection_locale ON help_articles(collection_id, slug, locale);
DROP TABLE IF EXISTS ai_custom_answers CASCADE;
CREATE TABLE ai_custom_answers (
DROP TABLE IF EXISTS ai_knowledge_base CASCADE;
CREATE TABLE ai_knowledge_base (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
question TEXT NOT NULL,
answer TEXT NOT NULL,
embedding vector(1536) NOT NULL,
type ai_knowledge_type NOT NULL DEFAULT 'snippet',
content TEXT NOT NULL,
enabled BOOLEAN DEFAULT true
);
CREATE INDEX index_ai_custom_answers_embedding ON ai_custom_answers USING hnsw (embedding vector_cosine_ops);
CREATE INDEX index_ai_knowledge_base_type_enabled ON ai_knowledge_base(type, enabled);
DROP TABLE IF EXISTS embeddings CASCADE;
CREATE TABLE embeddings (
@@ -693,6 +695,7 @@ BEGIN
END;
$$;
-- Trigger to enforce collection depth limit in article_collections.
DROP TRIGGER IF EXISTS trg_enforce_collection_depth_limit ON article_collections;
CREATE TRIGGER trg_enforce_collection_depth_limit
BEFORE INSERT OR UPDATE ON article_collections
FOR EACH ROW EXECUTE FUNCTION enforce_collection_max_depth();