Refactor localization strings to English across various services and UI components

- Updated titles and subtitles in channel_service.go for web channel and WeChat MP channel configurations.
- Changed handoff messages in conversation_human_dispatch_service.go to English.
- Modified notification messages in event handlers for ticket and conversation assignments to English.
- Adjusted ticket creation messages in ticket_service.go to reflect English localization.
- Updated default locale settings in i18n configuration files to English (en-US).
- Changed HTML language attribute in layout.tsx to English.
- Refactored widget locale detection logic in agent-desk-sdk.ts to prioritize English.
This commit is contained in:
mlogclub
2026-05-31 20:06:44 +08:00
parent bcf2ee38c0
commit b128447a7e
36 changed files with 315 additions and 164 deletions
@@ -183,13 +183,13 @@ func recommendNextAction(intent string, signals []string, input AnalyzeConversat
func recommendQuestions(intent string, signals []string, input AnalyzeConversationInput) []string { func recommendQuestions(intent string, signals []string, input AnalyzeConversationInput) []string {
questions := make([]string, 0, 3) questions := make([]string, 0, 3)
if containsSignal(signals, "ticket_expected") && strings.TrimSpace(input.ObservedIssue) == "" { if containsSignal(signals, "ticket_expected") && strings.TrimSpace(input.ObservedIssue) == "" {
questions = append(questions, "请进一步确认用户遇到的具体问题现象、报错信息和期望处理结果。") questions = append(questions, "Please confirm the specific issue, error message, and expected outcome.")
} }
if containsSignal(signals, "handoff_requested") { if containsSignal(signals, "handoff_requested") {
questions = append(questions, "请确认用户是否明确要求人工客服,以及当前问题为何需要人工继续处理。") questions = append(questions, "Please confirm whether the user explicitly requested human support and why the issue needs human handling.")
} }
if intent == "complaint" { if intent == "complaint" {
questions = append(questions, "请确认投诉点、影响范围和用户当前最希望解决的事项。") questions = append(questions, "Please confirm the complaint, impact, and the user's most important desired outcome.")
} }
return questions return questions
} }
@@ -89,7 +89,7 @@ func (g *CreateTicketGraph) Run(ctx context.Context, argumentsInJSON string) (st
Handled: true, Handled: true,
Terminal: true, Terminal: true,
Action: "ticket_created", Action: "ticket_created",
ReplyText: fmt.Sprintf("工单已创建,工单号:%s,标题:%s", strings.TrimSpace(item.TicketNo), strings.TrimSpace(item.Title)), ReplyText: fmt.Sprintf("Ticket created. Ticket no: %s. Title: %s.", strings.TrimSpace(item.TicketNo), strings.TrimSpace(item.Title)),
ShouldRetry: false, ShouldRetry: false,
}), nil }), nil
case ConfirmationDecisionCancel: case ConfirmationDecisionCancel:
@@ -134,7 +134,7 @@ func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request.
} }
func (g *CreateTicketGraph) buildConfirmationPrompt(req request.CreateTicketFromConversationRequest) string { func (g *CreateTicketGraph) buildConfirmationPrompt(req request.CreateTicketFromConversationRequest) string {
return fmt.Sprintf("我准备为你创建工单。\n标题:%s\n描述:%s\n请直接回复“确认”或“取消”。", return fmt.Sprintf("I am ready to create a ticket for you.\nTitle: %s\nDescription: %s\nPlease reply with \"Confirm\" or \"Cancel\".",
strings.TrimSpace(req.Title), strings.TrimSpace(req.Description)) strings.TrimSpace(req.Title), strings.TrimSpace(req.Description))
} }
+2 -2
View File
@@ -122,7 +122,7 @@ func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string,
} }
func (g *HandoffGraph) buildReason(argumentsInJSON string) (string, error) { func (g *HandoffGraph) buildReason(argumentsInJSON string) (string, error) {
reason := "用户需要转人工支持" reason := "The user needs human support."
var args handoffGraphArgs var args handoffGraphArgs
if strings.TrimSpace(argumentsInJSON) != "" { if strings.TrimSpace(argumentsInJSON) != "" {
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil { if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
@@ -136,7 +136,7 @@ func (g *HandoffGraph) buildReason(argumentsInJSON string) (string, error) {
} }
func (g *HandoffGraph) buildConfirmationPrompt(reason string) string { func (g *HandoffGraph) buildConfirmationPrompt(reason string) string {
return fmt.Sprintf("我准备为你转接人工客服。\n原因:%s\n请直接回复“确认”或“取消”。", strings.TrimSpace(reason)) return fmt.Sprintf("I am ready to connect you to a human support agent.\nReason: %s\nPlease reply with \"Confirm\" or \"Cancel\".", strings.TrimSpace(reason))
} }
func parseHandoffDecision(value string) ConfirmationDecision { func parseHandoffDecision(value string) ConfirmationDecision {
+11 -8
View File
@@ -5,11 +5,11 @@ import "strings"
const ( const (
InterruptTypeTicketCreationConfirmation = "ticket_creation_confirmation" InterruptTypeTicketCreationConfirmation = "ticket_creation_confirmation"
InterruptTypeHandoffConfirmation = "handoff_confirmation" InterruptTypeHandoffConfirmation = "handoff_confirmation"
ConfirmOrCancelPrompt = "请回复“确认”或“取消”。" ConfirmOrCancelPrompt = `Please reply with "Confirm" or "Cancel".`
NeedExplicitConfirmationPrompt = "我需要你的明确确认,请直接回复“确认”或“取消”。" NeedExplicitConfirmationPrompt = `I need your explicit confirmation. Please reply with "Confirm" or "Cancel".`
ConfirmationExpiredReply = "本次确认已失效,请重新发起。" ConfirmationExpiredReply = "This confirmation has expired. Please start again."
CancelCreateTicketReply = "已取消本次工单创建。" CancelCreateTicketReply = "Ticket creation has been cancelled."
CancelHandoffReply = "已取消本次转人工。" CancelHandoffReply = "Human handoff has been cancelled."
) )
type ConfirmationDecision string type ConfirmationDecision string
@@ -24,13 +24,13 @@ func ParseConfirmationDecision(value string) ConfirmationDecision {
if value == "" { if value == "" {
return "" return ""
} }
confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "继续", "同意"} confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "continue", "confirm", "继续", "同意"}
for _, item := range confirmWords { for _, item := range confirmWords {
if strings.Contains(value, item) { if strings.Contains(value, item) {
return ConfirmationDecisionConfirm return ConfirmationDecisionConfirm
} }
} }
cancelWords := []string{"取消", "不用", "不需要", "算了", "no"} cancelWords := []string{"取消", "不用", "不需要", "算了", "no", "cancel"}
for _, item := range cancelWords { for _, item := range cancelWords {
if strings.Contains(value, item) { if strings.Contains(value, item) {
return ConfirmationDecisionCancel return ConfirmationDecisionCancel
@@ -41,5 +41,8 @@ func ParseConfirmationDecision(value string) ConfirmationDecision {
func IsCancellationReply(replyText string) bool { func IsCancellationReply(replyText string) bool {
replyText = strings.TrimSpace(replyText) replyText = strings.TrimSpace(replyText)
return strings.Contains(replyText, CancelCreateTicketReply) || strings.Contains(replyText, CancelHandoffReply) return strings.Contains(replyText, CancelCreateTicketReply) ||
strings.Contains(replyText, CancelHandoffReply) ||
strings.Contains(replyText, "已取消本次工单创建。") ||
strings.Contains(replyText, "已取消本次转人工。")
} }
@@ -78,11 +78,11 @@ func buildPrepareTicketDraftResult(conversation models.Conversation, messages []
result.Description = buildDraftDescription(conversation, messages, input) result.Description = buildDraftDescription(conversation, messages, input)
if strings.TrimSpace(result.Title) == "" { if strings.TrimSpace(result.Title) == "" {
result.MissingFields = append(result.MissingFields, "title") result.MissingFields = append(result.MissingFields, "title")
result.FollowUpQuestions = append(result.FollowUpQuestions, "请补充一个简洁的工单标题,明确概括用户遇到的问题。") result.FollowUpQuestions = append(result.FollowUpQuestions, "Please provide a concise ticket title that clearly summarizes the issue.")
} }
if !hasSufficientIssueContext(input, result.Description) { if !hasSufficientIssueContext(input, result.Description) {
result.MissingFields = append(result.MissingFields, "issue") result.MissingFields = append(result.MissingFields, "issue")
result.FollowUpQuestions = append(result.FollowUpQuestions, "请补充具体问题现象、报错信息或用户诉求,以便整理成工单。") result.FollowUpQuestions = append(result.FollowUpQuestions, "Please provide the specific issue, error message, or request so I can prepare the ticket.")
} }
result.Ready = result.Title != "" && result.Description != "" && len(result.MissingFields) == 0 result.Ready = result.Title != "" && result.Description != "" && len(result.MissingFields) == 0
return result return result
@@ -107,22 +107,22 @@ func buildDraftDescription(conversation models.Conversation, messages []models.M
} }
parts := make([]string, 0, 6) parts := make([]string, 0, 6)
if input.Issue != "" { if input.Issue != "" {
parts = append(parts, "问题现象:"+input.Issue) parts = append(parts, "Issue: "+input.Issue)
} }
if input.Impact != "" { if input.Impact != "" {
parts = append(parts, "影响范围:"+input.Impact) parts = append(parts, "Impact: "+input.Impact)
} }
if input.ExpectedOutcome != "" { if input.ExpectedOutcome != "" {
parts = append(parts, "用户诉求:"+input.ExpectedOutcome) parts = append(parts, "Requested outcome: "+input.ExpectedOutcome)
} }
if input.CurrentAttempt != "" { if input.CurrentAttempt != "" {
parts = append(parts, "已尝试处理:"+input.CurrentAttempt) parts = append(parts, "Attempts so far: "+input.CurrentAttempt)
} }
if strings.TrimSpace(conversation.LastMessageSummary) != "" { if strings.TrimSpace(conversation.LastMessageSummary) != "" {
parts = append(parts, "会话摘要:"+strings.TrimSpace(conversation.LastMessageSummary)) parts = append(parts, "Conversation summary: "+strings.TrimSpace(conversation.LastMessageSummary))
} }
if recent := buildRecentMessageDigest(messages); recent != "" { if recent := buildRecentMessageDigest(messages); recent != "" {
parts = append(parts, "最近消息:"+recent) parts = append(parts, "Recent messages: "+recent)
} }
return strings.TrimSpace(strings.Join(parts, "\n")) return strings.TrimSpace(strings.Join(parts, "\n"))
} }
@@ -137,10 +137,10 @@ func hasSufficientIssueContext(input PrepareTicketDraftInput, description string
func buildConversationFacts(conversation models.Conversation, messages []models.Message) []string { func buildConversationFacts(conversation models.Conversation, messages []models.Message) []string {
facts := make([]string, 0, 4) facts := make([]string, 0, 4)
if strings.TrimSpace(conversation.LastMessageSummary) != "" { if strings.TrimSpace(conversation.LastMessageSummary) != "" {
facts = append(facts, "最近摘要:"+strings.TrimSpace(conversation.LastMessageSummary)) facts = append(facts, "Recent summary: "+strings.TrimSpace(conversation.LastMessageSummary))
} }
if digest := buildRecentMessageDigest(messages); digest != "" { if digest := buildRecentMessageDigest(messages); digest != "" {
facts = append(facts, "最近消息:"+digest) facts = append(facts, "Recent messages: "+digest)
} }
return facts return facts
} }
@@ -163,13 +163,13 @@ func buildRecentMessageDigest(messages []models.Message) string {
func messageSenderLabel(senderType enums.IMSenderType) string { func messageSenderLabel(senderType enums.IMSenderType) string {
switch senderType { switch senderType {
case enums.IMSenderTypeCustomer: case enums.IMSenderTypeCustomer:
return "用户" return "Customer"
case enums.IMSenderTypeAgent: case enums.IMSenderTypeAgent:
return "客服" return "Agent"
case enums.IMSenderTypeAI: case enums.IMSenderTypeAI:
return "AI" return "AI"
default: default:
return "消息" return "Message"
} }
} }
@@ -39,7 +39,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models
func resolveInterruptPrompt(summary *applicationruntime.Summary) string { func resolveInterruptPrompt(summary *applicationruntime.Summary) string {
if summary == nil || len(summary.Interrupts) == 0 { if summary == nil || len(summary.Interrupts) == 0 {
return "请继续补充信息后再试。" return "Please provide more information and try again."
} }
if prompt := extractInterruptMessage(summary.Interrupts[0].InfoPreview); prompt != "" { if prompt := extractInterruptMessage(summary.Interrupts[0].InfoPreview); prompt != "" {
return prompt return prompt
@@ -47,7 +47,7 @@ func resolveInterruptPrompt(summary *applicationruntime.Summary) string {
if prompt := strings.TrimSpace(summary.Interrupts[0].InfoPreview); prompt != "" { if prompt := strings.TrimSpace(summary.Interrupts[0].InfoPreview); prompt != "" {
return prompt return prompt
} }
return "请继续补充信息后再试。" return "Please provide more information and try again."
} }
func extractInterruptMessage(infoPreview string) string { func extractInterruptMessage(infoPreview string) string {
@@ -48,7 +48,7 @@ func (t *AnalyzeConversationTool) Build(ctx registry.Context) (einotool.BaseTool
func (t *AnalyzeConversationTool) Info(ctx context.Context) (*schema.ToolInfo, error) { func (t *AnalyzeConversationTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{ return &schema.ToolInfo{
Name: toolx.GraphAnalyzeConversation.Name, Name: toolx.GraphAnalyzeConversation.Name,
Desc: "Graph Tool。用于整理当前对话摘要、识别投诉/资金/情绪等风险信号,并给出继续解答、建单或转人工的建议。", Desc: "Graph Tool. Summarizes the current conversation, identifies complaint/payment/sentiment risk signals, and recommends whether to continue answering, create a ticket, or hand off to a human.",
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
Version: einojsonschema.Version, Version: einojsonschema.Version,
Type: "object", Type: "object",
@@ -57,42 +57,42 @@ func (t *AnalyzeConversationTool) Info(ctx context.Context) (*schema.ToolInfo, e
Key: "goal", Key: "goal",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "当前分析目标,例如判断是否需要转人工、是否需要建单、是否需要做风险质检。", Description: "Analysis goal, such as whether to hand off to a human, create a ticket, or perform risk review.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "observedIssue", Key: "observedIssue",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "当前观察到的主要问题或诉求。", Description: "Main issue or request observed in the conversation.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "needTicket", Key: "needTicket",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "boolean", Type: "boolean",
Description: "是否重点评估建单必要性。", Description: "Whether to focus on evaluating ticket creation.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "needHumanHandoff", Key: "needHumanHandoff",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "boolean", Type: "boolean",
Description: "是否重点评估转人工必要性。", Description: "Whether to focus on evaluating human handoff.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "needQualityCheck", Key: "needQualityCheck",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "boolean", Type: "boolean",
Description: "是否重点做风险/质检分析。", Description: "Whether to focus on risk or quality review.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "additionalContext", Key: "additionalContext",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "补充上下文,例如你已发现的争议点、投诉点或业务限制。", Description: "Additional context, such as disputes, complaint points, or business constraints already identified.",
}, },
}, },
)), )),
@@ -52,7 +52,7 @@ func (t *CreateTicketGraphTool) Build(ctx registry.Context) (einotool.BaseTool,
func (t *CreateTicketGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) { func (t *CreateTicketGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{ return &schema.ToolInfo{
Name: toolx.GraphCreateTicketConfirm.Name, Name: toolx.GraphCreateTicketConfirm.Name,
Desc: "Graph Tool。用于封装建单参数整理、用户确认、真正创建工单和结果返回的确定性流程。仅在用户明确要求建单且标题、描述已整理清楚后调用。", Desc: "Graph Tool. Handles ticket parameter preparation, user confirmation, actual ticket creation, and result return. Use only when the user explicitly asks to create a ticket and the title and description are clear.",
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
Version: einojsonschema.Version, Version: einojsonschema.Version,
Type: "object", Type: "object",
@@ -65,14 +65,14 @@ func (t *CreateTicketGraphTool) Info(ctx context.Context) (*schema.ToolInfo, err
Key: "title", Key: "title",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "工单标题,简洁概括问题。", Description: "Ticket title. Concisely summarizes the issue.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "description", Key: "description",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "工单描述,清晰整理用户问题、现象和诉求。", Description: "Ticket description. Clearly captures the user's issue, symptoms, and request.",
}, },
}, },
)), )),
@@ -52,7 +52,7 @@ func (t *HandoffGraphTool) Build(ctx registry.Context) (einotool.BaseTool, error
func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) { func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{ return &schema.ToolInfo{
Name: toolx.GraphHandoffConversation.Name, Name: toolx.GraphHandoffConversation.Name,
Desc: "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。仅在用户明确要求人工客服,或你已确认必须转人工处理时调用;若结果标记 terminal=true shouldRetry=false,不要重复调用。", Desc: "Graph Tool. Handles handoff reason preparation, user confirmation, actual human handoff, and result return. Use only when the user explicitly asks for a human agent or you have confirmed that human handling is required. Do not repeat the call when the result has terminal=true and shouldRetry=false.",
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
Version: einojsonschema.Version, Version: einojsonschema.Version,
Type: "object", Type: "object",
@@ -61,7 +61,7 @@ func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
Key: "reason", Key: "reason",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "转人工原因,简洁说明为何需要人工介入,例如用户明确要求人工、问题需要人工核验、需要人工售后处理等。", Description: "Handoff reason. Briefly explain why a human is needed, such as explicit user request, manual verification, or after-sales handling.",
}, },
}, },
)), )),
@@ -48,7 +48,7 @@ func (t *PrepareTicketDraftTool) Build(ctx registry.Context) (einotool.BaseTool,
func (t *PrepareTicketDraftTool) Info(ctx context.Context) (*schema.ToolInfo, error) { func (t *PrepareTicketDraftTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{ return &schema.ToolInfo{
Name: toolx.GraphPrepareTicketDraft.Name, Name: toolx.GraphPrepareTicketDraft.Name,
Desc: "Graph Tool。用于根据当前会话和已收集信息整理工单草稿,输出建议标题、建议描述、缺失字段和追问建议。适合在真正调用 create_ticket_with_confirmation 前先整理工单内容。", Desc: "Graph Tool. Prepares a ticket draft from the current conversation and collected information. Use it before create_ticket_with_confirmation when the ticket content needs to be organized.",
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
Version: einojsonschema.Version, Version: einojsonschema.Version,
Type: "object", Type: "object",
@@ -57,35 +57,35 @@ func (t *PrepareTicketDraftTool) Info(ctx context.Context) (*schema.ToolInfo, er
Key: "title", Key: "title",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "已整理出的工单标题,可选。", Description: "Prepared ticket title. Optional.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "description", Key: "description",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "已整理出的工单描述,可选。", Description: "Prepared ticket description. Optional.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "issue", Key: "issue",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "用户当前遇到的问题现象或报错信息。", Description: "The issue or error message the user is experiencing.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "impact", Key: "impact",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "问题影响范围,例如无法登录、无法下单、业务中断等。", Description: "Impact scope, such as unable to sign in, unable to place an order, or business interruption.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "expectedOutcome", Key: "expectedOutcome",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "用户期望的处理结果或诉求。", Description: "The user's expected outcome or request.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
@@ -48,7 +48,7 @@ func (t *TriageServiceRequestTool) Build(ctx registry.Context) (einotool.BaseToo
func (t *TriageServiceRequestTool) Info(ctx context.Context) (*schema.ToolInfo, error) { func (t *TriageServiceRequestTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{ return &schema.ToolInfo{
Name: toolx.GraphTriageServiceRequest.Name, Name: toolx.GraphTriageServiceRequest.Name,
Desc: "Graph Tool。用于综合分析当前对话,判断应该继续解答、整理工单草稿还是转人工;当判断为建单时,会一并返回结构化工单草稿建议。", Desc: "Graph Tool. Analyzes the current conversation to decide whether to continue answering, prepare a ticket draft, or hand off to a human. When ticket creation is recommended, it returns a structured ticket draft suggestion.",
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
Version: einojsonschema.Version, Version: einojsonschema.Version,
Type: "object", Type: "object",
@@ -57,35 +57,35 @@ func (t *TriageServiceRequestTool) Info(ctx context.Context) (*schema.ToolInfo,
Key: "goal", Key: "goal",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "当前分析目标,例如判断是否需要升级、是否要建单或转人工。", Description: "Analysis goal, such as whether to escalate, create a ticket, or hand off to a human.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "observedIssue", Key: "observedIssue",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "当前观察到的主要问题或争议点。", Description: "Main issue or dispute observed in the conversation.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "needTicket", Key: "needTicket",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "boolean", Type: "boolean",
Description: "是否重点评估建单必要性。", Description: "Whether to focus on evaluating ticket creation.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "needHumanHandoff", Key: "needHumanHandoff",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "boolean", Type: "boolean",
Description: "是否重点评估转人工必要性。", Description: "Whether to focus on evaluating human handoff.",
}, },
}, },
orderedmap.Pair[string, *einojsonschema.Schema]{ orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "additionalContext", Key: "additionalContext",
Value: &einojsonschema.Schema{ Value: &einojsonschema.Schema{
Type: "string", Type: "string",
Description: "补充上下文,例如你已发现的风险点或限制条件。", Description: "Additional context, such as risk signals or constraints already identified.",
}, },
}, },
)), )),
+4 -4
View File
@@ -14,7 +14,7 @@ import (
) )
func BuildConversation(item *models.Conversation) response.ConversationResponse { func BuildConversation(item *models.Conversation) response.ConversationResponse {
return BuildConversationWithLocale(item, i18nx.LocaleZhCN) return BuildConversationWithLocale(item, i18nx.DefaultLocale)
} }
func BuildConversationWithLocale(item *models.Conversation, locale string) response.ConversationResponse { func BuildConversationWithLocale(item *models.Conversation, locale string) response.ConversationResponse {
@@ -114,7 +114,7 @@ func BuildParticipantResponses(conversationID int64) []response.ConversationPart
} }
func BuildMessages(list []models.Message) []response.MessageResponse { func BuildMessages(list []models.Message) []response.MessageResponse {
return BuildMessagesWithLocale(list, i18nx.LocaleZhCN) return BuildMessagesWithLocale(list, i18nx.DefaultLocale)
} }
func BuildMessagesWithLocale(list []models.Message, locale string) []response.MessageResponse { func BuildMessagesWithLocale(list []models.Message, locale string) []response.MessageResponse {
@@ -132,7 +132,7 @@ func BuildMessagesWithLocale(list []models.Message, locale string) []response.Me
} }
func BuildMessage(item *models.Message) response.MessageResponse { func BuildMessage(item *models.Message) response.MessageResponse {
return BuildMessageWithLocale(item, i18nx.LocaleZhCN) return BuildMessageWithLocale(item, i18nx.DefaultLocale)
} }
func BuildMessageWithLocale(item *models.Message, locale string) response.MessageResponse { func BuildMessageWithLocale(item *models.Message, locale string) response.MessageResponse {
@@ -141,7 +141,7 @@ func BuildMessageWithLocale(item *models.Message, locale string) response.Messag
} }
func BuildMessageWithReadStates(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile) response.MessageResponse { 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) return BuildMessageWithReadStatesAndLocale(item, agentReadState, customerReadState, aiSenderNames, userSenderNames, agentProfiles, i18nx.DefaultLocale)
} }
func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile, locale string) response.MessageResponse { func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, customerReadState *models.ConversationReadState, aiSenderNames, userSenderNames map[int64]string, agentProfiles map[int64]*models.AgentProfile, locale string) response.MessageResponse {
+2 -2
View File
@@ -16,7 +16,7 @@ var (
) )
func BuildNotification(item *models.Notification) *response.NotificationResponse { func BuildNotification(item *models.Notification) *response.NotificationResponse {
return BuildNotificationWithLocale(item, i18nx.LocaleZhCN) return BuildNotificationWithLocale(item, i18nx.DefaultLocale)
} }
func BuildNotificationWithLocale(item *models.Notification, locale string) *response.NotificationResponse { func BuildNotificationWithLocale(item *models.Notification, locale string) *response.NotificationResponse {
@@ -39,7 +39,7 @@ func BuildNotificationWithLocale(item *models.Notification, locale string) *resp
} }
func BuildNotificationList(list []models.Notification) []response.NotificationResponse { func BuildNotificationList(list []models.Notification) []response.NotificationResponse {
return BuildNotificationListWithLocale(list, i18nx.LocaleZhCN) return BuildNotificationListWithLocale(list, i18nx.DefaultLocale)
} }
func BuildNotificationListWithLocale(list []models.Notification, locale string) []response.NotificationResponse { func BuildNotificationListWithLocale(list []models.Notification, locale string) []response.NotificationResponse {
@@ -161,7 +161,7 @@ func AIAgentPostUpdate_status(ctx *gin.Context) {
} }
func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse { func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
return buildAIAgentResponseWithLocale(item, i18nx.LocaleZhCN) return buildAIAgentResponseWithLocale(item, i18nx.DefaultLocale)
} }
func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse { func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse {
+105 -5
View File
@@ -18,7 +18,7 @@ const (
const ( const (
BootstrapAdminUsername = "admin" BootstrapAdminUsername = "admin"
BootstrapAdminPassword = "ChangeMe123!" BootstrapAdminPassword = "ChangeMe123!"
BootstrapAdminNickname = "超级管理员" BootstrapAdminNickname = "Super Admin"
) )
// Permission 权限结构体 // Permission 权限结构体
@@ -278,11 +278,111 @@ var PermissionMap = make(map[string]Permission)
// init 初始化 PermissionMap // init 初始化 PermissionMap
func init() { func init() {
normalizeBuiltinPermissionNames()
for _, permission := range Permissions { for _, permission := range Permissions {
PermissionMap[permission.Code] = permission PermissionMap[permission.Code] = permission
} }
} }
func normalizeBuiltinPermissionNames() {
for i := range Permissions {
Permissions[i].Name = builtinPermissionName(Permissions[i].Code, Permissions[i].Name)
}
}
func builtinPermissionName(code string, fallback string) string {
if name, ok := builtinPermissionNameOverrides[code]; ok {
return name
}
resourceKey, actionKey, ok := splitPermissionCode(code)
if !ok {
return fallback
}
action, ok := builtinPermissionActionLabels[actionKey]
if !ok {
return fallback
}
resource, ok := builtinPermissionResourceLabels[resourceKey]
if !ok {
return fallback
}
return action + " " + resource
}
func splitPermissionCode(code string) (string, string, bool) {
for i := 0; i < len(code); i++ {
if code[i] == '.' {
return code[:i], code[i+1:], i > 0 && i < len(code)-1
}
}
return "", "", false
}
var builtinPermissionActionLabels = map[string]string{
"view": "View",
"create": "Create",
"update": "Update",
"delete": "Delete",
"assignRole": "Assign roles to",
"assignPermission": "Assign permissions to",
"sync": "Sync",
"revoke": "Revoke",
"assign": "Assign",
"transfer": "Transfer",
"close": "Close",
"send": "Send",
"tag": "Manage tags for",
"handover": "Handle handoffs for",
"recycle": "Recycle",
"linkCustomer": "Link customers to",
"changeStatus": "Change status for",
"progress": "Update progress for",
"updateStatus": "Update status for",
"config": "Configure service rules for",
"batchGenerate": "Batch generate",
"call": "Call",
}
var builtinPermissionResourceLabels = map[string]string{
"user": "users",
"role": "roles",
"permission": "permissions",
"session": "sessions",
"conversation": "conversations",
"ticket": "tickets",
"notification": "notifications",
"quickReply": "quick replies",
"tag": "tags",
"company": "companies",
"channel": "channels",
"customer": "customers",
"agent": "agents",
"agentTeam": "agent teams",
"agentTeamSchedule": "agent team schedules",
"asset": "file assets",
"aiAgent": "AI Agents",
"aiConfig": "AI configurations",
"knowledgeBase": "knowledge bases",
"knowledgeDocument": "knowledge documents",
"knowledgeFAQ": "knowledge FAQs",
"skillDefinition": "Skill definitions",
"mcp": "MCP tools",
}
var builtinPermissionNameOverrides = map[string]string{
"user.assignRole": "Assign user roles",
"role.assignPermission": "Assign role permissions",
"session.revoke": "Revoke sessions",
"conversation.send": "Send conversation messages",
"conversation.linkCustomer": "Link conversation customer",
"ticket.changeStatus": "Change ticket status",
"ticket.progress": "Update ticket progress",
"agent.config": "Configure agent service rules",
"agentTeamSchedule.batchGenerate": "Batch generate agent team schedules",
"mcp.view": "View MCP debug information",
"mcp.call": "Call MCP tools",
}
type RoleSpec struct { type RoleSpec struct {
Name string Name string
Code string Code string
@@ -290,10 +390,10 @@ type RoleSpec struct {
} }
var Roles = []RoleSpec{ var Roles = []RoleSpec{
{Name: "超级管理员", Code: RoleCodeSuperAdmin, SortNo: 1}, {Name: "Super Admin", Code: RoleCodeSuperAdmin, SortNo: 1},
{Name: "管理员", Code: RoleCodeAdmin, SortNo: 2}, {Name: "Admin", Code: RoleCodeAdmin, SortNo: 2},
{Name: "客服组长", Code: RoleCodeCsTeamLeader, SortNo: 3}, {Name: "Support Team Lead", Code: RoleCodeCsTeamLeader, SortNo: 3},
{Name: "客服", Code: RoleCodeCsUser, SortNo: 4}, {Name: "Support Agent", Code: RoleCodeCsUser, SortNo: 4},
} }
var RolePermissions = map[string][]Permission{ var RolePermissions = map[string][]Permission{
+46
View File
@@ -0,0 +1,46 @@
package constants
import "testing"
func TestBuiltinAuthSeedNamesDefaultToEnglish(t *testing.T) {
t.Parallel()
if BootstrapAdminNickname != "Super Admin" {
t.Fatalf("BootstrapAdminNickname = %q, want %q", BootstrapAdminNickname, "Super Admin")
}
roles := map[string]string{}
for _, role := range Roles {
roles[role.Code] = role.Name
}
tests := map[string]string{
RoleCodeSuperAdmin: "Super Admin",
RoleCodeAdmin: "Admin",
RoleCodeCsTeamLeader: "Support Team Lead",
RoleCodeCsUser: "Support Agent",
}
for code, want := range tests {
if got := roles[code]; got != want {
t.Fatalf("role %s name = %q, want %q", code, got, want)
}
}
permissions := map[string]string{}
for _, permission := range Permissions {
permissions[permission.Code] = permission.Name
}
permissionTests := map[string]string{
"user.view": "View users",
"ticket.create": "Create tickets",
"conversation.send": "Send conversation messages",
"channel.view": "View channels",
"agent.view": "View agents",
}
for code, want := range permissionTests {
if got := permissions[code]; got != want {
t.Fatalf("permission %s name = %q, want %q", code, got, want)
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ var (
func Bundle() *i18n.Bundle { func Bundle() *i18n.Bundle {
bundleOnce.Do(func() { bundleOnce.Do(func() {
b := i18n.NewBundle(language.SimplifiedChinese) b := i18n.NewBundle(language.AmericanEnglish)
b.RegisterUnmarshalFunc("toml", toml.Unmarshal) b.RegisterUnmarshalFunc("toml", toml.Unmarshal)
for _, name := range []string{ for _, name := range []string{
"locales/active.zh-CN.toml", "locales/active.zh-CN.toml",
+3 -3
View File
@@ -6,15 +6,15 @@ const contextLocaleKey = "i18nx.locale"
func Locale(ctx *gin.Context) string { func Locale(ctx *gin.Context) string {
if ctx == nil { if ctx == nil {
return LocaleZhCN return DefaultLocale
} }
value, ok := ctx.Get(contextLocaleKey) value, ok := ctx.Get(contextLocaleKey)
if !ok { if !ok {
return LocaleZhCN return DefaultLocale
} }
locale, ok := value.(string) locale, ok := value.(string)
if !ok { if !ok {
return LocaleZhCN return DefaultLocale
} }
return NormalizeLocale(locale) return NormalizeLocale(locale)
} }
+4 -4
View File
@@ -16,14 +16,14 @@ func TestNormalizeLocale(t *testing.T) {
in string in string
want string want string
}{ }{
{name: "default for blank", in: "", want: LocaleZhCN}, {name: "default for blank", in: "", want: LocaleEnUS},
{name: "exact chinese", in: "zh-CN", want: LocaleZhCN}, {name: "exact chinese", in: "zh-CN", want: LocaleZhCN},
{name: "underscore chinese", in: "zh_CN", want: LocaleZhCN}, {name: "underscore chinese", in: "zh_CN", want: LocaleZhCN},
{name: "short chinese", in: "zh", want: LocaleZhCN}, {name: "short chinese", in: "zh", want: LocaleZhCN},
{name: "exact english", in: "en-US", want: LocaleEnUS}, {name: "exact english", in: "en-US", want: LocaleEnUS},
{name: "underscore english", in: "en_US", want: LocaleEnUS}, {name: "underscore english", in: "en_US", want: LocaleEnUS},
{name: "short english", in: "en", want: LocaleEnUS}, {name: "short english", in: "en", want: LocaleEnUS},
{name: "unsupported falls back", in: "fr-FR", want: LocaleZhCN}, {name: "unsupported falls back", in: "fr-FR", want: LocaleEnUS},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -60,13 +60,13 @@ func TestResolveLocalePrefersXLocale(t *testing.T) {
} }
} }
func TestTranslateFallsBackToChinese(t *testing.T) { func TestTranslateFallsBackToEnglish(t *testing.T) {
t.Parallel() t.Parallel()
if got := TLocale(LocaleEnUS, "error.auth.expired", nil); got != "Your session has expired. Please sign in again." { if got := TLocale(LocaleEnUS, "error.auth.expired", nil); got != "Your session has expired. Please sign in again." {
t.Fatalf("english translation = %q", got) t.Fatalf("english translation = %q", got)
} }
if got := TLocale("fr-FR", "error.auth.expired", nil); got != "未登录或登录已过期" { if got := TLocale("fr-FR", "error.auth.expired", nil); got != "Your session has expired. Please sign in again." {
t.Fatalf("fallback translation = %q", got) t.Fatalf("fallback translation = %q", got)
} }
} }
+4 -4
View File
@@ -13,7 +13,7 @@ func T(ctx *gin.Context, messageID string, data map[string]any) string {
} }
} }
} }
return TLocale(LocaleZhCN, messageID, data) return TLocale(DefaultLocale, messageID, data)
} }
func TLocale(locale string, messageID string, data map[string]any) string { func TLocale(locale string, messageID string, data map[string]any) string {
@@ -22,8 +22,8 @@ func TLocale(locale string, messageID string, data map[string]any) string {
if message != "" { if message != "" {
return message return message
} }
if normalized != LocaleZhCN { if normalized != DefaultLocale {
message = localize(LocaleZhCN, messageID, data) message = localize(DefaultLocale, messageID, data)
if message != "" { if message != "" {
return message return message
} }
@@ -32,7 +32,7 @@ func TLocale(locale string, messageID string, data map[string]any) string {
} }
func localize(locale string, messageID string, data map[string]any) string { func localize(locale string, messageID string, data map[string]any) string {
localizer := i18n.NewLocalizer(Bundle(), locale, LocaleZhCN) localizer := i18n.NewLocalizer(Bundle(), locale, DefaultLocale)
message, err := localizer.Localize(&i18n.LocalizeConfig{ message, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: messageID, MessageID: messageID,
TemplateData: data, TemplateData: data,
+7 -6
View File
@@ -9,8 +9,9 @@ import (
) )
const ( const (
LocaleZhCN = "zh-CN" LocaleZhCN = "zh-CN"
LocaleEnUS = "en-US" LocaleEnUS = "en-US"
DefaultLocale = LocaleEnUS
) )
var supportedLocales = map[string]string{ var supportedLocales = map[string]string{
@@ -26,17 +27,17 @@ var supportedLocales = map[string]string{
func NormalizeLocale(value string) string { func NormalizeLocale(value string) string {
key := strings.ToLower(strings.TrimSpace(value)) key := strings.ToLower(strings.TrimSpace(value))
if key == "" { if key == "" {
return LocaleZhCN return DefaultLocale
} }
if locale, ok := supportedLocales[key]; ok { if locale, ok := supportedLocales[key]; ok {
return locale return locale
} }
return LocaleZhCN return DefaultLocale
} }
func ResolveRequestLocale(req *http.Request) string { func ResolveRequestLocale(req *http.Request) string {
if req == nil { if req == nil {
return LocaleZhCN return DefaultLocale
} }
if locale := normalizeSupportedLocale(req.Header.Get("X-Locale")); locale != "" { if locale := normalizeSupportedLocale(req.Header.Get("X-Locale")); locale != "" {
return locale return locale
@@ -47,7 +48,7 @@ func ResolveRequestLocale(req *http.Request) string {
if locale := normalizeSupportedLocale(req.URL.Query().Get("locale")); locale != "" { if locale := normalizeSupportedLocale(req.URL.Query().Get("locale")); locale != "" {
return locale return locale
} }
return LocaleZhCN return DefaultLocale
} }
func Middleware() gin.HandlerFunc { func Middleware() gin.HandlerFunc {
+39 -39
View File
@@ -58,87 +58,87 @@ var (
Code: "graph/triage_service_request", Code: "graph/triage_service_request",
ServerCode: "graph", ServerCode: "graph",
Name: "triage_service_request", Name: "triage_service_request",
Title: "升级分流判断", Title: "Triage service request",
Description: "Graph Tool。用于综合分析当前对话,判断应继续解答、整理工单草稿还是转人工,并在需要建单时一并整理工单草稿。", Description: "Graph Tool. Analyzes the current conversation to decide whether to continue answering, prepare a ticket draft, or hand off to a human. It also prepares a ticket draft when ticket creation is recommended.",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
RuntimeStatic: true, RuntimeStatic: true,
Appendix: strings.TrimSpace(` Appendix: strings.TrimSpace(`
当你需要判断“继续解答 / 建单 / 转人工”这类复杂升级路径时,优先先调用 triage_service_request 这个 Graph Tool,并遵守以下规则: When you need to decide between continuing the answer, creating a ticket, or handing off to a human, call triage_service_request first and follow these rules:
1. 该工具会综合当前对话输出 recommendedAction,并在需要建单时附带 ticketDraft。 1. The tool returns recommendedAction and includes ticketDraft when ticket creation is needed.
2. 如果 recommendedAction=continue_answering,则优先继续澄清或解答,不要直接升级。 2. If recommendedAction=continue_answering, continue clarifying or answering instead of escalating directly.
3. 如果 recommendedAction=prepare_ticket,则优先使用 ticketDraft 或继续补充缺失字段,再调用 create_ticket_with_confirmation 3. If recommendedAction=prepare_ticket, use ticketDraft or collect missing fields before calling create_ticket_with_confirmation.
4. 如果 recommendedAction=handoff_to_human,则确认理由充分后再调用 handoff_to_human 4. If recommendedAction=handoff_to_human, confirm the reason is sufficient before calling handoff_to_human.
5. 当升级路径不明确时,优先使用该工具,而不是直接凭主 prompt 做复杂分流判断。 5. When the escalation path is unclear, use this tool instead of making a complex routing decision from the main prompt alone.
`), `),
} }
GraphAnalyzeConversation = ToolSpec{ GraphAnalyzeConversation = ToolSpec{
Code: "graph/analyze_conversation", Code: "graph/analyze_conversation",
ServerCode: "graph", ServerCode: "graph",
Name: "analyze_conversation", Name: "analyze_conversation",
Title: "分析对话风险与摘要", Title: "Analyze conversation risk and summary",
Description: "Graph Tool。用于整理当前对话摘要、识别风险信号,并给出继续解答、建单或转人工的建议。", Description: "Graph Tool. Summarizes the current conversation, identifies risk signals, and recommends whether to continue answering, create a ticket, or hand off to a human.",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
RuntimeStatic: true, RuntimeStatic: true,
Appendix: strings.TrimSpace(` Appendix: strings.TrimSpace(`
当对话可能涉及投诉升级、退款赔偿、明显负面情绪、是否要建单、是否要转人工等复杂判断时,优先调用 analyze_conversation 这个 Graph Tool,并遵守以下规则: When the conversation may involve escalation, refunds, compensation, clear negative sentiment, ticket creation, or human handoff, call analyze_conversation first and follow these rules:
1. 该工具用于输出结构化摘要、风险信号和下一步建议,不代表实际已经建单或转人工。 1. This tool returns a structured summary, risk signals, and next-step recommendation. It does not create tickets or hand off to a human.
2. 如果工具建议为 handoff_to_human,应先确认是否满足转人工条件,再考虑调用 handoff_to_human 2. If the tool recommends handoff_to_human, confirm the handoff conditions before calling handoff_to_human.
3. 如果工具建议为 prepare_ticket,应优先调用 prepare_ticket_draft 或继续补充信息,而不是直接建单。 3. If the tool recommends prepare_ticket, call prepare_ticket_draft or collect more information before creating a ticket.
4. 如果工具建议为 continue_answering,优先继续澄清和解答,不要过早升级动作。 4. If the tool recommends continue_answering, continue clarifying and answering instead of escalating too early.
`), `),
} }
GraphPrepareTicketDraft = ToolSpec{ GraphPrepareTicketDraft = ToolSpec{
Code: "graph/prepare_ticket_draft", Code: "graph/prepare_ticket_draft",
ServerCode: "graph", ServerCode: "graph",
Name: "prepare_ticket_draft", Name: "prepare_ticket_draft",
Title: "整理工单草稿", Title: "Prepare ticket draft",
Description: "Graph Tool。用于根据当前会话和已收集信息整理工单草稿,输出建议标题、描述、缺失字段和追问建议。", Description: "Graph Tool. Prepares a ticket draft from the current conversation and collected information, including a suggested title, description, missing fields, and follow-up questions.",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
RuntimeStatic: true, RuntimeStatic: true,
Appendix: strings.TrimSpace(` Appendix: strings.TrimSpace(`
当用户已经表达了建单、投诉、报障、售后处理等诉求,但工单标题、描述或问题整理还比较散乱时,优先调用 prepare_ticket_draft 这个 Graph Tool,并遵守以下规则: When the user has asked to create a ticket, file a complaint, report an issue, or request after-sales handling, but the title or description is still unclear, call prepare_ticket_draft first and follow these rules:
1. 该工具用于整理工单草稿,会返回建议标题、建议描述、缺失字段和追问建议。 1. This tool prepares a ticket draft and returns a suggested title, suggested description, missing fields, and follow-up questions.
2. 如果工具返回 ready=false,优先根据 missingFields followUpQuestions 继续追问,不要直接创建工单。 2. If ready=false, ask follow-up questions based on missingFields and followUpQuestions instead of creating a ticket directly.
3. 如果工具返回 ready=true,再结合结果考虑调用 create_ticket_with_confirmation 3. If ready=true, use the result to consider calling create_ticket_with_confirmation.
4. 该工具用于“整理草稿”,不代表已经创建工单。 4. This tool only prepares a draft. It does not create a ticket.
`), `),
} }
GraphCreateTicketConfirm = ToolSpec{ GraphCreateTicketConfirm = ToolSpec{
Code: "graph/create_ticket_with_confirmation", Code: "graph/create_ticket_with_confirmation",
ServerCode: "graph", ServerCode: "graph",
Name: "create_ticket_with_confirmation", Name: "create_ticket_with_confirmation",
Title: "创建工单确认流程", Title: "Create ticket confirmation flow",
Description: "Graph Tool。用于封装建单参数整理、用户确认、真正建单和结果返回的确定性流程。", Description: "Graph Tool. Handles ticket parameter preparation, user confirmation, actual ticket creation, and result return.",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
DirectAccess: true, DirectAccess: true,
RuntimeStatic: true, RuntimeStatic: true,
Aliases: []string{"builtin/create_ticket_with_confirmation"}, Aliases: []string{"builtin/create_ticket_with_confirmation"},
Appendix: strings.TrimSpace(` Appendix: strings.TrimSpace(`
你可以在确认信息充分后调用 create_ticket_with_confirmation 这个 Graph Tool 来创建工单,但必须遵守以下规则: You can call create_ticket_with_confirmation after enough information has been collected, but follow these rules:
1. 只有在用户明确表达希望提交工单、投诉、报障、售后处理等诉求时,才考虑调用该工具。 1. Only consider this tool when the user explicitly wants to submit a ticket, complaint, issue report, or after-sales request.
2. 调用前你必须已经整理出清晰的工单标题和问题描述;如果信息还比较散乱,优先先调用 prepare_ticket_draft 或继续追问,不要过早调用。 2. Before calling it, prepare a clear ticket title and issue description. If the information is still scattered, call prepare_ticket_draft or ask follow-up questions first.
3. 一旦准备创建工单,必须调用 create_ticket_with_confirmation 工具,禁止直接口头宣称“已经创建工单”。 3. Once you are ready to create a ticket, you must call create_ticket_with_confirmation. Do not simply claim in text that the ticket has been created.
4. Graph Tool 会先向用户发起确认。用户确认后才会真正创建工单;用户取消则结束本次建单流程。 4. This Graph Tool asks the user for confirmation first. The ticket is created only after the user confirms; if the user cancels, the flow ends.
5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。 5. If the user is only asking questions, complaining generally, or expressing dissatisfaction without explicitly requesting a ticket, continue clarifying instead of proactively creating one.
`), `),
} }
GraphHandoffConversation = ToolSpec{ GraphHandoffConversation = ToolSpec{
Code: "graph/handoff_to_human", Code: "graph/handoff_to_human",
ServerCode: "graph", ServerCode: "graph",
Name: "handoff_to_human", Name: "handoff_to_human",
Title: "转人工确认流程", Title: "Human handoff confirmation flow",
Description: "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。", Description: "Graph Tool. Handles handoff reason preparation, user confirmation, actual human handoff, and result return.",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
DirectAccess: true, DirectAccess: true,
RuntimeStatic: true, RuntimeStatic: true,
Appendix: strings.TrimSpace(` Appendix: strings.TrimSpace(`
你可以在确认需要人工介入后调用 handoff_to_human 这个 Graph Tool 来转人工,但必须遵守以下规则: You can call handoff_to_human after confirming that human help is needed, but follow these rules:
1. 只有在用户明确要求人工客服,或你已经判断该问题必须由人工继续处理时,才调用该工具。 1. Only call this tool when the user explicitly asks for a human agent or you have determined that the issue must be handled by a human.
2. 调用前先尽量整理清楚转人工原因;如果理由含糊,先追问或澄清,不要直接转人工。 2. Before calling it, summarize the handoff reason clearly. If the reason is vague, ask a follow-up question first.
3. 一旦决定转人工,必须调用 handoff_to_human 工具,禁止只在回复里口头说“我帮你转人工了”。 3. Once you decide to hand off, you must call handoff_to_human. Do not simply say in text that you have connected the user to a human.
4. Graph Tool 会先向用户发起确认。用户确认后才会真正转人工;用户取消则结束本次转人工流程。 4. This Graph Tool asks the user for confirmation first. The handoff happens only after the user confirms; if the user cancels, the flow ends.
5. 如果问题仍可由当前对话继续解决,优先继续解答,不要过早转人工。 5. If the issue can still be solved in the current conversation, continue helping instead of escalating too early.
6. 如果工具返回 terminal=true shouldRetry=false,说明转人工流程已经结束,禁止重复调用该工具。 6. If the tool returns terminal=true and shouldRetry=false, the handoff flow has ended. Do not call it repeatedly.
`), `),
} }
RegisteredToolSpecs = []ToolSpec{ RegisteredToolSpecs = []ToolSpec{
+6 -6
View File
@@ -208,8 +208,8 @@ func (s *channelService) ListWxWorkKFAccounts() ([]response.WxWorkKFAccountRespo
func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfig, error) { func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfig, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
cfg := &dto.WebChannelConfig{ cfg := &dto.WebChannelConfig{
Title: "在线客服", Title: "Support",
Subtitle: "欢迎咨询", Subtitle: "How can we help?",
ThemeColor: "#2563eb", ThemeColor: "#2563eb",
Position: "right", Position: "right",
Width: "380px", Width: "380px",
@@ -221,7 +221,7 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi
} }
cfg.Title = strings.TrimSpace(cfg.Title) cfg.Title = strings.TrimSpace(cfg.Title)
if cfg.Title == "" { if cfg.Title == "" {
cfg.Title = "在线客服" cfg.Title = "Support"
} }
cfg.Subtitle = strings.TrimSpace(cfg.Subtitle) cfg.Subtitle = strings.TrimSpace(cfg.Subtitle)
cfg.ThemeColor = strings.TrimSpace(cfg.ThemeColor) cfg.ThemeColor = strings.TrimSpace(cfg.ThemeColor)
@@ -246,8 +246,8 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi
func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPChannelConfig, error) { func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPChannelConfig, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
cfg := &dto.WechatMPChannelConfig{ cfg := &dto.WechatMPChannelConfig{
Title: "公众号客服", Title: "Official Account Support",
Subtitle: "欢迎咨询", Subtitle: "How can we help?",
ThemeColor: "#2563eb", ThemeColor: "#2563eb",
} }
if raw != "" { if raw != "" {
@@ -257,7 +257,7 @@ func (s *channelService) ParseWechatMPChannelConfig(raw string) (*dto.WechatMPCh
} }
cfg.Title = strings.TrimSpace(cfg.Title) cfg.Title = strings.TrimSpace(cfg.Title)
if cfg.Title == "" { if cfg.Title == "" {
cfg.Title = "公众号客服" cfg.Title = "Official Account Support"
} }
cfg.Subtitle = strings.TrimSpace(cfg.Subtitle) cfg.Subtitle = strings.TrimSpace(cfg.Subtitle)
cfg.ThemeColor = strings.TrimSpace(cfg.ThemeColor) cfg.ThemeColor = strings.TrimSpace(cfg.ThemeColor)
@@ -19,8 +19,8 @@ import (
var ConversationHumanDispatchService = newConversationHumanDispatchService() var ConversationHumanDispatchService = newConversationHumanDispatchService()
const ( const (
HandoffWaitingMessage = "已为你转接人工客服,请稍候。" HandoffWaitingMessage = "We are connecting you to a human support agent. Please wait."
HandoffOffHoursMessage = "当前暂不在人工客服服务时间内,你可以先继续描述问题,我会尽力协助;服务时间开始后也可以再次转人工。" HandoffOffHoursMessage = "Human support is currently outside service hours. You can keep describing the issue and I will do my best to help. You can also request a human agent again when service hours resume."
) )
type HandoffDecisionType string type HandoffDecisionType string
@@ -42,7 +42,7 @@ func TestConversationHumanDispatchAIHandoffOffHoursKeepsAIServingAndSendsNotice(
if message == nil { if message == nil {
t.Fatalf("expected off-hours notice message") t.Fatalf("expected off-hours notice message")
} }
if message.SenderType != enums.IMSenderTypeAI || !strings.Contains(message.Content, "当前暂不在人工客服服务时间内") { if message.SenderType != enums.IMSenderTypeAI || !strings.Contains(message.Content, "Human support is currently outside service hours") {
t.Fatalf("unexpected off-hours message: %+v", message) t.Fatalf("unexpected off-hours message: %+v", message)
} }
} }
@@ -37,11 +37,11 @@ func handleConversationAssignedNotify(ctx context.Context, event events.Conversa
func conversationAssignedNotifyTitle(assignType string) string { func conversationAssignedNotifyTitle(assignType string) string {
switch strings.TrimSpace(assignType) { switch strings.TrimSpace(assignType) {
case events.ConversationAssignTypeTransfer: case events.ConversationAssignTypeTransfer:
return "会话转接提醒" return "Conversation transferred"
case events.ConversationAssignTypeAutoAssign: case events.ConversationAssignTypeAutoAssign:
return "会话自动分配提醒" return "Conversation auto-assigned"
default: default:
return "会话分配提醒" return "Conversation assigned"
} }
} }
@@ -49,21 +49,21 @@ func buildConversationAssignedNotifyBody(conversation *models.Conversation, assi
if conversation == nil { if conversation == nil {
return "" return ""
} }
reasonLabel := "分配原因" reasonLabel := "Assignment reason"
if strings.TrimSpace(assignType) == events.ConversationAssignTypeTransfer { if strings.TrimSpace(assignType) == events.ConversationAssignTypeTransfer {
reasonLabel = "转接原因" reasonLabel = "Transfer reason"
} }
lines := []string{ lines := []string{
fmt.Sprintf("会话ID: #%d", conversation.ID), fmt.Sprintf("Conversation ID: #%d", conversation.ID),
fmt.Sprintf("会话摘要: %s", strs.DefaultIfBlank(services.ConversationService.BuildConversationSummary(conversation), "-")), fmt.Sprintf("Summary: %s", strs.DefaultIfBlank(services.ConversationService.BuildConversationSummary(conversation), "-")),
fmt.Sprintf("接入渠道: %s", resolveConversationChannelLabel(conversation)), fmt.Sprintf("Channel: %s", resolveConversationChannelLabel(conversation)),
fmt.Sprintf("当前状态: %s", enums.GetIMConversationStatusLabel(conversation.Status)), fmt.Sprintf("Status: %s", enums.GetIMConversationStatusLabel(conversation.Status)),
fmt.Sprintf("处理人: %s", resolveNotifyUserLabel(assigneeID)), fmt.Sprintf("Assignee: %s", resolveNotifyUserLabel(assigneeID)),
} }
if strings.TrimSpace(reason) != "" { if strings.TrimSpace(reason) != "" {
lines = append(lines, fmt.Sprintf("%s: %s", reasonLabel, strings.TrimSpace(reason))) lines = append(lines, fmt.Sprintf("%s: %s", reasonLabel, strings.TrimSpace(reason)))
} }
lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format("2006-01-02 15:04:05"))) lines = append(lines, fmt.Sprintf("Time: %s", time.Now().Format("2006-01-02 15:04:05")))
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
@@ -31,16 +31,16 @@ func handleTicketAssignedInAppNotification(ctx context.Context, event events.Tic
if ticket == nil { if ticket == nil {
return nil return nil
} }
content := fmt.Sprintf("工单 %s 已指派给你", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))) content := fmt.Sprintf("Ticket %s has been assigned to you.", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID)))
if title := strings.TrimSpace(ticket.Title); title != "" { if title := strings.TrimSpace(ticket.Title); title != "" {
content = content + "\n" + title content = content + "\n" + title
} }
if reason := strings.TrimSpace(event.Reason); reason != "" { if reason := strings.TrimSpace(event.Reason); reason != "" {
content = content + "\n指派原因: " + reason content = content + "\nAssignment reason: " + reason
} }
_, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{ _, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{
RecipientUserID: event.ToUserID, RecipientUserID: event.ToUserID,
Title: "工单指派提醒", Title: "Ticket assigned",
Content: content, Content: content,
NotificationType: "ticket_assigned", NotificationType: "ticket_assigned",
BizType: "ticket", BizType: "ticket",
@@ -61,12 +61,12 @@ func handleConversationAssignedInAppNotification(ctx context.Context, event even
if conversation == nil { if conversation == nil {
return nil return nil
} }
content := fmt.Sprintf("会话 #%d 已分配给你", conversation.ID) content := fmt.Sprintf("Conversation #%d has been assigned to you.", conversation.ID)
if summary := strings.TrimSpace(services.ConversationService.BuildConversationSummary(conversation)); summary != "" { if summary := strings.TrimSpace(services.ConversationService.BuildConversationSummary(conversation)); summary != "" {
content = content + "\n" + summary content = content + "\n" + summary
} }
if reason := strings.TrimSpace(event.Reason); reason != "" { if reason := strings.TrimSpace(event.Reason); reason != "" {
content = content + "\n分配原因: " + reason content = content + "\nAssignment reason: " + reason
} }
_, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{ _, err := services.NotificationService.CreateAndPush(request.CreateNotificationRequest{
RecipientUserID: event.ToUserID, RecipientUserID: event.ToUserID,
@@ -29,7 +29,7 @@ func handleTicketAssignedNotify(ctx context.Context, event events.TicketAssigned
return nil return nil
} }
content := buildTicketAssignedNotifyBody(ticket, event.ToUserID, event.Reason) content := buildTicketAssignedNotifyBody(ticket, event.ToUserID, event.Reason)
return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(event.ToUserID, "工单指派提醒", content) return services.WxWorkNotifyService.SendTextToAssigneeOrDefault(event.ToUserID, "Ticket assigned", content)
} }
func buildTicketAssignedNotifyBody(ticket *models.Ticket, assigneeID int64, reason string) string { func buildTicketAssignedNotifyBody(ticket *models.Ticket, assigneeID int64, reason string) string {
@@ -37,14 +37,14 @@ func buildTicketAssignedNotifyBody(ticket *models.Ticket, assigneeID int64, reas
return "" return ""
} }
lines := []string{ lines := []string{
fmt.Sprintf("工单号: %s", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))), fmt.Sprintf("Ticket no: %s", strs.DefaultIfBlank(ticket.TicketNo, fmt.Sprintf("#%d", ticket.ID))),
fmt.Sprintf("工单标题: %s", strs.DefaultIfBlank(ticket.Title, "-")), fmt.Sprintf("Title: %s", strs.DefaultIfBlank(ticket.Title, "-")),
fmt.Sprintf("当前状态: %s", enums.GetTicketStatusLabel(ticket.Status)), fmt.Sprintf("Status: %s", enums.GetTicketStatusLabel(ticket.Status)),
fmt.Sprintf("处理人: %s", resolveNotifyUserLabel(assigneeID)), fmt.Sprintf("Assignee: %s", resolveNotifyUserLabel(assigneeID)),
} }
if strings.TrimSpace(reason) != "" { if strings.TrimSpace(reason) != "" {
lines = append(lines, fmt.Sprintf("指派原因: %s", strings.TrimSpace(reason))) lines = append(lines, fmt.Sprintf("Assignment reason: %s", strings.TrimSpace(reason)))
} }
lines = append(lines, fmt.Sprintf("时间: %s", time.Now().Format("2006-01-02 15:04:05"))) lines = append(lines, fmt.Sprintf("Time: %s", time.Now().Format("2006-01-02 15:04:05")))
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
+2 -2
View File
@@ -237,7 +237,7 @@ func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator *
} }
return repositories.TicketProgressRepository.Create(ctx.Tx, &models.TicketProgress{ return repositories.TicketProgressRepository.Create(ctx.Tx, &models.TicketProgress{
TicketID: ticket.ID, TicketID: ticket.ID,
Content: "创建工单", Content: "Created ticket",
AuthorID: operator.UserID, AuthorID: operator.UserID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
}) })
@@ -265,7 +265,7 @@ func (s *ticketService) CreateFromConversation(req request.CreateTicketFromConve
title = strings.TrimSpace(ConversationService.BuildConversationSummary(conversation)) title = strings.TrimSpace(ConversationService.BuildConversationSummary(conversation))
} }
if title == "" { if title == "" {
title = "会话工单" title = "Conversation ticket"
} }
description := strings.TrimSpace(req.Description) description := strings.TrimSpace(req.Description)
if description == "" { if description == "" {
+1 -1
View File
@@ -81,7 +81,7 @@ func TestTicketServiceCreateTicketSetsPendingStatusAndTicketNo(t *testing.T) {
if len(progresses) != 1 { if len(progresses) != 1 {
t.Fatalf("expected initial progress, got %d", len(progresses)) t.Fatalf("expected initial progress, got %d", len(progresses))
} }
if progresses[0].Content != "创建工单" || progresses[0].AuthorID != operator.UserID { if progresses[0].Content != "Created ticket" || progresses[0].AuthorID != operator.UserID {
t.Fatalf("unexpected initial progress: %+v", progresses[0]) t.Fatalf("unexpected initial progress: %+v", progresses[0])
} }
+1 -1
View File
@@ -34,7 +34,7 @@ type MCPToolCatalogItem struct {
} }
func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) { func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) {
return s.ListMCPToolsWithLocale(ctx, i18nx.LocaleZhCN) return s.ListMCPToolsWithLocale(ctx, i18nx.DefaultLocale)
} }
func (s *toolCatalogService) ListMCPToolsWithLocale(ctx context.Context, locale string) ([]MCPToolCatalogItem, error) { func (s *toolCatalogService) ListMCPToolsWithLocale(ctx context.Context, locale string) ([]MCPToolCatalogItem, error) {
+1 -1
View File
@@ -43,7 +43,7 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="zh-CN" suppressHydrationWarning> <html lang="en-US" suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
+1
View File
@@ -25,6 +25,7 @@ async function loadConfig() {
test("normalizes supported locale aliases", async () => { test("normalizes supported locale aliases", async () => {
const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig() const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig()
assert.equal(DEFAULT_LOCALE, "en-US")
assert.equal(normalizeLocale("zh-CN"), "zh-CN") assert.equal(normalizeLocale("zh-CN"), "zh-CN")
assert.equal(normalizeLocale("zh_CN"), "zh-CN") assert.equal(normalizeLocale("zh_CN"), "zh-CN")
assert.equal(normalizeLocale("zh"), "zh-CN") assert.equal(normalizeLocale("zh"), "zh-CN")
+1 -1
View File
@@ -1,7 +1,7 @@
export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const
export type AppLocale = (typeof SUPPORTED_LOCALES)[number] export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
export const DEFAULT_LOCALE: AppLocale = "zh-CN" export const DEFAULT_LOCALE: AppLocale = "en-US"
export const LOCALE_STORAGE_KEY = "cs_ai_agent_locale" export const LOCALE_STORAGE_KEY = "cs_ai_agent_locale"
const LOCALE_ALIASES: Record<string, AppLocale> = { const LOCALE_ALIASES: Record<string, AppLocale> = {
+2 -2
View File
@@ -42,9 +42,9 @@ function getWidgetLocale() {
try { try {
const stored = window.localStorage?.getItem("cs_ai_agent_locale") const stored = window.localStorage?.getItem("cs_ai_agent_locale")
const language = stored || document.documentElement.lang || window.navigator?.language || "" const language = stored || document.documentElement.lang || window.navigator?.language || ""
return language.toLowerCase().startsWith("en") ? "en-US" : "zh-CN" return language.toLowerCase().startsWith("zh") ? "zh-CN" : "en-US"
} catch { } catch {
return "zh-CN" return "en-US"
} }
} }
File diff suppressed because one or more lines are too long