diff --git a/go.mod b/go.mod index 89462af..2e2546d 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,10 @@ require ( github.com/mlogclub/codegen v1.0.3 github.com/mlogclub/simple v1.2.40 github.com/modelcontextprotocol/go-sdk v1.4.1 + github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/openai/openai-go/v3 v3.28.0 github.com/panjf2000/ants/v2 v2.12.0 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/qdrant/go-client v1.17.1 github.com/robfig/cron/v3 v3.0.1 github.com/silenceper/wechat/v2 v2.1.12 @@ -29,6 +31,7 @@ require ( github.com/yuin/goldmark v1.4.13 golang.org/x/crypto v0.49.0 golang.org/x/net v0.51.0 + golang.org/x/text v0.35.0 golang.org/x/tools v0.42.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 @@ -79,7 +82,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/nxadm/tail v1.4.11 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect @@ -104,7 +106,6 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/grpc v1.78.0 // indirect diff --git a/go.sum b/go.sum index bcff2a9..a8741c2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= @@ -195,6 +197,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -318,6 +322,8 @@ go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4Len go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 471b76d..16ebfa8 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -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))) }, }) diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index 51cf480..0e54d3f 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -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{}) diff --git a/internal/builders/conversation_builder_test.go b/internal/builders/conversation_builder_test.go new file mode 100644 index 0000000..22b1a05 --- /dev/null +++ b/internal/builders/conversation_builder_test.go @@ -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) + } + }) + } +} diff --git a/internal/builders/notification_builder.go b/internal/builders/notification_builder.go index b6622c1..5396885 100644 --- a/internal/builders/notification_builder.go +++ b/internal/builders/notification_builder.go @@ -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") +} diff --git a/internal/builders/notification_builder_test.go b/internal/builders/notification_builder_test.go index 1307abd..88ebab1 100644 --- a/internal/builders/notification_builder_test.go +++ b/internal/builders/notification_builder_test.go @@ -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) + } +} diff --git a/internal/handlers/api/conversation_handler.go b/internal/handlers/api/conversation_handler.go index 6b4e81d..9387d40 100644 --- a/internal/handlers/api/conversation_handler.go +++ b/internal/handlers/api/conversation_handler.go @@ -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) { diff --git a/internal/handlers/api/message_handler.go b/internal/handlers/api/message_handler.go index cf7863b..d3581b6 100644 --- a/internal/handlers/api/message_handler.go +++ b/internal/handlers/api/message_handler.go @@ -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) { diff --git a/internal/handlers/dashboard/ai_agent_handler.go b/internal/handlers/dashboard/ai_agent_handler.go index 7666c31..b49b01c 100644 --- a/internal/handlers/dashboard/ai_agent_handler.go +++ b/internal/handlers/dashboard/ai_agent_handler.go @@ -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 } } diff --git a/internal/handlers/dashboard/conversation_handler.go b/internal/handlers/dashboard/conversation_handler.go index c3e286d..40adae8 100644 --- a/internal/handlers/dashboard/conversation_handler.go +++ b/internal/handlers/dashboard/conversation_handler.go @@ -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) { diff --git a/internal/handlers/dashboard/dashboard_handler.go b/internal/handlers/dashboard/dashboard_handler.go index 4d9cb86..83d48e3 100644 --- a/internal/handlers/dashboard/dashboard_handler.go +++ b/internal/handlers/dashboard/dashboard_handler.go @@ -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))) } diff --git a/internal/handlers/dashboard/mcp_handler.go b/internal/handlers/dashboard/mcp_handler.go index 27a707f..9c30ac3 100644 --- a/internal/handlers/dashboard/mcp_handler.go +++ b/internal/handlers/dashboard/mcp_handler.go @@ -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 diff --git a/internal/handlers/dashboard/notification_handler.go b/internal/handlers/dashboard/notification_handler.go index 924ef67..3ad6460 100644 --- a/internal/handlers/dashboard/notification_handler.go +++ b/internal/handlers/dashboard/notification_handler.go @@ -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, }) } diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 160102e..6ef7112 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -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 } diff --git a/internal/pkg/httpx/path.go b/internal/pkg/httpx/path.go index 1716140..4ed1e1f 100644 --- a/internal/pkg/httpx/path.go +++ b/internal/pkg/httpx/path.go @@ -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 diff --git a/internal/pkg/httpx/response.go b/internal/pkg/httpx/response.go index 87697a9..dd78699 100644 --- a/internal/pkg/httpx/response.go +++ b/internal/pkg/httpx/response.go @@ -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 { diff --git a/internal/pkg/httpx/response_test.go b/internal/pkg/httpx/response_test.go index 0299a0e..40ee76b 100644 --- a/internal/pkg/httpx/response_test.go +++ b/internal/pkg/httpx/response_test.go @@ -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() diff --git a/internal/pkg/i18nx/bundle.go b/internal/pkg/i18nx/bundle.go new file mode 100644 index 0000000..d2a3c77 --- /dev/null +++ b/internal/pkg/i18nx/bundle.go @@ -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 +} diff --git a/internal/pkg/i18nx/context.go b/internal/pkg/i18nx/context.go new file mode 100644 index 0000000..2e7375f --- /dev/null +++ b/internal/pkg/i18nx/context.go @@ -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)) +} diff --git a/internal/pkg/i18nx/i18nx_test.go b/internal/pkg/i18nx/i18nx_test.go new file mode 100644 index 0000000..1e78435 --- /dev/null +++ b/internal/pkg/i18nx/i18nx_test.go @@ -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) + } +} diff --git a/internal/pkg/i18nx/known_messages.go b/internal/pkg/i18nx/known_messages.go new file mode 100644 index 0000000..4f68552 --- /dev/null +++ b/internal/pkg/i18nx/known_messages.go @@ -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 +} diff --git a/internal/pkg/i18nx/locales/active.en-US.toml b/internal/pkg/i18nx/locales/active.en-US.toml new file mode 100644 index 0000000..36d66a0 --- /dev/null +++ b/internal/pkg/i18nx/locales/active.en-US.toml @@ -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." diff --git a/internal/pkg/i18nx/locales/active.zh-CN.toml b/internal/pkg/i18nx/locales/active.zh-CN.toml new file mode 100644 index 0000000..6d7883d --- /dev/null +++ b/internal/pkg/i18nx/locales/active.zh-CN.toml @@ -0,0 +1,8 @@ +["error.auth.expired"] +other = "未登录或登录已过期" + +["error.notFound"] +other = "Not found" + +["error.path.invalid"] +other = "路径参数错误" diff --git a/internal/pkg/i18nx/message.go b/internal/pkg/i18nx/message.go new file mode 100644 index 0000000..e1c6073 --- /dev/null +++ b/internal/pkg/i18nx/message.go @@ -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 +} diff --git a/internal/pkg/i18nx/middleware.go b/internal/pkg/i18nx/middleware.go new file mode 100644 index 0000000..7769f14 --- /dev/null +++ b/internal/pkg/i18nx/middleware.go @@ -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 "" +} diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go index 9d9ac79..2e46343 100644 --- a/internal/pkg/toolx/builtin_tools.go +++ b/internal/pkg/toolx/builtin_tools.go @@ -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 { diff --git a/internal/pkg/toolx/builtin_tools_test.go b/internal/pkg/toolx/builtin_tools_test.go index dfa6782..91292d4 100644 --- a/internal/pkg/toolx/builtin_tools_test.go +++ b/internal/pkg/toolx/builtin_tools_test.go @@ -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) + } +} diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index e513188..2cd6e11 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -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", +} diff --git a/internal/services/dashboard_service_test.go b/internal/services/dashboard_service_test.go new file mode 100644 index 0000000..e9a518c --- /dev/null +++ b/internal/services/dashboard_service_test.go @@ -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) + } +} diff --git a/internal/services/tool_catalog_service.go b/internal/services/tool_catalog_service.go index 22a6860..6835bb1 100644 --- a/internal/services/tool_catalog_service.go +++ b/internal/services/tool_catalog_service.go @@ -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 { diff --git a/web/app/dashboard/_components/alert-list.tsx b/web/app/dashboard/_components/alert-list.tsx index 76e67e5..4ffd41d 100644 --- a/web/app/dashboard/_components/alert-list.tsx +++ b/web/app/dashboard/_components/alert-list.tsx @@ -5,6 +5,7 @@ import { ArrowRightIcon, ShieldAlertIcon } from "lucide-react" import type { DashboardAlert } from "@/lib/api/dashboard" import { Badge } from "@/components/ui/badge" +import { useI18n } from "@/i18n/provider" import { Card, CardContent, @@ -28,19 +29,21 @@ function getAlertBadgeVariant(level: DashboardAlert["level"]) { } export function AlertList({ alerts }: AlertListProps) { + const t = useI18n() + return ( - 风险提醒 - 优先处理会直接影响接待效率和 AI 稳定性的项目 + {t("dashboardHome.riskAlerts")} + {t("dashboardHome.riskAlertsDescription")} {alerts.length === 0 ? (
-
当前没有需要优先处理的风险项
+
{t("dashboardHome.noRiskTitle")}
- 首页将持续监控会话堆积、客服排班与 AI 配置异常 + {t("dashboardHome.noRiskDescription")}
) : ( diff --git a/web/app/dashboard/_components/dashboard-home.tsx b/web/app/dashboard/_components/dashboard-home.tsx index 3703db2..e299a68 100644 --- a/web/app/dashboard/_components/dashboard-home.tsx +++ b/web/app/dashboard/_components/dashboard-home.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { Card, CardContent } from "@/components/ui/card" +import { useI18n } from "@/i18n/provider" import { fetchDashboardOverview, type DashboardOverview, @@ -17,12 +18,6 @@ import { TrendPanel } from "./trend-panel" import { TeamLoadPanel } from "./team-load-panel" import { AlertList } from "./alert-list" -const rangeOptions: Array<{ value: DashboardRange; label: string }> = [ - { value: "today", label: "今天" }, - { value: "7d", label: "近 7 天" }, - { value: "30d", label: "近 30 天" }, -] - function LoadingCards() { return (
@@ -40,6 +35,7 @@ function LoadingCards() { } export function DashboardHome() { + const t = useI18n() const [range, setRange] = useState("7d") const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -56,27 +52,33 @@ export function DashboardHome() { const result = await fetchDashboardOverview(nextRange) setData(result) } catch (error) { - toast.error(error instanceof Error ? error.message : "加载首页概览失败") + toast.error(error instanceof Error ? error.message : t("dashboardHome.loadFailed")) } finally { setLoading(false) setRefreshing(false) } }, - [] + [t] ) useEffect(() => { void loadData(range) }, [loadData, range]) + const rangeOptions: Array<{ value: DashboardRange; label: string }> = [ + { value: "today", label: t("dashboardHome.rangeToday") }, + { value: "7d", label: t("dashboardHome.range7d") }, + { value: "30d", label: t("dashboardHome.range30d") }, + ] + return (
-

后台总览

+

{t("dashboardHome.title")}

- 聚焦会话接待、工单处理、客服负载与 AI 运行状态 - {data ? `,更新于 ${data.generatedAt}` : ""} + {t("dashboardHome.description")} + {data ? t("dashboardHome.updatedAt", { time: data.generatedAt }) : ""}

@@ -99,7 +101,7 @@ export function DashboardHome() { disabled={loading || refreshing} > - 刷新 + {t("dashboardHome.refresh")}
@@ -111,8 +113,8 @@ export function DashboardHome() { @@ -123,33 +125,33 @@ export function DashboardHome() {
-
启用中的 AI Agent
+
{t("dashboardHome.enabledAiAgents")}
{data.aiStats.enabledAiAgents}
-
启用中的接入渠道
+
{t("dashboardHome.enabledChannels")}
{data.aiStats.enabledChannels}
-
今日知识检索次数
+
{t("dashboardHome.todayKnowledgeRetrieves")}
{data.aiStats.todayKnowledgeRetrieves}
-
今日检索失败率
+
{t("dashboardHome.todayKnowledgeRetrieveFailRate")}
{data.aiStats.todayKnowledgeRetrieveFailRate.toFixed(1)}%
-
今日 Skill 失败次数
+
{t("dashboardHome.todaySkillRunFailCount")}
{data.aiStats.todaySkillRunFailCount}
-
今日 AI 转人工次数
+
{t("dashboardHome.todayAiHandoffCount")}
{data.aiStats.todayAiHandoffCount}
@@ -163,7 +165,7 @@ export function DashboardHome() { ) : ( - 暂无首页概览数据 + {t("dashboardHome.empty")} )} diff --git a/web/app/dashboard/_components/summary-cards.tsx b/web/app/dashboard/_components/summary-cards.tsx index 4b791b6..01a1418 100644 --- a/web/app/dashboard/_components/summary-cards.tsx +++ b/web/app/dashboard/_components/summary-cards.tsx @@ -17,6 +17,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { useI18n } from "@/i18n/provider" type SummaryCardsProps = { summary: DashboardOverview["summary"] @@ -24,8 +25,8 @@ type SummaryCardsProps = { type SummaryCardItem = { key: keyof DashboardOverview["summary"] - title: string - description: string + titleKey: string + descriptionKey: string link: string icon: typeof BotMessageSquareIcon format?: (value: number) => string @@ -34,36 +35,36 @@ type SummaryCardItem = { const cards: SummaryCardItem[] = [ { key: "todayNewConversations", - title: "今日新增会话", - description: "今日进入系统的新增咨询量", + titleKey: "dashboardHome.summaryTodayNewConversations", + descriptionKey: "dashboardHome.summaryTodayNewConversationsDescription", link: "/dashboard/conversations", icon: BotMessageSquareIcon, }, { key: "processingConversations", - title: "当前处理中", - description: "正在由 AI 或人工接待的会话", + titleKey: "dashboardHome.summaryProcessingConversations", + descriptionKey: "dashboardHome.summaryProcessingConversationsDescription", link: "/dashboard/conversations", icon: WavesIcon, }, { key: "pendingDispatchConversations", - title: "待分配会话", - description: "仍在待接入池中等待分配", + titleKey: "dashboardHome.summaryPendingDispatchConversations", + descriptionKey: "dashboardHome.summaryPendingDispatchConversationsDescription", link: "/dashboard/conversations", icon: CircleDashedIcon, }, { key: "onlineAgents", - title: "在线客服", - description: "近 15 分钟内仍有活跃心跳的客服", + titleKey: "dashboardHome.summaryOnlineAgents", + descriptionKey: "dashboardHome.summaryOnlineAgentsDescription", link: "/dashboard/agents", icon: HeadsetIcon, }, { key: "aiServiceRate", - title: "AI 接待占比", - description: "当前活跃会话中 AI 参与服务比例", + titleKey: "dashboardHome.summaryAiServiceRate", + descriptionKey: "dashboardHome.summaryAiServiceRateDescription", link: "/dashboard/ai-agents", icon: SparklesIcon, format: (value: number) => `${value.toFixed(1)}%`, @@ -71,6 +72,8 @@ const cards: SummaryCardItem[] = [ ] export function SummaryCards({ summary }: SummaryCardsProps) { + const t = useI18n() + return (
{cards.map((item) => { @@ -86,8 +89,8 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
- {item.title} - {item.description} + {t(item.titleKey)} + {t(item.descriptionKey)}
diff --git a/web/app/dashboard/_components/team-load-panel.tsx b/web/app/dashboard/_components/team-load-panel.tsx index 4f5527f..a358a1c 100644 --- a/web/app/dashboard/_components/team-load-panel.tsx +++ b/web/app/dashboard/_components/team-load-panel.tsx @@ -1,6 +1,7 @@ "use client" import { Badge } from "@/components/ui/badge" +import { useI18n } from "@/i18n/provider" import { Card, CardContent, @@ -25,18 +26,24 @@ function getLoadTone(loadRate: number) { } export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) { + const t = useI18n() + return ( - 客服组负载 + {t("dashboardHome.teamLoad")} - 在线 {agentStats.onlineAgents},忙碌 {agentStats.busyAgents},离线 {agentStats.offlineAgents} + {t("dashboardHome.teamLoadDescription", { + online: agentStats.onlineAgents, + busy: agentStats.busyAgents, + offline: agentStats.offlineAgents, + })} {agentStats.teamLoads.length === 0 ? (
- 暂无客服组数据 + {t("dashboardHome.noTeamData")}
) : ( agentStats.teamLoads.map((item) => ( @@ -46,20 +53,27 @@ export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
{item.teamName}
{item.hasScheduleNow ? ( - 排班中 + {t("dashboardHome.scheduled")} ) : ( - 无当前排班 + {t("dashboardHome.notScheduled")} )}
- 总客服 {item.totalAgents},在线 {item.onlineAgents},忙碌 {item.busyAgents},离线{" "} - {item.offlineAgents} + {t("dashboardHome.teamStats", { + total: item.totalAgents, + online: item.onlineAgents, + busy: item.busyAgents, + offline: item.offlineAgents, + })}
{item.loadRate.toFixed(1)}%
- 负载 {item.processingConversations}/{item.maxConcurrentCapacity || 0} + {t("dashboardHome.load", { + processing: item.processingConversations, + capacity: item.maxConcurrentCapacity || 0, + })}
@@ -73,19 +87,19 @@ export function TeamLoadPanel({ agentStats }: TeamLoadPanelProps) {
-
待接入
+
{t("dashboardHome.waiting")}
{item.waitingConversations}
-
处理中
+
{t("dashboardHome.processing")}
{item.processingConversations}
-
并发容量
+
{t("dashboardHome.capacity")}
{item.maxConcurrentCapacity}
-
忙碌客服
+
{t("dashboardHome.busyAgents")}
{item.busyAgents}
diff --git a/web/app/dashboard/_components/trend-panel.tsx b/web/app/dashboard/_components/trend-panel.tsx index cf111e2..209db4d 100644 --- a/web/app/dashboard/_components/trend-panel.tsx +++ b/web/app/dashboard/_components/trend-panel.tsx @@ -16,6 +16,7 @@ import { ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" +import { useI18n } from "@/i18n/provider" type TrendPanelProps = { title: string @@ -24,33 +25,37 @@ type TrendPanelProps = { distribution: DashboardStatusDistributionItem[] } -const trendConfig = { - newCount: { - label: "新增", - color: "hsl(24 95% 53%)", - }, - closedCount: { - label: "关闭", - color: "hsl(190 95% 39%)", - }, -} satisfies ChartConfig - -const distributionConfig = { - count: { - label: "数量", - theme: { - light: "hsl(222 47% 11%)", - dark: "hsl(210 40% 98%)", - }, - }, -} satisfies ChartConfig - export function TrendPanel({ title, description, trend, distribution, }: TrendPanelProps) { + const t = useI18n() + const trendConfig = { + newCount: { + label: t("dashboardHome.chartNew"), + color: "hsl(24 95% 53%)", + }, + closedCount: { + label: t("dashboardHome.chartClosed"), + color: "hsl(190 95% 39%)", + }, + } satisfies ChartConfig + const distributionConfig = { + count: { + label: t("dashboardHome.chartCount"), + theme: { + light: "hsl(222 47% 11%)", + dark: "hsl(210 40% 98%)", + }, + }, + } satisfies ChartConfig + const localizedDistribution = distribution.map((item) => ({ + ...item, + label: getStatusLabel(item.status, item.label, t), + })) + return (
@@ -87,12 +92,12 @@ export function TrendPanel({ - 状态分布 - 当前状态数量分布 + {t("dashboardHome.statusDistribution")} + {t("dashboardHome.statusDistributionDescription")} - + ) } + +function getStatusLabel( + status: number, + fallback: string, + t: (key: string) => string +) { + switch (status) { + case 1: + return t("dashboardHome.statusAiServing") + case 2: + return t("dashboardHome.statusPending") + case 3: + return t("dashboardHome.statusActive") + case 4: + return t("dashboardHome.statusClosed") + default: + return fallback + } +} diff --git a/web/app/dashboard/agent-run-logs/_components/detail.tsx b/web/app/dashboard/agent-run-logs/_components/detail.tsx index 8ddc54a..edc7180 100644 --- a/web/app/dashboard/agent-run-logs/_components/detail.tsx +++ b/web/app/dashboard/agent-run-logs/_components/detail.tsx @@ -9,6 +9,7 @@ import { JsonTreeViewer } from "@/components/json-tree-viewer" import { ProjectDialog } from "@/components/project-dialog" import { Button } from "@/components/ui/button" import { fetchAgentRunLog, type AgentRunLog } from "@/lib/api/admin" +import { useI18n } from "@/i18n/provider" import { formatDateTime } from "@/lib/utils" type AgentRunLogDetailDialogProps = { @@ -17,11 +18,14 @@ type AgentRunLogDetailDialogProps = { onOpenChange: (open: boolean) => void } +type TFunction = (key: string, values?: Record) => string + export function AgentRunLogDetailDialog({ open, logId, onOpenChange, }: AgentRunLogDetailDialogProps) { + const t = useI18n() const [loading, setLoading] = useState(false) const [activeLog, setActiveLog] = useState(null) @@ -42,7 +46,7 @@ export function AgentRunLogDetailDialog({ } } catch (error) { if (!cancelled) { - toast.error(error instanceof Error ? error.message : "加载日志详情失败") + toast.error(error instanceof Error ? error.message : t("agentRunLog.loadDetailFailed")) onOpenChange(false) } } finally { @@ -57,7 +61,7 @@ export function AgentRunLogDetailDialog({ return () => { cancelled = true } - }, [logId, onOpenChange, open]) + }, [logId, onOpenChange, open, t]) useEffect(() => { if (open) { @@ -87,35 +91,35 @@ export function AgentRunLogDetailDialog({ title={ - Agent 运行详情 + {t("agentRunLog.detailTitle")} } - description="查看 planner 选择、最终动作、回复内容与错误信息。" + description={t("agentRunLog.detailDescription")} size="xl" allowFullscreen defaultFullscreen bodyClassName="min-h-0" footer={ } > {loading ? ( -
加载中...
+
{t("agentRunLog.loading")}
) : activeLog ? ( <> } - title="用户问题" + title={t("agentRunLog.userMessage")} value={activeLog.userMessage} renderAsHtml /> } - title="机器人回复" + title={t("agentRunLog.botReply")} value={activeLog.replyText} /> - + ) : ( -
未找到详情数据
+
{t("agentRunLog.notFound")}
)} ) } +function getHitlStatusLabel(status: string | undefined, t: TFunction) { + switch (status) { + case "pending": + return t("agentRunLog.hitlPending") + case "confirmed": + return t("agentRunLog.hitlConfirmed") + case "cancelled": + return t("agentRunLog.hitlCancelled") + case "expired": + return t("agentRunLog.hitlExpired") + case "triggered": + return t("agentRunLog.hitlTriggered") + default: + return "" + } +} + +function getHitlSummary(status: string | undefined, t: TFunction) { + switch (status) { + case "pending": + return t("agentRunLog.hitlPendingSummary") + case "confirmed": + return t("agentRunLog.hitlConfirmedSummary") + case "cancelled": + return t("agentRunLog.hitlCancelledSummary") + case "expired": + return t("agentRunLog.hitlExpiredSummary") + case "triggered": + return t("agentRunLog.hitlTriggeredSummary") + default: + return "" + } +} + function safeParseJSON(value: string) { if (!value.trim()) { return null diff --git a/web/app/dashboard/agent-run-logs/page.tsx b/web/app/dashboard/agent-run-logs/page.tsx index a7d6f59..f8e16b3 100644 --- a/web/app/dashboard/agent-run-logs/page.tsx +++ b/web/app/dashboard/agent-run-logs/page.tsx @@ -22,36 +22,62 @@ import { type AgentRunLog, type PageResult, } from "@/lib/api/admin" +import { useI18n } from "@/i18n/provider" import { formatDateTime } from "@/lib/utils" -const actionOptions = [ - { value: "all", label: "全部动作" }, - { value: "rag", label: "RAG" }, - { value: "skill", label: "Skill" }, - { value: "tool", label: "Tool" }, - { value: "graph", label: "Graph" }, - { value: "handoff", label: "转人工" }, - { value: "reply", label: "回复" }, - { value: "fallback", label: "兜底" }, -] +type TFunction = (key: string, values?: Record) => string -const finalStatusOptions = [ - { value: "all", label: "全部状态" }, - { value: "completed", label: "completed" }, - { value: "interrupted", label: "interrupted" }, - { value: "expired", label: "expired" }, - { value: "error", label: "error" }, - { value: "fallback", label: "fallback" }, -] +function getActionOptions(t: TFunction) { + return [ + { value: "all", label: t("agentRunLog.allActions") }, + { value: "rag", label: "RAG" }, + { value: "skill", label: "Skill" }, + { value: "tool", label: "Tool" }, + { value: "graph", label: "Graph" }, + { value: "handoff", label: t("agentRunLog.handoff") }, + { value: "reply", label: t("agentRunLog.reply") }, + { value: "fallback", label: t("agentRunLog.fallback") }, + ] +} -const hitlStatusOptions = [ - { value: "all", label: "全部 HITL" }, - { value: "pending", label: "等待确认" }, - { value: "confirmed", label: "已确认" }, - { value: "cancelled", label: "已取消" }, - { value: "expired", label: "已过期" }, - { value: "triggered", label: "已触发" }, -] +function getFinalStatusOptions(t: TFunction) { + return [ + { value: "all", label: t("agentRunLog.allStatus") }, + { value: "completed", label: "completed" }, + { value: "interrupted", label: "interrupted" }, + { value: "expired", label: "expired" }, + { value: "error", label: "error" }, + { value: "fallback", label: "fallback" }, + ] +} + +function getHitlStatusOptions(t: TFunction) { + return [ + { value: "all", label: t("agentRunLog.allHitl") }, + { value: "pending", label: t("agentRunLog.hitlPending") }, + { value: "confirmed", label: t("agentRunLog.hitlConfirmed") }, + { value: "cancelled", label: t("agentRunLog.hitlCancelled") }, + { value: "expired", label: t("agentRunLog.hitlExpired") }, + { value: "triggered", label: t("agentRunLog.hitlTriggered") }, + ] +} + +function getHitlStatusLabel(status: string | undefined, t: TFunction) { + switch (status) { + case "pending": + return t("agentRunLog.hitlPending") + case "confirmed": + return t("agentRunLog.hitlConfirmed") + case "cancelled": + return t("agentRunLog.hitlCancelled") + case "expired": + return t("agentRunLog.hitlExpired") + case "triggered": + return t("agentRunLog.hitlTriggered") + default: + return "" + } +} function actionBadgeVariant(action: string) { switch (action) { @@ -73,6 +99,7 @@ function actionBadgeVariant(action: string) { } export default function DashboardAgentRunLogsPage() { + const t = useI18n() const [keywordInput, setKeywordInput] = useState("") const [plannedActionInput, setPlannedActionInput] = useState("all") const [finalActionInput, setFinalActionInput] = useState("all") @@ -95,16 +122,19 @@ export default function DashboardAgentRunLogsPage() { page: { page: 1, limit: 20, total: 0 }, }) const [aiAgents, setAiAgents] = useState([]) + const actionOptions = useMemo(() => getActionOptions(t), [t]) + const finalStatusOptions = useMemo(() => getFinalStatusOptions(t), [t]) + const hitlStatusOptions = useMemo(() => getHitlStatusOptions(t), [t]) const aiAgentOptions = useMemo( () => [ - { value: "all", label: "全部 Agent" }, + { value: "all", label: t("agentRunLog.allAgents") }, ...aiAgents.map((item) => ({ value: String(item.id), label: item.name, })), ], - [aiAgents] + [aiAgents, t] ) const loadData = useCallback(async () => { @@ -122,11 +152,11 @@ export default function DashboardAgentRunLogsPage() { }) setResult(data) } catch (error) { - toast.error(error instanceof Error ? error.message : "加载 Agent 运行日志失败") + toast.error(error instanceof Error ? error.message : t("agentRunLog.loadFailed")) } finally { setLoading(false) } - }, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction]) + }, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction, t]) useEffect(() => { void loadData() @@ -138,11 +168,11 @@ export default function DashboardAgentRunLogsPage() { const data = await fetchAIAgentsAll() setAiAgents(data) } catch (error) { - toast.error(error instanceof Error ? error.message : "加载 AI Agent 列表失败") + toast.error(error instanceof Error ? error.message : t("agentRunLog.loadAgentsFailed")) } } void loadAIAgents() - }, []) + }, [t]) function applyFilters() { setKeyword(keywordInput) @@ -174,7 +204,7 @@ export default function DashboardAgentRunLogsPage() { className="w-full xl:w-auto" > - 刷新 + {t("agentRunLog.refresh")} } > @@ -184,7 +214,7 @@ export default function DashboardAgentRunLogsPage() { value={keywordInput} onChange={(event) => setKeywordInput(event.target.value)} onKeyDown={handleFilterKeyDown} - placeholder="按用户问题筛选" + placeholder={t("agentRunLog.filterUserMessage")} className="pl-9" />
@@ -192,9 +222,9 @@ export default function DashboardAgentRunLogsPage() { setPlannedActionInput(value || "all")} />
@@ -202,9 +232,9 @@ export default function DashboardAgentRunLogsPage() { setFinalActionInput(value || "all")} />
@@ -212,9 +242,9 @@ export default function DashboardAgentRunLogsPage() { setFinalStatusInput(value || "all")} />
@@ -222,9 +252,9 @@ export default function DashboardAgentRunLogsPage() { setHitlStatusInput(value || "all")} /> @@ -232,15 +262,15 @@ export default function DashboardAgentRunLogsPage() { setAiAgentIdInput(value || "all")} /> @@ -261,18 +291,18 @@ export default function DashboardAgentRunLogsPage() { > {!loading && result.results.length === 0 ? (
- 暂无 Agent 运行日志 + {t("agentRunLog.emptyRows")}
) : (
-
时间
-
用户问题
-
规划动作
-
Skill / Tool
-
最终状态
-
耗时
-
操作
+
{t("agentRunLog.time")}
+
{t("agentRunLog.userMessage")}
+
{t("agentRunLog.plannedAction")}
+
{t("agentRunLog.skillTool")}
+
{t("agentRunLog.finalStatus")}
+
{t("agentRunLog.duration")}
+
{t("agentRunLog.actions")}
@@ -286,7 +316,7 @@ export default function DashboardAgentRunLogsPage() {
- + {item.errorMessage ? (
{item.errorMessage}
) : null} @@ -310,13 +340,13 @@ export default function DashboardAgentRunLogsPage() {
) : item.handoffReason ? (
- 转人工原因:{item.handoffReason} + {t("agentRunLog.handoffReason", { reason: item.handoffReason })}
) : item.recommendedAction ? (
- 分流建议:{item.recommendedAction} + {t("agentRunLog.routingRecommendation", { action: item.recommendedAction })} {item.riskLevel ? ` / ${item.riskLevel} risk` : ""} - {item.ticketDraftReady ? " / 草稿已就绪" : ""} + {item.ticketDraftReady ? ` / ${t("agentRunLog.draftReady")}` : ""}
) : null}
@@ -331,8 +361,8 @@ export default function DashboardAgentRunLogsPage() { {item.finalAction || "-"}
- {item.hitlStatusName - ? `${item.hitlStatusName} / ${item.finalStatus || "-"}` + {getHitlStatusLabel(item.hitlStatus, t) + ? `${getHitlStatusLabel(item.hitlStatus, t)} / ${item.finalStatus || "-"}` : item.finalStatus || "-"}
@@ -351,7 +381,7 @@ export default function DashboardAgentRunLogsPage() { setDetailOpen(true) }} > - 详情 + {t("agentRunLog.detail")} @@ -375,8 +405,8 @@ export default function DashboardAgentRunLogsPage() { ) } -function UserMessagePreview({ value }: { value?: string }) { - const preview = useMemo(() => summarizeUserMessage(value), [value]) +function UserMessagePreview({ value, t }: { value?: string; t: TFunction }) { + const preview = useMemo(() => summarizeUserMessage(value, t), [value, t]) return (
@@ -385,7 +415,7 @@ function UserMessagePreview({ value }: { value?: string }) { ) } -function summarizeUserMessage(value?: string) { +function summarizeUserMessage(value: string | undefined, t: TFunction) { const normalized = value?.trim() if (!normalized) { return "-" @@ -396,9 +426,9 @@ function summarizeUserMessage(value?: string) { } if (containsHTML(normalized)) { if (/]/i.test(normalized)) { - return "[图片]" + return t("agentRunLog.imageMessage") } - return "[富文本消息]" + return t("agentRunLog.richMessage") } return normalized } diff --git a/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx b/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx index a1da59a..a596f86 100644 --- a/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx @@ -34,6 +34,7 @@ import { type AdminAgentTeamScheduleBatchPreview, type BatchAdminAgentTeamSchedulePayload, } from "@/lib/api/admin" +import { useI18n } from "@/i18n/provider" import { cn } from "@/lib/utils" type BatchScheduleDialogProps = { @@ -42,15 +43,24 @@ type BatchScheduleDialogProps = { onSuccess: (created: number) => void | Promise } -const weekdayOptions = [ - { value: 1, label: "周一" }, - { value: 2, label: "周二" }, - { value: 3, label: "周三" }, - { value: 4, label: "周四" }, - { value: 5, label: "周五" }, - { value: 6, label: "周六" }, - { value: 7, label: "周日" }, -] +type TFunction = (key: string, values?: Record) => string + +const weekdayKeys = [ + "weekdayMon", + "weekdayTue", + "weekdayWed", + "weekdayThu", + "weekdayFri", + "weekdaySat", + "weekdaySun", +] as const + +function getWeekdayOptions(t: TFunction) { + return weekdayKeys.map((key, index) => ({ + value: index + 1, + label: t(`agentTeamSchedule.${key}`), + })) +} function todayDateValue() { const today = new Date() @@ -88,32 +98,33 @@ function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload } } -function getWeekdayLabel(value: number) { - return weekdayOptions.find((option) => option.value === value)?.label ?? `周${value}` +function getWeekdayLabel(value: number, t: TFunction) { + const weekdayOptions = getWeekdayOptions(t) + return weekdayOptions.find((option) => option.value === value)?.label ?? String(value) } -function validateForm(form: BatchFormState) { +function validateForm(form: BatchFormState, t: TFunction) { const today = todayDateValue() if (form.selectedTeamIds.length === 0) { - return "请选择至少一个客服组" + return t("agentTeamSchedule.selectAtLeastOneTeam") } if (!form.startDate || !form.endDate) { - return "请选择日期范围" + return t("agentTeamSchedule.selectDateRange") } if (form.startDate < today) { - return "开始日期不能早于今天" + return t("agentTeamSchedule.startBeforeToday") } if (form.endDate < form.startDate) { - return "结束日期不能早于开始日期" + return t("agentTeamSchedule.endBeforeStart") } if (form.weekdays.length === 0) { - return "请选择至少一个星期" + return t("agentTeamSchedule.selectAtLeastOneWeekday") } if (!form.startTime || !form.endTime) { - return "请选择开始和结束时间" + return t("agentTeamSchedule.selectStartEndTime") } if (form.endTime <= form.startTime) { - return "结束时间必须晚于开始时间" + return t("agentTeamSchedule.endAfterStart") } return "" } @@ -123,6 +134,7 @@ export function BatchScheduleDialog({ onOpenChange, onSuccess, }: BatchScheduleDialogProps) { + const t = useI18n() const [teams, setTeams] = useState([]) const [form, setForm] = useState(defaultFormState) const [step, setStep] = useState("form") @@ -134,6 +146,7 @@ export function BatchScheduleDialog({ const openRef = useRef(open) const previewRequestIdRef = useRef(0) const busy = loadingTeams || previewing || submitting + const weekdayOptions = useMemo(() => getWeekdayOptions(t), [t]) const teamOptions = useMemo( () => @@ -181,7 +194,7 @@ export function BatchScheduleDialog({ } } catch (error) { if (!ignore) { - toast.error(error instanceof Error ? error.message : "加载客服组选项失败") + toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.loadTeamsFailed")) } } finally { if (!ignore) { @@ -194,7 +207,7 @@ export function BatchScheduleDialog({ return () => { ignore = true } - }, [open]) + }, [open, t]) function updateForm(values: Partial) { previewRequestIdRef.current += 1 @@ -234,7 +247,7 @@ export function BatchScheduleDialog({ } async function handlePreview() { - const validationMessage = validateForm(form) + const validationMessage = validateForm(form, t) if (validationMessage) { toast.error(validationMessage) return @@ -254,7 +267,7 @@ export function BatchScheduleDialog({ setStep("preview") } catch (error) { if (openRef.current && previewRequestIdRef.current === requestId) { - toast.error(error instanceof Error ? error.message : "预览批量排班失败") + toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.previewFailed")) } } finally { if (openRef.current && previewRequestIdRef.current === requestId) { @@ -265,11 +278,11 @@ export function BatchScheduleDialog({ async function handleSubmit() { if (!preview || !previewPayload) { - toast.error("请先预览批量排班") + toast.error(t("agentTeamSchedule.previewFirst")) return } if (preview.conflict) { - toast.error("存在冲突排班,不能提交") + toast.error(t("agentTeamSchedule.conflictCannotSubmit")) return } @@ -277,17 +290,17 @@ export function BatchScheduleDialog({ setSubmitting(true) try { const data = await generateAgentTeamScheduleBatch(payload) - toast.success(`已创建 ${data.created} 条客服组排班`) + toast.success(t("agentTeamSchedule.batchCreated", { count: data.created })) setPreview(null) setPreviewPayload(null) onOpenChange(false) try { await onSuccess(data.created) } catch (error) { - toast.error(error instanceof Error ? `排班已生成,但刷新列表失败:${error.message}` : "排班已生成,但刷新列表失败") + toast.error(error instanceof Error ? t("agentTeamSchedule.batchRefreshFailed", { message: error.message }) : t("agentTeamSchedule.batchRefreshFailedFallback")) } } catch (error) { - toast.error(error instanceof Error ? error.message : "生成批量排班失败") + toast.error(error instanceof Error ? error.message : t("agentTeamSchedule.generateFailed")) } finally { setSubmitting(false) } @@ -297,9 +310,9 @@ export function BatchScheduleDialog({ - 批量排班 + {t("agentTeamSchedule.batch")} - 选择客服组、日期范围和工作时间,先预览后生成排班。 + {t("agentTeamSchedule.batchDescription")} @@ -307,15 +320,15 @@ export function BatchScheduleDialog({ {step === "form" ? (
- +
@@ -332,7 +345,7 @@ export function BatchScheduleDialog({ size="icon-sm" className="size-5 rounded-sm" onClick={() => removeTeam(team.id)} - aria-label={`移除${team.name}`} + aria-label={t("agentTeamSchedule.removeTeam", { name: team.name })} > @@ -340,13 +353,13 @@ export function BatchScheduleDialog({ ))}
) : ( -
尚未选择客服组
+
{t("agentTeamSchedule.noSelectedTeams")}
)}
- +
- +
- +
{weekdayOptions.map((option) => { const selected = selectedWeekdays.has(option.value) @@ -391,7 +404,7 @@ export function BatchScheduleDialog({
- +
- +
- +