mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 04:53:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
548
cmd/helpcenter.go
Normal 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
|
||||
}
|
||||
10
cmd/init.go
10
cmd/init.go
@@ -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,
|
||||
|
||||
@@ -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
108
cmd/snippets.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
287
frontend/apps/main/src/features/admin/help-center/TreeNode.vue
Normal file
287
frontend/apps/main/src/features/admin/help-center/TreeNode.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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')),
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
108
frontend/apps/main/src/features/admin/snippets/SnippetForm.vue
Normal file
108
frontend/apps/main/src/features/admin/snippets/SnippetForm.vue
Normal 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>
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
24
frontend/apps/main/src/features/admin/snippets/formSchema.js
Normal file
24
frontend/apps/main/src/features/admin/snippets/formSchema.js
Normal 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),
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, {
|
||||
35
frontend/apps/main/src/views/admin/snippets/Snippets.vue
Normal file
35
frontend/apps/main/src/views/admin/snippets/Snippets.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
58
frontend/shared-ui/components/ui/menu-card/MenuCard.vue
Normal file
58
frontend/shared-ui/components/ui/menu-card/MenuCard.vue
Normal 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>
|
||||
1
frontend/shared-ui/constants/index.js
Normal file
1
frontend/shared-ui/constants/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './languages.js'
|
||||
93
frontend/shared-ui/constants/languages.js
Normal file
93
frontend/shared-ui/constants/languages.js
Normal 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)
|
||||
}
|
||||
@@ -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
7
go.mod
@@ -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
14
go.sum
@@ -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=
|
||||
|
||||
40
i18n/en.json
40
i18n/en.json
@@ -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.",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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;
|
||||
|
||||
112
internal/ai/system_prompts.go
Normal file
112
internal/ai/system_prompts.go
Normal 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."
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
770
internal/helpcenter/helpcenter.go
Normal file
770
internal/helpcenter/helpcenter.go
Normal 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)
|
||||
}
|
||||
83
internal/helpcenter/models/models.go
Normal file
83
internal/helpcenter/models/models.go
Normal 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"`
|
||||
}
|
||||
428
internal/helpcenter/queries.sql
Normal file
428
internal/helpcenter/queries.sql
Normal 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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
454
internal/stringutil/htmlchunker.go
Normal file
454
internal/stringutil/htmlchunker.go
Normal 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)
|
||||
}
|
||||
1566
internal/stringutil/htmlchunker_test.go
Normal file
1566
internal/stringutil/htmlchunker_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
29
schema.sql
29
schema.sql
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user