refactor: support i18n
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/ginx"
|
||||
"cs-agent/internal/pkg/httpx"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/services"
|
||||
webspa "cs-agent/web"
|
||||
|
||||
@@ -32,6 +33,7 @@ func NewServer() (*gin.Engine, error) {
|
||||
app.Use(gin.Recovery())
|
||||
app.Use(requestLogMiddleware())
|
||||
app.Use(maxBodySizeMiddleware(cfg.Storage.MaxRequestBodySizeBytes()))
|
||||
app.Use(i18nx.Middleware())
|
||||
|
||||
addRouter(app)
|
||||
|
||||
@@ -51,7 +53,7 @@ func NewServer() (*gin.Engine, error) {
|
||||
},
|
||||
NotFoundPrefixes: notFoundPrefixes,
|
||||
NotFoundHandler: func(ctx *gin.Context) {
|
||||
httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, "Not found"))
|
||||
httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, i18nx.T(ctx, "error.notFound", nil)))
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/services"
|
||||
|
||||
@@ -13,6 +14,10 @@ import (
|
||||
)
|
||||
|
||||
func BuildConversation(item *models.Conversation) response.ConversationResponse {
|
||||
return BuildConversationWithLocale(item, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func BuildConversationWithLocale(item *models.Conversation, locale string) response.ConversationResponse {
|
||||
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ID)
|
||||
ret := response.ConversationResponse{
|
||||
ID: item.ID,
|
||||
@@ -28,7 +33,7 @@ func BuildConversation(item *models.Conversation) response.ConversationResponse
|
||||
LastMessageID: item.LastMessageID,
|
||||
LastMessageAt: utils.FormatTime(item.LastMessageAt),
|
||||
LastActiveAt: utils.FormatTime(item.LastActiveAt),
|
||||
LastMessageSummary: item.LastMessageSummary,
|
||||
LastMessageSummary: localizeConversationSummary(locale, item.LastMessageSummary),
|
||||
CustomerUnreadCount: item.CustomerUnreadCount,
|
||||
AgentUnreadCount: item.AgentUnreadCount,
|
||||
CustomerLastReadMessageID: readStateMessageID(customerReadState),
|
||||
@@ -68,6 +73,26 @@ func BuildConversation(item *models.Conversation) response.ConversationResponse
|
||||
return ret
|
||||
}
|
||||
|
||||
func localizeConversationSummary(locale string, summary string) string {
|
||||
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
|
||||
return summary
|
||||
}
|
||||
switch {
|
||||
case summary == "[图片]":
|
||||
return "[Image]"
|
||||
case strings.HasPrefix(summary, "[图片] "):
|
||||
return "[Image] " + strings.TrimPrefix(summary, "[图片] ")
|
||||
case summary == "[附件]":
|
||||
return "[Attachment]"
|
||||
case strings.HasPrefix(summary, "[附件] "):
|
||||
return "[Attachment] " + strings.TrimPrefix(summary, "[附件] ")
|
||||
case summary == "该消息已撤回":
|
||||
return "This message was recalled."
|
||||
default:
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
func BuildParticipantResponses(conversationID int64) []response.ConversationParticipantResponse {
|
||||
list := services.ConversationParticipantService.Find(sqls.NewCnd().Eq("conversation_id", conversationID).Asc("id"))
|
||||
if len(list) == 0 {
|
||||
@@ -89,6 +114,10 @@ func BuildParticipantResponses(conversationID int64) []response.ConversationPart
|
||||
}
|
||||
|
||||
func BuildMessages(list []models.Message) []response.MessageResponse {
|
||||
return BuildMessagesWithLocale(list, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func BuildMessagesWithLocale(list []models.Message, locale string) []response.MessageResponse {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -97,17 +126,25 @@ func BuildMessages(list []models.Message) []response.MessageResponse {
|
||||
agentProfiles := collectAgentProfilesByMessages(list)
|
||||
ret := make([]response.MessageResponse, 0, len(list))
|
||||
for i := range list {
|
||||
ret = append(ret, BuildMessageWithReadStates(&list[i], agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles))
|
||||
ret = append(ret, BuildMessageWithReadStatesAndLocale(&list[i], agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles, locale))
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func BuildMessage(item *models.Message) response.MessageResponse {
|
||||
return BuildMessageWithLocale(item, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func BuildMessageWithLocale(item *models.Message, locale string) response.MessageResponse {
|
||||
agentReadState, customerReadState := services.ConversationReadStateService.GetConversationReadStates(item.ConversationID)
|
||||
return BuildMessageWithReadStates(item, agentReadState, customerReadState, nil, nil, nil)
|
||||
return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, nil, nil, nil, locale)
|
||||
}
|
||||
|
||||
func BuildMessageWithReadStates(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile) response.MessageResponse {
|
||||
return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile, locale string) response.MessageResponse {
|
||||
content, payload := utils.BuildRenderableMessage(item)
|
||||
ret := response.MessageResponse{
|
||||
ID: item.ID,
|
||||
@@ -116,7 +153,7 @@ func BuildMessageWithReadStates(item *models.Message, agentReadState, customerRe
|
||||
SenderType: item.SenderType,
|
||||
SenderID: item.SenderID,
|
||||
MessageType: item.MessageType,
|
||||
Content: content,
|
||||
Content: localizeRenderableMessageContent(locale, content),
|
||||
Payload: payload,
|
||||
SeqNo: item.SeqNo,
|
||||
SendStatus: item.SendStatus,
|
||||
@@ -169,6 +206,16 @@ func BuildMessageWithReadStates(item *models.Message, agentReadState, customerRe
|
||||
return ret
|
||||
}
|
||||
|
||||
func localizeRenderableMessageContent(locale string, content string) string {
|
||||
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
|
||||
return content
|
||||
}
|
||||
if content == "该消息已撤回" {
|
||||
return "This message was recalled."
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func collectAgentProfilesByMessages(list []models.Message) map[int64]*models.AgentProfile {
|
||||
var agentUserIDs []int64
|
||||
seen := make(map[int64]struct{})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
func TestLocalizeConversationSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
locale string
|
||||
summary string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "image summary in english",
|
||||
locale: i18nx.LocaleEnUS,
|
||||
summary: "[图片]",
|
||||
want: "[Image]",
|
||||
},
|
||||
{
|
||||
name: "attachment summary in english",
|
||||
locale: i18nx.LocaleEnUS,
|
||||
summary: "[附件] spec.pdf",
|
||||
want: "[Attachment] spec.pdf",
|
||||
},
|
||||
{
|
||||
name: "recalled message in english",
|
||||
locale: i18nx.LocaleEnUS,
|
||||
summary: "该消息已撤回",
|
||||
want: "This message was recalled.",
|
||||
},
|
||||
{
|
||||
name: "business text is not translated",
|
||||
locale: i18nx.LocaleEnUS,
|
||||
summary: "客户反馈无法登录",
|
||||
want: "客户反馈无法登录",
|
||||
},
|
||||
{
|
||||
name: "chinese locale keeps existing summary",
|
||||
locale: i18nx.LocaleZhCN,
|
||||
summary: "[图片]",
|
||||
want: "[图片]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := localizeConversationSummary(tt.locale, tt.summary); got != tt.want {
|
||||
t.Fatalf("localizeConversationSummary() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizeRenderableMessageContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
locale string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "recalled message in english",
|
||||
locale: i18nx.LocaleEnUS,
|
||||
content: "该消息已撤回",
|
||||
want: "This message was recalled.",
|
||||
},
|
||||
{
|
||||
name: "normal customer message is not translated",
|
||||
locale: i18nx.LocaleEnUS,
|
||||
content: "客户反馈无法登录",
|
||||
want: "客户反馈无法登录",
|
||||
},
|
||||
{
|
||||
name: "chinese locale keeps content",
|
||||
locale: i18nx.LocaleZhCN,
|
||||
content: "该消息已撤回",
|
||||
want: "该消息已撤回",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := localizeRenderableMessageContent(tt.locale, tt.content); got != tt.want {
|
||||
t.Fatalf("localizeRenderableMessageContent() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,32 @@ package builders
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ticketAssignedNotificationPattern = regexp.MustCompile(`^工单 (.+) 已指派给你$`)
|
||||
conversationAssignedNotificationPattern = regexp.MustCompile(`^会话 #([0-9]+) 已分配给你$`)
|
||||
)
|
||||
|
||||
func BuildNotification(item *models.Notification) *response.NotificationResponse {
|
||||
return BuildNotificationWithLocale(item, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func BuildNotificationWithLocale(item *models.Notification, locale string) *response.NotificationResponse {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
title, content := localizeNotificationText(item, locale)
|
||||
return &response.NotificationResponse{
|
||||
ID: item.ID,
|
||||
RecipientUserID: item.RecipientUserID,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
Title: title,
|
||||
Content: content,
|
||||
NotificationType: item.NotificationType,
|
||||
BizType: item.BizType,
|
||||
BizID: item.BizID,
|
||||
@@ -25,11 +39,90 @@ func BuildNotification(item *models.Notification) *response.NotificationResponse
|
||||
}
|
||||
|
||||
func BuildNotificationList(list []models.Notification) []response.NotificationResponse {
|
||||
return BuildNotificationListWithLocale(list, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func BuildNotificationListWithLocale(list []models.Notification, locale string) []response.NotificationResponse {
|
||||
results := make([]response.NotificationResponse, 0, len(list))
|
||||
for i := range list {
|
||||
if item := BuildNotification(&list[i]); item != nil {
|
||||
if item := BuildNotificationWithLocale(&list[i], locale); item != nil {
|
||||
results = append(results, *item)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func localizeNotificationText(item *models.Notification, locale string) (string, string) {
|
||||
title := item.Title
|
||||
content := item.Content
|
||||
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
|
||||
return title, content
|
||||
}
|
||||
switch strings.TrimSpace(item.NotificationType) {
|
||||
case "ticket_assigned":
|
||||
return localizeTicketAssignedNotification(title, content)
|
||||
case "conversation_assigned":
|
||||
return localizeConversationAssignedNotification(title, content)
|
||||
default:
|
||||
return title, content
|
||||
}
|
||||
}
|
||||
|
||||
func localizeTicketAssignedNotification(title string, content string) (string, string) {
|
||||
lines := splitNotificationLines(content)
|
||||
if len(lines) == 0 {
|
||||
return localizeNotificationTitle(title), content
|
||||
}
|
||||
if matches := ticketAssignedNotificationPattern.FindStringSubmatch(lines[0]); len(matches) == 2 {
|
||||
lines[0] = fmt.Sprintf("Ticket %s has been assigned to you.", matches[1])
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
if reason, ok := strings.CutPrefix(line, "指派原因: "); ok {
|
||||
lines[i+1] = "Assignment reason: " + reason
|
||||
}
|
||||
}
|
||||
return localizeNotificationTitle(title), strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func localizeConversationAssignedNotification(title string, content string) (string, string) {
|
||||
lines := splitNotificationLines(content)
|
||||
if len(lines) == 0 {
|
||||
return localizeNotificationTitle(title), content
|
||||
}
|
||||
if matches := conversationAssignedNotificationPattern.FindStringSubmatch(lines[0]); len(matches) == 2 {
|
||||
lines[0] = fmt.Sprintf("Conversation #%s has been assigned to you.", matches[1])
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
if reason, ok := strings.CutPrefix(line, "分配原因: "); ok {
|
||||
lines[i+1] = "Assignment reason: " + reason
|
||||
continue
|
||||
}
|
||||
if reason, ok := strings.CutPrefix(line, "转接原因: "); ok {
|
||||
lines[i+1] = "Transfer reason: " + reason
|
||||
}
|
||||
}
|
||||
return localizeNotificationTitle(title), strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func localizeNotificationTitle(title string) string {
|
||||
switch strings.TrimSpace(title) {
|
||||
case "工单指派提醒":
|
||||
return "Ticket assigned"
|
||||
case "会话转接提醒":
|
||||
return "Conversation transferred"
|
||||
case "会话自动分配提醒":
|
||||
return "Conversation auto-assigned"
|
||||
case "会话分配提醒":
|
||||
return "Conversation assigned"
|
||||
default:
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
func splitNotificationLines(content string) []string {
|
||||
normalized := strings.TrimSpace(content)
|
||||
if normalized == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(normalized, "\n")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
|
||||
@@ -16,3 +17,22 @@ func TestBuildNotificationListReturnsEmptySlice(t *testing.T) {
|
||||
t.Fatalf("expected empty slice, got %d items", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNotificationLocalizesKnownSystemNotification(t *testing.T) {
|
||||
result := BuildNotificationWithLocale(&models.Notification{
|
||||
Title: "工单指派提醒",
|
||||
Content: "工单 TK-100 已指派给你\n无法登录后台\n指派原因: 优先处理",
|
||||
NotificationType: "ticket_assigned",
|
||||
}, i18nx.LocaleEnUS)
|
||||
|
||||
if result == nil {
|
||||
t.Fatalf("expected notification response")
|
||||
}
|
||||
if result.Title != "Ticket assigned" {
|
||||
t.Fatalf("title = %q", result.Title)
|
||||
}
|
||||
wantContent := "Ticket TK-100 has been assigned to you.\n无法登录后台\nAssignment reason: 优先处理"
|
||||
if result.Content != wantContent {
|
||||
t.Fatalf("content = %q, want %q", result.Content, wantContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/httpx"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/services"
|
||||
|
||||
"cs-agent/internal/pkg/httpx/params"
|
||||
@@ -39,7 +40,7 @@ func ConversationGetBy(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
detail := response.ConversationDetailResponse{
|
||||
ConversationResponse: builders.BuildConversation(item),
|
||||
ConversationResponse: builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)),
|
||||
Participants: builders.BuildParticipantResponses(id),
|
||||
}
|
||||
httpx.WriteJSON(ctx, detail)
|
||||
@@ -62,7 +63,7 @@ func ConversationPostCreate_or_match(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildConversation(item))
|
||||
httpx.WriteJSON(ctx, builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)))
|
||||
}
|
||||
|
||||
func ConversationPostClose(ctx *gin.Context) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/httpx"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/services"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -51,7 +52,7 @@ func MessageAnyList(ctx *gin.Context) {
|
||||
list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor(
|
||||
conversationID, cursor, limit, senderType, messageType,
|
||||
)
|
||||
results := builders.BuildMessages(list)
|
||||
results := builders.BuildMessagesWithLocale(list, i18nx.Locale(ctx))
|
||||
httpx.WriteJSON(ctx, httpx.CursorData(results, cast.ToString(nextCursor), hasMore))
|
||||
}
|
||||
|
||||
@@ -77,7 +78,7 @@ func MessagePostSend(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildMessage(item))
|
||||
httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
|
||||
}
|
||||
|
||||
func MessagePostRead(ctx *gin.Context) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/pkg/toolx"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/services"
|
||||
@@ -34,7 +35,7 @@ func AIAgentAnyList(ctx *gin.Context) {
|
||||
list, paging := services.AIAgentService.FindPageByCnd(cnd)
|
||||
results := make([]response.AIAgentResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
results = append(results, buildAIAgentResponse(&item))
|
||||
results = append(results, buildAIAgentResponseWithLocale(&item, i18nx.Locale(ctx)))
|
||||
}
|
||||
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func AIAgentGetList_all(ctx *gin.Context) {
|
||||
list := services.AIAgentService.Find(sqls.NewCnd().Where("status = ?", enums.StatusOk).Desc("sort_no").Desc("id"))
|
||||
results := make([]response.AIAgentResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
results = append(results, buildAIAgentResponse(&item))
|
||||
results = append(results, buildAIAgentResponseWithLocale(&item, i18nx.Locale(ctx)))
|
||||
}
|
||||
httpx.WriteJSON(ctx, results)
|
||||
}
|
||||
@@ -66,7 +67,7 @@ func AIAgentGetBy(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, web.JsonErrorMsg("AI Agent 不存在"))
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, buildAIAgentResponse(item))
|
||||
httpx.WriteJSON(ctx, buildAIAgentResponseWithLocale(item, i18nx.Locale(ctx)))
|
||||
}
|
||||
|
||||
func AIAgentPostCreate(ctx *gin.Context) {
|
||||
@@ -85,7 +86,7 @@ func AIAgentPostCreate(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, buildAIAgentResponse(item))
|
||||
httpx.WriteJSON(ctx, buildAIAgentResponseWithLocale(item, i18nx.Locale(ctx)))
|
||||
}
|
||||
|
||||
func AIAgentPostUpdate(ctx *gin.Context) {
|
||||
@@ -160,6 +161,10 @@ func AIAgentPostUpdate_status(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
|
||||
return buildAIAgentResponseWithLocale(item, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse {
|
||||
ret := response.AIAgentResponse{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
@@ -242,13 +247,13 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
|
||||
}
|
||||
title := strings.TrimSpace(tool.Title)
|
||||
if title == "" {
|
||||
if registeredTitle := toolx.GetRegisteredToolTitle(toolCode); registeredTitle != "" {
|
||||
if registeredTitle := toolx.GetRegisteredToolTitleLocale(toolCode, locale); registeredTitle != "" {
|
||||
title = registeredTitle
|
||||
}
|
||||
}
|
||||
description := strings.TrimSpace(tool.Description)
|
||||
if description == "" {
|
||||
if registeredDescription := toolx.GetRegisteredToolDescription(toolCode); registeredDescription != "" {
|
||||
if registeredDescription := toolx.GetRegisteredToolDescriptionLocale(toolCode, locale); registeredDescription != "" {
|
||||
description = registeredDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/httpx"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/services"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -65,7 +66,7 @@ func ConversationAnyList(ctx *gin.Context) {
|
||||
list, paging := services.ConversationService.FindPageByCnd(cnd)
|
||||
results := make([]response.ConversationResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
results = append(results, builders.BuildConversation(&item))
|
||||
results = append(results, builders.BuildConversationWithLocale(&item, i18nx.Locale(ctx)))
|
||||
}
|
||||
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
|
||||
}
|
||||
@@ -94,7 +95,7 @@ func ConversationAnyConversations(ctx *gin.Context) {
|
||||
|
||||
results := make([]response.ConversationResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
results = append(results, builders.BuildConversation(&item))
|
||||
results = append(results, builders.BuildConversationWithLocale(&item, i18nx.Locale(ctx)))
|
||||
}
|
||||
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
|
||||
}
|
||||
@@ -116,7 +117,7 @@ func ConversationGetBy(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
detail := response.ConversationDetailResponse{
|
||||
ConversationResponse: builders.BuildConversation(item),
|
||||
ConversationResponse: builders.BuildConversationWithLocale(item, i18nx.Locale(ctx)),
|
||||
Participants: builders.BuildParticipantResponses(id),
|
||||
}
|
||||
httpx.WriteJSON(ctx, detail)
|
||||
@@ -143,7 +144,7 @@ func ConversationAnyMessage_list(ctx *gin.Context) {
|
||||
list, nextCursor, hasMore := services.MessageService.FindByConversationIDCursor(
|
||||
conversationID, cursor, limit, senderType, messageType,
|
||||
)
|
||||
results := builders.BuildMessages(list)
|
||||
results := builders.BuildMessagesWithLocale(list, i18nx.Locale(ctx))
|
||||
|
||||
httpx.WriteJSON(ctx, httpx.CursorData(results, cast.ToString(nextCursor), hasMore))
|
||||
}
|
||||
@@ -259,7 +260,7 @@ func ConversationPostSend_message(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildMessage(item))
|
||||
httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
|
||||
}
|
||||
|
||||
func ConversationPostRecall_message(ctx *gin.Context) {
|
||||
@@ -279,7 +280,7 @@ func ConversationPostRecall_message(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildMessage(item))
|
||||
httpx.WriteJSON(ctx, builders.BuildMessageWithLocale(item, i18nx.Locale(ctx)))
|
||||
}
|
||||
|
||||
func ConversationPostRead(ctx *gin.Context) {
|
||||
|
||||
@@ -5,11 +5,12 @@ import (
|
||||
"cs-agent/internal/services"
|
||||
|
||||
"cs-agent/internal/pkg/httpx/params"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func DashboardGetOverview(ctx *gin.Context) {
|
||||
rangeValue, _ := params.Get(ctx, "range")
|
||||
httpx.WriteJSON(ctx, services.DashboardService.GetOverview(rangeValue))
|
||||
httpx.WriteJSON(ctx, services.DashboardService.GetOverview(rangeValue, i18nx.Locale(ctx)))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"cs-agent/internal/pkg/constants"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/services"
|
||||
|
||||
"cs-agent/internal/pkg/httpx/params"
|
||||
@@ -27,7 +28,7 @@ func MCPAnyCatalog(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
items, err := services.ToolCatalogService.ListMCPTools(context.Background())
|
||||
items, err := services.ToolCatalogService.ListMCPToolsWithLocale(context.Background(), i18nx.Locale(ctx))
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"cs-agent/internal/services"
|
||||
|
||||
"cs-agent/internal/pkg/httpx/params"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
@@ -39,7 +40,7 @@ func NotificationAnyList(ctx *gin.Context) {
|
||||
|
||||
list, paging := services.NotificationService.FindPageByCnd(cnd)
|
||||
httpx.WriteJSON(ctx, &web.PageResult{
|
||||
Results: builders.BuildNotificationList(list),
|
||||
Results: builders.BuildNotificationListWithLocale(list, i18nx.Locale(ctx)),
|
||||
Page: paging,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -16,7 +17,9 @@ func AuthMiddleware(ctx *gin.Context) {
|
||||
|
||||
func authenticateRequest(ctx *gin.Context) bool {
|
||||
if _, err := services.AuthService.Authenticate(ctx); err != nil {
|
||||
ctx.JSON(200, web.JsonError(err))
|
||||
result := web.JsonError(err)
|
||||
result.Message = i18nx.T(ctx, "error.auth.expired", nil)
|
||||
ctx.JSON(200, result)
|
||||
ctx.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
func GetPathInt64(ctx *gin.Context, name string) (int64, bool) {
|
||||
value, err := strconv.ParseInt(ctx.Param(name), 10, 64)
|
||||
if err != nil {
|
||||
WriteHttpStatusJSON(ctx, http.StatusBadRequest, web.JsonErrorMsg("路径参数错误"))
|
||||
WriteHttpStatusJSON(ctx, http.StatusBadRequest, web.JsonErrorMsg(i18nx.T(ctx, "error.path.invalid", nil)))
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -28,11 +29,19 @@ func PageData(results any, paging *sqls.Paging) any {
|
||||
}
|
||||
|
||||
func WriteJSON(ctx *gin.Context, result any) {
|
||||
ctx.JSON(http.StatusOK, buildJSONResult(result))
|
||||
ctx.JSON(http.StatusOK, localizeJSONResult(ctx, buildJSONResult(result)))
|
||||
}
|
||||
|
||||
func WriteHttpStatusJSON(ctx *gin.Context, statusCode int, result any) {
|
||||
ctx.JSON(statusCode, buildJSONResult(result))
|
||||
ctx.JSON(statusCode, localizeJSONResult(ctx, buildJSONResult(result)))
|
||||
}
|
||||
|
||||
func localizeJSONResult(ctx *gin.Context, result *web.JsonResult) *web.JsonResult {
|
||||
if result == nil || result.Success || result.Message == "" {
|
||||
return result
|
||||
}
|
||||
result.Message = i18nx.TranslateKnownMessage(i18nx.Locale(ctx), result.Message)
|
||||
return result
|
||||
}
|
||||
|
||||
func buildJSONResult(result any) *web.JsonResult {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -67,6 +68,22 @@ func TestWriteJSONWrapsCommonResultTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONLocalizesKnownErrorMessages(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, recorder := testContext()
|
||||
i18nx.SetLocale(ctx, i18nx.LocaleEnUS)
|
||||
|
||||
WriteJSON(ctx, web.JsonErrorMsg("会话不存在"))
|
||||
|
||||
var got web.JsonResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Message != "Conversation not found." {
|
||||
t.Fatalf("message = %q, want %q", got.Message, "Conversation not found.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHttpStatusJSONUsesProvidedStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, recorder := testContext()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package i18nx
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed locales/*.toml
|
||||
var messageFiles embed.FS
|
||||
|
||||
var (
|
||||
bundleOnce sync.Once
|
||||
bundle *i18n.Bundle
|
||||
)
|
||||
|
||||
func Bundle() *i18n.Bundle {
|
||||
bundleOnce.Do(func() {
|
||||
b := i18n.NewBundle(language.SimplifiedChinese)
|
||||
b.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
for _, name := range []string{
|
||||
"locales/active.zh-CN.toml",
|
||||
"locales/active.en-US.toml",
|
||||
} {
|
||||
if _, err := b.LoadMessageFileFS(messageFiles, name); err != nil {
|
||||
slog.Error("load i18n message file failed", "file", name, "err", err)
|
||||
}
|
||||
}
|
||||
bundle = b
|
||||
})
|
||||
return bundle
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package i18nx
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const contextLocaleKey = "i18nx.locale"
|
||||
|
||||
func Locale(ctx *gin.Context) string {
|
||||
if ctx == nil {
|
||||
return LocaleZhCN
|
||||
}
|
||||
value, ok := ctx.Get(contextLocaleKey)
|
||||
if !ok {
|
||||
return LocaleZhCN
|
||||
}
|
||||
locale, ok := value.(string)
|
||||
if !ok {
|
||||
return LocaleZhCN
|
||||
}
|
||||
return NormalizeLocale(locale)
|
||||
}
|
||||
|
||||
func SetLocale(ctx *gin.Context, locale string) {
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
ctx.Set(contextLocaleKey, NormalizeLocale(locale))
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package i18nx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNormalizeLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "default for blank", in: "", want: LocaleZhCN},
|
||||
{name: "exact chinese", in: "zh-CN", want: LocaleZhCN},
|
||||
{name: "underscore chinese", in: "zh_CN", want: LocaleZhCN},
|
||||
{name: "short chinese", in: "zh", want: LocaleZhCN},
|
||||
{name: "exact english", in: "en-US", want: LocaleEnUS},
|
||||
{name: "underscore english", in: "en_US", want: LocaleEnUS},
|
||||
{name: "short english", in: "en", want: LocaleEnUS},
|
||||
{name: "unsupported falls back", in: "fr-FR", want: LocaleZhCN},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := NormalizeLocale(tt.in); got != tt.want {
|
||||
t.Fatalf("NormalizeLocale(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocaleFromHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
|
||||
req.Header.Set("Accept-Language", "fr-FR, en-US;q=0.9, zh-CN;q=0.8")
|
||||
|
||||
if got := ResolveRequestLocale(req); got != LocaleEnUS {
|
||||
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocalePrefersXLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/user/list", nil)
|
||||
req.Header.Set("X-Locale", "en-US")
|
||||
req.Header.Set("Accept-Language", "zh-CN")
|
||||
|
||||
if got := ResolveRequestLocale(req); got != LocaleEnUS {
|
||||
t.Fatalf("ResolveRequestLocale() = %q, want %q", got, LocaleEnUS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateFallsBackToChinese(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := TLocale(LocaleEnUS, "error.auth.expired", nil); got != "Your session has expired. Please sign in again." {
|
||||
t.Fatalf("english translation = %q", got)
|
||||
}
|
||||
if got := TLocale("fr-FR", "error.auth.expired", nil); got != "未登录或登录已过期" {
|
||||
t.Fatalf("fallback translation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateKnownMessageSupportsFormattedMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "batch limit",
|
||||
message: "单次最多生成 31 条排班",
|
||||
want: "You can generate at most 31 schedule entries at a time.",
|
||||
},
|
||||
{
|
||||
name: "start date",
|
||||
message: "开始时间格式错误,请使用 yyyy-MM-dd",
|
||||
want: "Invalid start time format. Use yyyy-MM-dd.",
|
||||
},
|
||||
{
|
||||
name: "end date time",
|
||||
message: "结束时间格式错误,请使用 yyyy-MM-dd HH:mm:ss 或 RFC3339",
|
||||
want: "Invalid end time format. Use yyyy-MM-dd HH:mm:ss or RFC3339.",
|
||||
},
|
||||
{
|
||||
name: "oidc issuer config",
|
||||
message: "OIDC issuer 未配置",
|
||||
want: "OIDC issuer is not configured.",
|
||||
},
|
||||
{
|
||||
name: "oidc login result",
|
||||
message: "登录结果不能为空",
|
||||
want: "Sign-in result is required.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := TranslateKnownMessage(LocaleEnUS, tt.message); got != tt.want {
|
||||
t.Fatalf("TranslateKnownMessage() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareStoresLocale(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(Middleware())
|
||||
router.GET("/ping", func(ctx *gin.Context) {
|
||||
ctx.String(http.StatusOK, Locale(ctx))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
req.Header.Set("X-Locale", "en-US")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
if got := recorder.Body.String(); got != LocaleEnUS {
|
||||
t.Fatalf("middleware locale = %q, want %q", got, LocaleEnUS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package i18nx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var maxScheduleBatchMessagePattern = regexp.MustCompile(`^单次最多生成 ([0-9]+) 条排班$`)
|
||||
|
||||
var knownMessages = map[string]map[string]string{
|
||||
LocaleEnUS: {
|
||||
"未登录或登录已过期": "Your session has expired. Please sign in again.",
|
||||
"无权限执行该操作": "You do not have permission to perform this action.",
|
||||
"无权限操作": "You do not have permission to perform this action.",
|
||||
"参数不合法": "Invalid request parameters.",
|
||||
"路径参数错误": "Invalid path parameter.",
|
||||
"状态值不合法": "Invalid status value.",
|
||||
"用户名和密码不能为空": "Enter both username and password.",
|
||||
"用户名或密码错误": "The username or password is incorrect.",
|
||||
"登录失败次数过多,请稍后再试": "Too many failed sign-in attempts. Please try again later.",
|
||||
"用户不存在": "User not found.",
|
||||
"用户不存在或已被禁用": "The user does not exist or has been disabled.",
|
||||
"用户名不能为空": "Enter a username.",
|
||||
"用户名已存在": "This username is already in use.",
|
||||
"手机号已存在": "This phone number is already in use.",
|
||||
"邮箱已存在": "This email address is already in use.",
|
||||
"新密码不能为空": "Enter a new password.",
|
||||
"禁用角色不允许分配": "Disabled roles cannot be assigned.",
|
||||
"角色不存在": "Role not found.",
|
||||
"角色名称和编码不能为空": "Enter both role name and role code.",
|
||||
"角色编码已存在": "This role code is already in use.",
|
||||
"系统内置角色不允许删除": "Built-in system roles cannot be deleted.",
|
||||
"角色已被用户使用,无法删除": "This role is assigned to users and cannot be deleted.",
|
||||
"权限不存在": "Permission not found.",
|
||||
"会话不存在": "Conversation not found.",
|
||||
"会话筛选项不合法": "Invalid conversation filter.",
|
||||
"只有待接入会话允许分配": "Only queued conversations can be assigned.",
|
||||
"只有待接入会话允许自动分配": "Only queued conversations can be auto-assigned.",
|
||||
"当前会话已分配客服": "This conversation has already been assigned.",
|
||||
"当前暂不在人工客服服务时间内": "Human support is currently outside service hours.",
|
||||
"目标客服不能为空": "Select a target agent.",
|
||||
"目标客服不存在": "Target agent not found.",
|
||||
"无权转接该会话": "You do not have permission to transfer this conversation.",
|
||||
"只有处理中会话允许转接": "Only active conversations can be transferred.",
|
||||
"当前会话未分配客服": "This conversation is not assigned to an agent.",
|
||||
"目标客服不能与当前指派人相同": "The target agent must be different from the current assignee.",
|
||||
"无权访问该会话": "You do not have permission to access this conversation.",
|
||||
"当前状态不允许关闭会话": "This conversation cannot be closed in its current status.",
|
||||
"关闭原因不能为空": "Enter a close reason.",
|
||||
"无权关闭该会话": "You do not have permission to close this conversation.",
|
||||
"已关闭的会话无法关联客户": "Closed conversations cannot be linked to customers.",
|
||||
"无权限关联该会话": "You do not have permission to link this conversation.",
|
||||
"外部用户标识不能为空": "External user ID is required.",
|
||||
"接入渠道未初始化": "The channel is not initialized.",
|
||||
"接入渠道不存在或已停用": "The channel does not exist or has been disabled.",
|
||||
"接入渠道异常": "Channel error.",
|
||||
"channel not found": "Channel not found.",
|
||||
"该渠道不支持开放客服配置": "This channel does not support public support configuration.",
|
||||
"外部身份未初始化": "External identity is not initialized.",
|
||||
"conversationId不能为空": "Conversation ID is required.",
|
||||
"请选择上传图片": "Choose an image to upload.",
|
||||
"仅支持上传图片文件": "Only image files are supported.",
|
||||
"请选择上传附件": "Choose an attachment to upload.",
|
||||
"请选择上传文件": "Choose a file to upload.",
|
||||
"文件不存在": "File not found.",
|
||||
"文件不可访问": "This file cannot be accessed.",
|
||||
"上传文件超过大小限制": "The uploaded file exceeds the size limit.",
|
||||
"图片资源不存在": "Image asset not found.",
|
||||
"当前暂不支持该存储类型的文件读取": "Reading files from this storage provider is not supported yet.",
|
||||
"客户不存在": "Customer not found.",
|
||||
"客户名称不能为空": "Enter a customer name.",
|
||||
"所属公司不存在": "Company not found.",
|
||||
"公司不存在": "Company not found.",
|
||||
"公司名称不能为空": "Enter a company name.",
|
||||
"公司名称已存在": "This company name is already in use.",
|
||||
"工单不存在": "Ticket not found.",
|
||||
"工单标题不能为空": "Enter a ticket title.",
|
||||
"工单描述不能为空": "Enter a ticket description.",
|
||||
"工单来源不合法": "Invalid ticket source.",
|
||||
"工单状态不合法": "Invalid ticket status.",
|
||||
"处理进展不能为空": "Enter a progress update.",
|
||||
"负责人不存在": "Assignee not found.",
|
||||
"会话与客户不匹配": "The conversation does not belong to this customer.",
|
||||
"视图名称不能为空": "Enter a view name.",
|
||||
"视图筛选条件格式不正确": "The view filter format is invalid.",
|
||||
"视图不存在": "View not found.",
|
||||
"标签不存在": "Tag not found.",
|
||||
"标签名称不能为空": "Enter a tag name.",
|
||||
"父标签不存在": "Parent tag not found.",
|
||||
"同级下已存在相同名称的标签": "A tag with this name already exists at the same level.",
|
||||
"不能将标签设为自己的子标签": "A tag cannot be moved under itself.",
|
||||
"该标签下存在子标签,无法删除": "This tag has child tags and cannot be deleted.",
|
||||
"该标签已关联会话,无法删除": "This tag is linked to conversations and cannot be deleted.",
|
||||
"该标签已关联工单,无法删除": "This tag is linked to tickets and cannot be deleted.",
|
||||
"快捷回复不存在": "Quick reply not found.",
|
||||
"客服档案不存在": "Agent profile not found.",
|
||||
"请选择关联用户": "Select a user to link.",
|
||||
"关联用户不存在": "Linked user not found.",
|
||||
"请选择所属客服组": "Select an agent team.",
|
||||
"所属客服组不存在": "Agent team not found.",
|
||||
"客服工号和展示名不能为空": "Enter both agent ID and display name.",
|
||||
"该用户已存在客服档案": "This user already has an agent profile.",
|
||||
"客服工号已存在": "This agent ID is already in use.",
|
||||
"客服状态不合法": "Invalid agent status.",
|
||||
"最大并发接待数不能小于 0": "Maximum concurrent conversations cannot be less than 0.",
|
||||
"客服组不存在": "Agent team not found.",
|
||||
"客服组排班不存在": "Agent team schedule not found.",
|
||||
"知识库不存在": "Knowledge base not found.",
|
||||
"FAQ不存在": "FAQ not found.",
|
||||
"FAQ知识库不支持文档": "FAQ knowledge bases do not support documents.",
|
||||
"文档不存在": "Document not found.",
|
||||
"内容类型不支持": "Unsupported content type.",
|
||||
"问题不能为空": "Enter a question.",
|
||||
"答案不能为空": "Enter an answer.",
|
||||
"相似问格式不合法": "Invalid similar-question format.",
|
||||
"当前知识库不是FAQ知识库": "This knowledge base is not an FAQ knowledge base.",
|
||||
"documentId或faqId不能为空": "Document ID or FAQ ID is required.",
|
||||
"检索日志不存在": "Retrieval log not found.",
|
||||
"indexStatus参数不合法": "Invalid index status.",
|
||||
"AI配置不存在": "AI configuration not found.",
|
||||
"启用中的AI配置不允许删除": "Enabled AI configurations cannot be deleted.",
|
||||
"配置名称不能为空": "Enter a configuration name.",
|
||||
"供应商不能为空": "Select a provider.",
|
||||
"基础地址不能为空": "Enter a base URL.",
|
||||
"模型类型不能为空": "Select a model type.",
|
||||
"模型名称不能为空": "Enter a model name.",
|
||||
"AI Agent 不存在": "AI Agent not found.",
|
||||
"AI Agent不存在或未启用": "AI Agent not found or not enabled.",
|
||||
"AI Agent 不存在或已停用": "AI Agent not found or disabled.",
|
||||
"AI Agent关联的AI配置不存在": "The AI configuration linked to this AI Agent does not exist.",
|
||||
"CheckPoint 不存在": "Checkpoint not found.",
|
||||
"CheckPoint 与 AI Agent 不匹配": "The checkpoint does not belong to this AI Agent.",
|
||||
"会话与 AI Agent 不匹配": "The conversation does not belong to this AI Agent.",
|
||||
"Skill 不存在或未启用": "Skill not found or not enabled.",
|
||||
"Skill 不存在": "Skill not found.",
|
||||
"Skill ID 不合法": "Invalid Skill ID.",
|
||||
"Skill 编码已存在": "This Skill code is already in use.",
|
||||
"Skill 编码不能为空": "Enter a Skill code.",
|
||||
"Skill 名称不能为空": "Enter a Skill name.",
|
||||
"技能说明不能为空": "Enter a Skill description.",
|
||||
"JSON 数组格式不合法": "Invalid JSON array format.",
|
||||
"已删除的 Skill 不能直接修改状态,请先恢复": "Restore this deleted Skill before changing its status.",
|
||||
"请使用删除接口处理删除状态": "Use the delete endpoint to mark a Skill as deleted.",
|
||||
"仅已删除的 Skill 支持恢复": "Only deleted Skills can be restored.",
|
||||
"MCP未启用": "MCP is not enabled.",
|
||||
"serverCode不能为空": "Server code is required.",
|
||||
"MCP endpoint不能为空": "MCP endpoint is required.",
|
||||
"MCP服务配置不存在": "MCP server configuration not found.",
|
||||
"MCP服务未启用": "MCP server is not enabled.",
|
||||
"toolName不能为空": "Tool name is required.",
|
||||
"toolCode不能为空": "Tool code is required.",
|
||||
"toolCode格式不合法": "Invalid tool code format.",
|
||||
"toolCode 绑定的 MCP 服务不存在或未启用": "The MCP server bound to this tool code does not exist or is not enabled.",
|
||||
"文本内容不能为空": "Text content is required.",
|
||||
"文本列表不能为空": "Text list is required.",
|
||||
"密码长度不合法": "Invalid password length.",
|
||||
"AI Agent 不存在或未启用": "AI Agent not found or not enabled.",
|
||||
"AI Agent 名称不能为空": "Enter an AI Agent name.",
|
||||
"AI Agent 名称已存在": "This AI Agent name is already in use.",
|
||||
"AI 配置不存在": "AI configuration not found.",
|
||||
"AI 配置不能为空": "Select an AI configuration.",
|
||||
"AI 配置未启用": "The AI configuration is not enabled.",
|
||||
"Agent 运行日志不存在": "Agent run log not found.",
|
||||
"Direct Tool 的 toolCode 与 serverCode 不一致": "Direct Tool toolCode does not match serverCode.",
|
||||
"Direct Tool 的 toolCode 与 toolName 不一致": "Direct Tool toolCode does not match toolName.",
|
||||
"Direct Tool 的 toolCode 格式不合法": "Invalid Direct Tool toolCode format.",
|
||||
"Direct Tool 的 toolCode、serverCode 和 toolName 不能为空": "Direct Tool toolCode, serverCode, and toolName are required.",
|
||||
"Direct Tools 仅允许配置 MCP 工具": "Direct Tools can only contain MCP tools.",
|
||||
"Direct Tools 配置格式不合法": "Invalid Direct Tools configuration format.",
|
||||
"Graph Tools 仅允许配置 Graph Tool": "Graph Tools can only contain Graph tools.",
|
||||
"Graph Tools 配置格式不合法": "Invalid Graph Tools configuration format.",
|
||||
"HTML消息中的图片必须使用已上传文件": "Images in HTML messages must use uploaded files.",
|
||||
"OIDC id_token 不存在": "OIDC id_token is missing.",
|
||||
"OIDC clientId 未配置": "OIDC clientId is not configured.",
|
||||
"OIDC clientSecret 未配置": "OIDC clientSecret is not configured.",
|
||||
"OIDC issuer 未配置": "OIDC issuer is not configured.",
|
||||
"OIDC redirectUrl 未配置": "OIDC redirectUrl is not configured.",
|
||||
"OIDC 授权 code 不能为空": "OIDC authorization code is required.",
|
||||
"OIDC 用户信息不存在": "OIDC user information not found.",
|
||||
"OIDC 用户标识不存在": "OIDC user identifier not found.",
|
||||
"OIDC 登录密钥未配置": "OIDC sign-in secret is not configured.",
|
||||
"OIDC 登录未启用": "OIDC sign-in is not enabled.",
|
||||
"OIDC 登录状态无效或已过期": "OIDC sign-in state is invalid or has expired.",
|
||||
"OIDC 账号绑定的系统用户不存在": "The system user linked to this OIDC account does not exist.",
|
||||
"OSS accessKeyId 未配置": "OSS accessKeyId is not configured.",
|
||||
"OSS accessKeySecret 未配置": "OSS accessKeySecret is not configured.",
|
||||
"OSS bucket 未配置": "OSS bucket is not configured.",
|
||||
"OSS endpoint 未配置": "OSS endpoint is not configured.",
|
||||
"Skill 未启用": "Skill is not enabled.",
|
||||
"Web渠道配置 position 只能为 left 或 right": "Web channel position must be left or right.",
|
||||
"Web渠道配置不合法": "Invalid web channel configuration.",
|
||||
"aiAgentId不能为空": "AI Agent ID is required.",
|
||||
"checkPointId不能为空": "Checkpoint ID is required.",
|
||||
"customerId 必填": "Customer ID is required.",
|
||||
"openKfID不能为空": "openKfID is required.",
|
||||
"openKfId 已被其他渠道使用": "This openKfId is already used by another channel.",
|
||||
"skillCode不能为空": "Skill code is required.",
|
||||
"ticket 不能为空": "Ticket is required.",
|
||||
"userMessage不能为空": "User message is required.",
|
||||
"登录结果不能为空": "Sign-in result is required.",
|
||||
"不支持的发送人类型": "Unsupported sender type.",
|
||||
"不支持的已读操作类型": "Unsupported read operation type.",
|
||||
"不支持的文件存储类型": "Unsupported file storage type.",
|
||||
"不能添加或修改历史日期的排班": "Schedules cannot be added or changed for past dates.",
|
||||
"事务上下文不能为空": "Transaction context is required.",
|
||||
"仅允许撤回自己发送的消息": "You can only recall messages you sent.",
|
||||
"仅支持撤回客服消息": "Only agent messages can be recalled.",
|
||||
"仅能指定一条主联系方式": "Only one primary contact method can be set.",
|
||||
"企业微信 openKfID 不能为空": "WeCom openKfID is required.",
|
||||
"企业微信媒体ID不能为空": "WeCom media ID is required.",
|
||||
"企业微信客户ID不能为空": "WeCom customer ID is required.",
|
||||
"企业微信手机号已被系统用户占用": "This WeCom phone number is already used by a system user.",
|
||||
"企业微信接入渠道未绑定AI Agent": "The WeCom channel is not linked to an AI Agent.",
|
||||
"企业微信接入渠道绑定的AI Agent不存在或已禁用": "The AI Agent linked to this WeCom channel does not exist or has been disabled.",
|
||||
"企业微信未启用或配置不完整": "WeCom is not enabled or its configuration is incomplete.",
|
||||
"企业微信消息ID不能为空": "WeCom message ID is required.",
|
||||
"企业微信渠道配置不合法": "Invalid WeCom channel configuration.",
|
||||
"企业微信渠道配置缺少 openKfId": "WeCom channel configuration is missing openKfId.",
|
||||
"企业微信用户ID已被系统用户名占用": "This WeCom user ID is already used as a system username.",
|
||||
"企业微信用户ID获取失败": "Failed to get the WeCom user ID.",
|
||||
"企业微信用户信息不存在": "WeCom user information not found.",
|
||||
"企业微信登录密钥未配置": "WeCom sign-in secret is not configured.",
|
||||
"企业微信登录未启用": "WeCom sign-in is not enabled.",
|
||||
"企业微信登录状态无效或已过期": "WeCom sign-in state is invalid or has expired.",
|
||||
"企业微信账号绑定的系统用户不存在": "The system user linked to this WeCom account does not exist.",
|
||||
"企业微信邮箱已被系统用户占用": "This WeCom email address is already used by a system user.",
|
||||
"会话已关闭": "This conversation is closed.",
|
||||
"会话未分配客服,暂不允许发送消息": "This conversation has not been assigned to an agent, so messages cannot be sent yet.",
|
||||
"兜底策略不合法": "Invalid fallback policy.",
|
||||
"分块策略不支持": "Unsupported chunking strategy.",
|
||||
"创建会话失败": "Failed to create the conversation.",
|
||||
"单条排班记录不能跨天": "A single schedule entry cannot span multiple days.",
|
||||
"只有待接入未分配会话允许自动分配": "Only queued unassigned conversations can be auto-assigned.",
|
||||
"回复超时秒数不能小于 0": "Reply timeout seconds cannot be less than 0.",
|
||||
"存在冲突排班,请先处理冲突": "There are conflicting schedules. Resolve the conflicts first.",
|
||||
"存在无效工单标签": "Some ticket tags are invalid.",
|
||||
"存在未启用的工单标签": "Some ticket tags are disabled.",
|
||||
"客服会话不能为空": "Support session is required.",
|
||||
"客服会话参数不完整": "Support session parameters are incomplete.",
|
||||
"客服会话密钥未配置": "Support session secret is not configured.",
|
||||
"客服会话已过期": "The support session has expired.",
|
||||
"客服会话校验失败": "Support session verification failed.",
|
||||
"客服组下仍有关联 AI Agent,无法删除": "This agent team is linked to AI Agents and cannot be deleted.",
|
||||
"客服组下仍有关联客服档案,无法删除": "This agent team has linked agent profiles and cannot be deleted.",
|
||||
"客服组下仍有关联组排班,无法删除": "This agent team has schedules and cannot be deleted.",
|
||||
"客服组名称不能为空": "Enter an agent team name.",
|
||||
"客服组名称已存在": "This agent team name is already in use.",
|
||||
"客服组未启用": "Agent team is not enabled.",
|
||||
"客服组状态不合法": "Invalid agent team status.",
|
||||
"已有接入渠道绑定该 AI Agent,无法删除": "This AI Agent is linked to channels and cannot be deleted.",
|
||||
"当前 OIDC 绑定已停用": "The current OIDC binding has been disabled.",
|
||||
"当前企业微信绑定已停用": "The current WeCom binding has been disabled.",
|
||||
"当前会话不处于 AI 接待状态": "This conversation is not currently handled by AI.",
|
||||
"当前会话已分配给其他客服": "This conversation has been assigned to another agent.",
|
||||
"当前会话已由人工客服接管": "This conversation has already been taken over by a human agent.",
|
||||
"当前渠道不支持用户 JWT Secret": "This channel does not support a user JWT secret.",
|
||||
"当前系统账号已被禁用": "The current system account has been disabled.",
|
||||
"微信公众号渠道配置不合法": "Invalid WeChat Official Account channel configuration.",
|
||||
"接入渠道不存在": "Channel not found.",
|
||||
"接收人不能为空": "Recipient is required.",
|
||||
"文档知识库不能使用FAQ分块策略": "Document knowledge bases cannot use the FAQ chunking strategy.",
|
||||
"时间格式错误": "Invalid time format.",
|
||||
"星期必须在 1 到 7 之间": "Weekday must be between 1 and 7.",
|
||||
"服务模式不合法": "Invalid service mode.",
|
||||
"未找到匹配的企业微信接入渠道": "No matching WeCom channel was found.",
|
||||
"未生成任何排班": "No schedules were generated.",
|
||||
"未配置可用的 AI 配置": "No available AI configuration is configured.",
|
||||
"未配置可用的 Embedding 模型": "No available embedding model is configured.",
|
||||
"标题和内容不能为空": "Enter both title and content.",
|
||||
"消息不存在": "Message not found.",
|
||||
"消息内容不能为空": "Message content is required.",
|
||||
"消息已撤回": "This message has been recalled.",
|
||||
"渠道名称不能为空": "Enter a channel name.",
|
||||
"渠道标识已存在": "This channel code is already in use.",
|
||||
"渠道状态不合法": "Invalid channel status.",
|
||||
"渠道类型不合法": "Invalid channel type.",
|
||||
"用户名称不能为空": "User name is required.",
|
||||
"用户标识不能为空": "User ID is required.",
|
||||
"用户身份不能为空": "User identity is required.",
|
||||
"用户身份已过期": "User identity has expired.",
|
||||
"用户身份校验失败": "User identity verification failed.",
|
||||
"用户身份校验未配置": "User identity verification is not configured.",
|
||||
"登录票据无效或已过期": "The sign-in ticket is invalid or has expired.",
|
||||
"知识库下存在FAQ,无法删除": "This knowledge base contains FAQs and cannot be deleted.",
|
||||
"知识库下存在文档,无法删除": "This knowledge base contains documents and cannot be deleted.",
|
||||
"知识库不能为空": "Knowledge base is required.",
|
||||
"知识库未启用": "Knowledge base is not enabled.",
|
||||
"知识库类型不支持": "Unsupported knowledge base type.",
|
||||
"组长用户不存在": "Team lead user not found.",
|
||||
"结束日期必须晚于或等于开始日期": "End date must be later than or equal to start date.",
|
||||
"结束时间必须晚于开始时间": "End time must be later than start time.",
|
||||
"联系方式不存在": "Contact method not found.",
|
||||
"联系方式不能为空": "Contact method is required.",
|
||||
"联系方式类型不合法": "Invalid contact method type.",
|
||||
"该客服组在所选时间段已存在排班": "This agent team already has a schedule in the selected time period.",
|
||||
"该联系方式已存在": "This contact method already exists.",
|
||||
"请至少选择一个知识库": "Select at least one knowledge base.",
|
||||
"请选择 AI Agent": "Select an AI Agent.",
|
||||
"请选择客服组": "Select an agent team.",
|
||||
"请选择星期": "Select a weekday.",
|
||||
"转人工模式不合法": "Invalid human handoff mode.",
|
||||
"通知不存在": "Notification not found.",
|
||||
"附件不存在": "Attachment not found.",
|
||||
"附件尚未上传完成": "The attachment has not finished uploading.",
|
||||
"附件消息 payload 格式错误": "Invalid attachment message payload format.",
|
||||
"附件消息缺少 assetId": "Attachment message is missing assetId.",
|
||||
"附件消息缺少 payload": "Attachment message is missing payload.",
|
||||
"默认客服组待接入池模式必须至少选择一个客服组": "Default team queue mode requires at least one agent team.",
|
||||
},
|
||||
}
|
||||
|
||||
func TranslateKnownMessage(locale string, message string) string {
|
||||
normalized := NormalizeLocale(locale)
|
||||
if normalized == LocaleZhCN {
|
||||
return message
|
||||
}
|
||||
if translations, ok := knownMessages[normalized]; ok {
|
||||
if translated, exists := translations[message]; exists {
|
||||
return translated
|
||||
}
|
||||
}
|
||||
if translated, ok := translateKnownPattern(normalized, message); ok {
|
||||
return translated
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func translateKnownPattern(locale string, message string) (string, bool) {
|
||||
if locale != LocaleEnUS {
|
||||
return "", false
|
||||
}
|
||||
if matches := maxScheduleBatchMessagePattern.FindStringSubmatch(message); len(matches) == 2 {
|
||||
return fmt.Sprintf("You can generate at most %s schedule entries at a time.", matches[1]), true
|
||||
}
|
||||
if strings.HasPrefix(message, "开始时间格式错误,请使用 ") {
|
||||
return translateTimeFormatMessage(message, "开始时间格式错误,请使用 ", "start time")
|
||||
}
|
||||
if strings.HasPrefix(message, "结束时间格式错误,请使用 ") {
|
||||
return translateTimeFormatMessage(message, "结束时间格式错误,请使用 ", "end time")
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func translateTimeFormatMessage(message string, prefix string, field string) (string, bool) {
|
||||
format := strings.TrimPrefix(message, prefix)
|
||||
if format == message || format == "" {
|
||||
return "", false
|
||||
}
|
||||
format = strings.ReplaceAll(format, " 或 ", " or ")
|
||||
return fmt.Sprintf("Invalid %s format. Use %s.", field, format), true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
["error.auth.expired"]
|
||||
other = "Your session has expired. Please sign in again."
|
||||
|
||||
["error.notFound"]
|
||||
other = "Not found"
|
||||
|
||||
["error.path.invalid"]
|
||||
other = "Invalid path parameter."
|
||||
@@ -0,0 +1,8 @@
|
||||
["error.auth.expired"]
|
||||
other = "未登录或登录已过期"
|
||||
|
||||
["error.notFound"]
|
||||
other = "Not found"
|
||||
|
||||
["error.path.invalid"]
|
||||
other = "路径参数错误"
|
||||
@@ -0,0 +1,44 @@
|
||||
package i18nx
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
)
|
||||
|
||||
func T(ctx *gin.Context, messageID string, data map[string]any) string {
|
||||
if ctx != nil {
|
||||
if value, exists := ctx.Get(contextLocaleKey); exists {
|
||||
if locale, ok := value.(string); ok {
|
||||
return TLocale(locale, messageID, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return TLocale(LocaleZhCN, messageID, data)
|
||||
}
|
||||
|
||||
func TLocale(locale string, messageID string, data map[string]any) string {
|
||||
normalized := NormalizeLocale(locale)
|
||||
message := localize(normalized, messageID, data)
|
||||
if message != "" {
|
||||
return message
|
||||
}
|
||||
if normalized != LocaleZhCN {
|
||||
message = localize(LocaleZhCN, messageID, data)
|
||||
if message != "" {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
func localize(locale string, messageID string, data map[string]any) string {
|
||||
localizer := i18n.NewLocalizer(Bundle(), locale, LocaleZhCN)
|
||||
message, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||
MessageID: messageID,
|
||||
TemplateData: data,
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package i18nx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const (
|
||||
LocaleZhCN = "zh-CN"
|
||||
LocaleEnUS = "en-US"
|
||||
)
|
||||
|
||||
var supportedLocales = map[string]string{
|
||||
"zh": LocaleZhCN,
|
||||
"zh-cn": LocaleZhCN,
|
||||
"zh_cn": LocaleZhCN,
|
||||
"zh-hans": LocaleZhCN,
|
||||
"en": LocaleEnUS,
|
||||
"en-us": LocaleEnUS,
|
||||
"en_us": LocaleEnUS,
|
||||
}
|
||||
|
||||
func NormalizeLocale(value string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if key == "" {
|
||||
return LocaleZhCN
|
||||
}
|
||||
if locale, ok := supportedLocales[key]; ok {
|
||||
return locale
|
||||
}
|
||||
return LocaleZhCN
|
||||
}
|
||||
|
||||
func ResolveRequestLocale(req *http.Request) string {
|
||||
if req == nil {
|
||||
return LocaleZhCN
|
||||
}
|
||||
if locale := normalizeSupportedLocale(req.Header.Get("X-Locale")); locale != "" {
|
||||
return locale
|
||||
}
|
||||
if locale := resolveAcceptLanguage(req.Header.Get("Accept-Language")); locale != "" {
|
||||
return locale
|
||||
}
|
||||
if locale := normalizeSupportedLocale(req.URL.Query().Get("locale")); locale != "" {
|
||||
return locale
|
||||
}
|
||||
return LocaleZhCN
|
||||
}
|
||||
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
SetLocale(ctx, ResolveRequestLocale(ctx.Request))
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSupportedLocale(value string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if locale, ok := supportedLocales[key]; ok {
|
||||
return locale
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveAcceptLanguage(value string) string {
|
||||
tags, _, err := language.ParseAcceptLanguage(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if locale := normalizeSupportedLocale(tag.String()); locale != "" {
|
||||
return locale
|
||||
}
|
||||
base, _ := tag.Base()
|
||||
if locale := normalizeSupportedLocale(base.String()); locale != "" {
|
||||
return locale
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
type ToolSpec struct {
|
||||
@@ -218,6 +219,20 @@ func GetRegisteredToolTitle(toolCode string) string {
|
||||
return spec.Title
|
||||
}
|
||||
|
||||
func GetRegisteredToolTitleLocale(toolCode string, locale string) string {
|
||||
spec, ok := GetRegisteredToolSpec(toolCode)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
|
||||
return spec.Title
|
||||
}
|
||||
if text := registeredToolEnglishTitle(spec.Code); text != "" {
|
||||
return text
|
||||
}
|
||||
return spec.Title
|
||||
}
|
||||
|
||||
func GetRegisteredToolDescription(toolCode string) string {
|
||||
spec, ok := GetRegisteredToolSpec(toolCode)
|
||||
if !ok {
|
||||
@@ -226,6 +241,62 @@ func GetRegisteredToolDescription(toolCode string) string {
|
||||
return spec.Description
|
||||
}
|
||||
|
||||
func GetRegisteredToolDescriptionLocale(toolCode string, locale string) string {
|
||||
spec, ok := GetRegisteredToolSpec(toolCode)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS {
|
||||
return spec.Description
|
||||
}
|
||||
if text := registeredToolEnglishDescription(spec.Code); text != "" {
|
||||
return text
|
||||
}
|
||||
return spec.Description
|
||||
}
|
||||
|
||||
func registeredToolEnglishTitle(toolCode string) string {
|
||||
switch toolCode {
|
||||
case BuiltinToolSearch.Code:
|
||||
return "Search and Run Dynamic Tools"
|
||||
case BuiltinSkill.Code:
|
||||
return "Load Skill Instructions"
|
||||
case GraphTriageServiceRequest.Code:
|
||||
return "Route Service Request"
|
||||
case GraphAnalyzeConversation.Code:
|
||||
return "Analyze Conversation Risk and Summary"
|
||||
case GraphPrepareTicketDraft.Code:
|
||||
return "Prepare Ticket Draft"
|
||||
case GraphCreateTicketConfirm.Code:
|
||||
return "Create Ticket With Confirmation"
|
||||
case GraphHandoffConversation.Code:
|
||||
return "Handoff to Human With Confirmation"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func registeredToolEnglishDescription(toolCode string) string {
|
||||
switch toolCode {
|
||||
case BuiltinToolSearch.Code:
|
||||
return "Searches the MCP tools currently available to the agent and runs the selected tool after its toolCode is confirmed. Best for long-tail tools; it should not replace fixed built-in workflow tools."
|
||||
case BuiltinSkill.Code:
|
||||
return "Loads specialized skill instructions for the current agent when extra task-specific guidance is needed."
|
||||
case GraphTriageServiceRequest.Code:
|
||||
return "Analyzes the current conversation to decide whether to keep answering, prepare a ticket draft, or hand off to a human, including a ticket draft when ticket creation is appropriate."
|
||||
case GraphAnalyzeConversation.Code:
|
||||
return "Summarizes the current conversation, identifies risk signals, and recommends whether to keep answering, create a ticket, or hand off to a human."
|
||||
case GraphPrepareTicketDraft.Code:
|
||||
return "Turns the current conversation and collected details into a ticket draft with a suggested title, description, missing fields, and follow-up questions."
|
||||
case GraphCreateTicketConfirm.Code:
|
||||
return "Guides ticket creation with parameter preparation, customer confirmation, actual ticket creation, and final result delivery."
|
||||
case GraphHandoffConversation.Code:
|
||||
return "Guides human handoff with reason preparation, customer confirmation, actual transfer, and final result delivery."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func GetRegisteredToolIdentity(toolCode string) (serverCode, toolName string, ok bool) {
|
||||
spec, ok := GetRegisteredToolSpec(toolCode)
|
||||
if !ok {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package toolx
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
)
|
||||
|
||||
func TestResolveToolMetadata(t *testing.T) {
|
||||
item := ResolveToolMetadata("builtin/create_ticket_with_confirmation", "")
|
||||
@@ -33,3 +37,28 @@ func TestResolveToolMetadataFallsBackToName(t *testing.T) {
|
||||
t.Fatalf("unexpected source type: %s", item.SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredToolTextUsesEnglishLocale(t *testing.T) {
|
||||
title := GetRegisteredToolTitleLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleEnUS)
|
||||
if title != "Create Ticket With Confirmation" {
|
||||
t.Fatalf("unexpected english title: %q", title)
|
||||
}
|
||||
|
||||
description := GetRegisteredToolDescriptionLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleEnUS)
|
||||
want := "Guides ticket creation with parameter preparation, customer confirmation, actual ticket creation, and final result delivery."
|
||||
if description != want {
|
||||
t.Fatalf("unexpected english description: %q", description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredToolTextKeepsChineseLocale(t *testing.T) {
|
||||
title := GetRegisteredToolTitleLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleZhCN)
|
||||
if title != GraphCreateTicketConfirm.Title {
|
||||
t.Fatalf("unexpected chinese title: %q", title)
|
||||
}
|
||||
|
||||
description := GetRegisteredToolDescriptionLocale(GraphCreateTicketConfirm.Code, i18nx.LocaleZhCN)
|
||||
if description != GraphCreateTicketConfirm.Description {
|
||||
t.Fatalf("unexpected chinese description: %q", description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/repositories"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -23,7 +24,8 @@ func newDashboardService() *dashboardService {
|
||||
type dashboardService struct {
|
||||
}
|
||||
|
||||
func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOverviewResponse {
|
||||
func (s *dashboardService) GetOverview(rangeValue string, locale string) response.DashboardOverviewResponse {
|
||||
locale = i18nx.NormalizeLocale(locale)
|
||||
now := time.Now()
|
||||
normalizedRange, trendDays := normalizeDashboardRange(rangeValue)
|
||||
todayStart := startOfDay(now)
|
||||
@@ -78,7 +80,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
|
||||
enabledAIAgents := repositories.DashboardRepository.ListAIAgents(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", enums.StatusOk)
|
||||
})
|
||||
alerts := s.buildAlerts(now, db, enabledAIAgents, agentTeams, activeSchedules)
|
||||
alerts := s.buildAlerts(now, db, enabledAIAgents, agentTeams, activeSchedules, locale)
|
||||
|
||||
return response.DashboardOverviewResponse{
|
||||
Range: normalizedRange,
|
||||
@@ -91,7 +93,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
|
||||
AIServiceRate: calcAIServiceRate(activeConversations),
|
||||
},
|
||||
ConversationStats: response.DashboardSectionStatsResponse{
|
||||
StatusDistribution: buildConversationStatusDistribution(db),
|
||||
StatusDistribution: buildConversationStatusDistribution(db, locale),
|
||||
Trend: buildConversationTrend(db, trendStart),
|
||||
},
|
||||
AgentStats: response.DashboardAgentStatsResponse{
|
||||
@@ -110,7 +112,7 @@ func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOver
|
||||
TodayAIHandoffCount: aiHandoffCount,
|
||||
},
|
||||
Alerts: alerts,
|
||||
QuickLinks: buildDashboardQuickLinks(),
|
||||
QuickLinks: buildDashboardQuickLinks(locale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +217,7 @@ func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTe
|
||||
return onlineAgents, busyAgents, offlineAgents, teamLoads
|
||||
}
|
||||
|
||||
func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []models.AIAgent, teams []models.AgentTeam, schedules []models.AgentTeamSchedule) []response.DashboardAlertResponse {
|
||||
func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []models.AIAgent, teams []models.AgentTeam, schedules []models.AgentTeamSchedule, locale string) []response.DashboardAlertResponse {
|
||||
alerts := make([]response.DashboardAlertResponse, 0, 4)
|
||||
pendingTimeout := now.Add(-10 * time.Minute)
|
||||
activeTimeout := now.Add(-30 * time.Minute)
|
||||
@@ -227,8 +229,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "pending-long-wait",
|
||||
Level: "warning",
|
||||
Title: "待接入会话堆积",
|
||||
Description: "存在超过 10 分钟仍未接入的会话,建议优先处理分配。",
|
||||
Title: dashboardText(locale, "alert.pendingLongWait.title"),
|
||||
Description: dashboardText(locale, "alert.pendingLongWait.description"),
|
||||
Count: pendingLongWaitCount,
|
||||
Link: "/dashboard/conversations",
|
||||
})
|
||||
@@ -244,8 +246,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "stale-processing",
|
||||
Level: "warning",
|
||||
Title: "处理中会话长时间无响应",
|
||||
Description: "部分处理中会话已超过 30 分钟没有最新消息,需要确认跟进状态。",
|
||||
Title: dashboardText(locale, "alert.staleProcessing.title"),
|
||||
Description: dashboardText(locale, "alert.staleProcessing.description"),
|
||||
Count: staleProcessingCount,
|
||||
Link: "/dashboard/conversations",
|
||||
})
|
||||
@@ -265,8 +267,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "team-no-schedule",
|
||||
Level: "info",
|
||||
Title: "客服组当前无生效排班",
|
||||
Description: "部分启用中的客服组当前没有生效排班,可能影响自动分配。",
|
||||
Title: dashboardText(locale, "alert.teamNoSchedule.title"),
|
||||
Description: dashboardText(locale, "alert.teamNoSchedule.description"),
|
||||
Count: scheduleMissingCount,
|
||||
Link: "/dashboard/agent-team-schedules",
|
||||
})
|
||||
@@ -282,8 +284,8 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "ai-no-knowledge",
|
||||
Level: "info",
|
||||
Title: "AI Agent 未绑定知识库",
|
||||
Description: "部分启用中的 AI Agent 尚未绑定知识库,回答质量可能不稳定。",
|
||||
Title: dashboardText(locale, "alert.aiNoKnowledge.title"),
|
||||
Description: dashboardText(locale, "alert.aiNoKnowledge.description"),
|
||||
Count: aiAgentWithoutKnowledgeCount,
|
||||
Link: "/dashboard/ai-agents",
|
||||
})
|
||||
@@ -299,12 +301,12 @@ func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, aiAgents []mo
|
||||
return alerts
|
||||
}
|
||||
|
||||
func buildConversationStatusDistribution(db *gorm.DB) []response.DashboardStatusDistributionItem {
|
||||
func buildConversationStatusDistribution(db *gorm.DB, locale string) []response.DashboardStatusDistributionItem {
|
||||
ret := make([]response.DashboardStatusDistributionItem, 0, len(enums.IMConversationStatusValues))
|
||||
for _, status := range enums.IMConversationStatusValues {
|
||||
ret = append(ret, response.DashboardStatusDistributionItem{
|
||||
Status: int(status),
|
||||
Label: labelOrDefault(enums.GetIMConversationStatusLabel(status), fmt.Sprintf("状态 %d", status)),
|
||||
Label: conversationStatusLabel(status, locale),
|
||||
Count: repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", status)
|
||||
}),
|
||||
@@ -364,13 +366,13 @@ func flattenTrendMap(series map[string]*response.DashboardTrendItem) []response.
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildDashboardQuickLinks() []response.DashboardQuickLinkResponse {
|
||||
func buildDashboardQuickLinks(locale string) []response.DashboardQuickLinkResponse {
|
||||
return []response.DashboardQuickLinkResponse{
|
||||
{Title: "会话管理", Description: "查看待接入与处理中会话", Link: "/dashboard/conversations"},
|
||||
{Title: "客服档案", Description: "查看客服状态与分组配置", Link: "/dashboard/agents"},
|
||||
{Title: "知识库", Description: "维护文档与查看检索日志", Link: "/dashboard/knowledge"},
|
||||
{Title: "AI Agent", Description: "配置 AI 接待策略与知识绑定", Link: "/dashboard/ai-agents"},
|
||||
{Title: "接入渠道", Description: "管理接入渠道与默认 Agent", Link: "/dashboard/channels"},
|
||||
{Title: dashboardText(locale, "quick.conversations.title"), Description: dashboardText(locale, "quick.conversations.description"), Link: "/dashboard/conversations"},
|
||||
{Title: dashboardText(locale, "quick.agents.title"), Description: dashboardText(locale, "quick.agents.description"), Link: "/dashboard/agents"},
|
||||
{Title: dashboardText(locale, "quick.knowledge.title"), Description: dashboardText(locale, "quick.knowledge.description"), Link: "/dashboard/knowledge"},
|
||||
{Title: dashboardText(locale, "quick.aiAgents.title"), Description: dashboardText(locale, "quick.aiAgents.description"), Link: "/dashboard/ai-agents"},
|
||||
{Title: dashboardText(locale, "quick.channels.title"), Description: dashboardText(locale, "quick.channels.description"), Link: "/dashboard/channels"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,3 +417,75 @@ func labelOrDefault(value, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func conversationStatusLabel(status enums.IMConversationStatus, locale string) string {
|
||||
if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS {
|
||||
switch status {
|
||||
case enums.IMConversationStatusAIServing:
|
||||
return "AI active"
|
||||
case enums.IMConversationStatusPending:
|
||||
return "Queued"
|
||||
case enums.IMConversationStatusActive:
|
||||
return "In progress"
|
||||
case enums.IMConversationStatusClosed:
|
||||
return "Closed"
|
||||
default:
|
||||
return fmt.Sprintf("Status %d", status)
|
||||
}
|
||||
}
|
||||
return labelOrDefault(enums.GetIMConversationStatusLabel(status), fmt.Sprintf("状态 %d", status))
|
||||
}
|
||||
|
||||
func dashboardText(locale string, key string) string {
|
||||
if i18nx.NormalizeLocale(locale) == i18nx.LocaleEnUS {
|
||||
if value, ok := dashboardEnUS[key]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if value, ok := dashboardZhCN[key]; ok {
|
||||
return value
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
var dashboardZhCN = map[string]string{
|
||||
"alert.pendingLongWait.title": "待接入会话堆积",
|
||||
"alert.pendingLongWait.description": "存在超过 10 分钟仍未接入的会话,建议优先处理分配。",
|
||||
"alert.staleProcessing.title": "处理中会话长时间无响应",
|
||||
"alert.staleProcessing.description": "部分处理中会话已超过 30 分钟没有最新消息,需要确认跟进状态。",
|
||||
"alert.teamNoSchedule.title": "客服组当前无生效排班",
|
||||
"alert.teamNoSchedule.description": "部分启用中的客服组当前没有生效排班,可能影响自动分配。",
|
||||
"alert.aiNoKnowledge.title": "AI Agent 未绑定知识库",
|
||||
"alert.aiNoKnowledge.description": "部分启用中的 AI Agent 尚未绑定知识库,回答质量可能不稳定。",
|
||||
"quick.conversations.title": "会话管理",
|
||||
"quick.conversations.description": "查看待接入与处理中会话",
|
||||
"quick.agents.title": "客服档案",
|
||||
"quick.agents.description": "查看客服状态与分组配置",
|
||||
"quick.knowledge.title": "知识库",
|
||||
"quick.knowledge.description": "维护文档与查看检索日志",
|
||||
"quick.aiAgents.title": "AI Agent",
|
||||
"quick.aiAgents.description": "配置 AI 接待策略与知识绑定",
|
||||
"quick.channels.title": "接入渠道",
|
||||
"quick.channels.description": "管理接入渠道与默认 Agent",
|
||||
}
|
||||
|
||||
var dashboardEnUS = map[string]string{
|
||||
"alert.pendingLongWait.title": "Queued conversations are piling up",
|
||||
"alert.pendingLongWait.description": "Some conversations have been waiting for more than 10 minutes. Prioritize assignment.",
|
||||
"alert.staleProcessing.title": "Active conversations need attention",
|
||||
"alert.staleProcessing.description": "Some active conversations have had no new messages for over 30 minutes. Check their follow-up status.",
|
||||
"alert.teamNoSchedule.title": "Agent teams have no active schedule",
|
||||
"alert.teamNoSchedule.description": "Some enabled agent teams do not have an active schedule right now, which may affect automatic assignment.",
|
||||
"alert.aiNoKnowledge.title": "AI Agents are missing knowledge bases",
|
||||
"alert.aiNoKnowledge.description": "Some enabled AI Agents are not linked to a knowledge base yet, which may reduce answer quality.",
|
||||
"quick.conversations.title": "Conversations",
|
||||
"quick.conversations.description": "Review queued and active conversations",
|
||||
"quick.agents.title": "Agents",
|
||||
"quick.agents.description": "Check agent status and team setup",
|
||||
"quick.knowledge.title": "Knowledge base",
|
||||
"quick.knowledge.description": "Manage documents and review retrieval logs",
|
||||
"quick.aiAgents.title": "AI Agents",
|
||||
"quick.aiAgents.description": "Configure AI service policies and knowledge bindings",
|
||||
"quick.channels.title": "Channels",
|
||||
"quick.channels.description": "Manage channels and default agents",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDashboardTextUsesEnglishLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := dashboardText(i18nx.LocaleEnUS, "alert.pendingLongWait.title"); got != "Queued conversations are piling up" {
|
||||
t.Fatalf("dashboardText() = %q", got)
|
||||
}
|
||||
if got := dashboardText(i18nx.LocaleZhCN, "alert.pendingLongWait.title"); got != "待接入会话堆积" {
|
||||
t.Fatalf("dashboardText() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationStatusLabelUsesEnglishLocale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := conversationStatusLabel(enums.IMConversationStatusPending, i18nx.LocaleEnUS); got != "Queued" {
|
||||
t.Fatalf("conversationStatusLabel() = %q", got)
|
||||
}
|
||||
if got := conversationStatusLabel(enums.IMConversationStatusPending, i18nx.LocaleZhCN); got != "待接入" {
|
||||
t.Fatalf("conversationStatusLabel() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/pkg/i18nx"
|
||||
"cs-agent/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
@@ -33,6 +34,10 @@ type MCPToolCatalogItem struct {
|
||||
}
|
||||
|
||||
func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) {
|
||||
return s.ListMCPToolsWithLocale(ctx, i18nx.LocaleZhCN)
|
||||
}
|
||||
|
||||
func (s *toolCatalogService) ListMCPToolsWithLocale(ctx context.Context, locale string) ([]MCPToolCatalogItem, error) {
|
||||
cfg := config.Current()
|
||||
ret := make([]MCPToolCatalogItem, 0, 3)
|
||||
for _, spec := range toolx.ListAgentDirectToolSpecs() {
|
||||
@@ -45,8 +50,8 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog
|
||||
ToolName: spec.Name,
|
||||
SourceType: spec.SourceType,
|
||||
AutoInjected: spec.AutoInjected,
|
||||
Title: spec.Title,
|
||||
Description: spec.Description,
|
||||
Title: toolx.GetRegisteredToolTitleLocale(spec.Code, locale),
|
||||
Description: toolx.GetRegisteredToolDescriptionLocale(spec.Code, locale),
|
||||
})
|
||||
}
|
||||
if !cfg.MCP.Enabled {
|
||||
|
||||
Reference in New Issue
Block a user