Enhance internationalization support and logging in Assistant and LLM components
- Integrated i18n translations for trace logging messages in the Assistant's Stream method, improving localization for user-facing messages. - Updated logging in the OpenAI provider and CUI writer to utilize i18n for error messages, enhancing clarity and consistency across different locales. - Added new embedded template handling in the i18n package, allowing for more flexible message formatting with variable substitutions. - Improved test coverage for i18n functionalities, ensuring robust handling of various translation scenarios and edge cases.
This commit is contained in:
parent
fe0d93fa04
commit
30778bbf2f
8 changed files with 863 additions and 150 deletions
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/llm"
|
||||
"github.com/yaoapp/yao/agent/output"
|
||||
"github.com/yaoapp/yao/trace/types"
|
||||
|
|
@ -35,10 +36,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
|||
var agentNode types.Node = nil
|
||||
if trace != nil {
|
||||
agentNode, _ = trace.Add(inputMessages, types.TraceNodeOption{
|
||||
Label: fmt.Sprintf("Assistant %s", ast.Name),
|
||||
Label: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.label"), // "Assistant {{name}}"
|
||||
Icon: "assistant",
|
||||
Description: fmt.Sprintf("Assistant %s is processing the request", ast.Name),
|
||||
Description: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.description"), // "Assistant {{name}} is processing the request"
|
||||
})
|
||||
|
||||
if agentNode != nil {
|
||||
// Mark the node as complete when the function returns
|
||||
defer agentNode.Complete()
|
||||
|
|
@ -56,7 +58,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
|||
|
||||
// Log the chat history
|
||||
if agentNode != nil {
|
||||
agentNode.Info("Get Chat History", map[string]any{"messages": fullMessages})
|
||||
agentNode.Info(i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.history"), map[string]any{"messages": fullMessages}) // "Get Chat History"
|
||||
}
|
||||
|
||||
// Request Create hook ( Optional )
|
||||
|
|
@ -116,7 +118,9 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
|||
trace.Add(
|
||||
map[string]any{"messages": completionMessages, "options": completionOptions},
|
||||
types.TraceNodeOption{
|
||||
Label: fmt.Sprintf("LLM %s", conn.ID()), Icon: "llm", Description: fmt.Sprintf("LLM %s is processing the request", conn.ID()),
|
||||
Label: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.label"), conn.ID()), // "LLM %s"
|
||||
Icon: "llm",
|
||||
Description: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.description"), conn.ID()), // "LLM %s is processing the request"
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -187,9 +191,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
|||
// Close the output writer to send [DONE] marker
|
||||
if err := output.Close(ctx); err != nil {
|
||||
if trace, _ := ctx.Trace(); trace != nil {
|
||||
trace.Error("Agent: Failed to close output", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.close_error"), map[string]any{"error": err.Error()}) // "Failed to close output"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
177
agent/i18n/builtin.go
Normal file
177
agent/i18n/builtin.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package i18n
|
||||
|
||||
// init registers built-in global messages
|
||||
func init() {
|
||||
// Initialize __global__ if not exists
|
||||
if Locales["__global__"] == nil {
|
||||
Locales["__global__"] = make(map[string]I18n)
|
||||
}
|
||||
|
||||
// Built-in English messages
|
||||
Locales["__global__"]["en"] = I18n{
|
||||
Locale: "en",
|
||||
Messages: map[string]any{
|
||||
// Assistant: agent.go Stream() function
|
||||
"assistant.agent.stream.label": "Assistant {{name}}",
|
||||
"assistant.agent.stream.description": "Assistant {{name}} is processing the request",
|
||||
"assistant.agent.stream.history": "Get Chat History",
|
||||
"assistant.agent.stream.capabilities": "Get Connector Capabilities",
|
||||
"assistant.agent.stream.create_hook": "Call Create Hook",
|
||||
"assistant.agent.stream.closing": "Closing output (root call)",
|
||||
"assistant.agent.stream.skipping": "Skipping output close (nested call)",
|
||||
"assistant.agent.stream.close_error": "Failed to close output",
|
||||
|
||||
// LLM: providers/openai/openai.go Stream() function
|
||||
"llm.openai.stream.label": "LLM %s",
|
||||
"llm.openai.stream.description": "LLM %s is processing the request",
|
||||
"llm.openai.stream.starting": "Starting stream request",
|
||||
"llm.openai.stream.request": "Stream Request",
|
||||
"llm.openai.stream.retry": "Stream request failed, retrying",
|
||||
"llm.openai.stream.api_error": "OpenAI API returned error response",
|
||||
"llm.openai.stream.error": "OpenAI Stream Error",
|
||||
"llm.openai.stream.no_data": "Request body that caused empty response",
|
||||
"llm.openai.stream.no_data_info": "Request details",
|
||||
"llm.openai.post.api_error": "OpenAI API error response",
|
||||
|
||||
// LLM: handlers/stream.go (general LLM stream handler)
|
||||
"llm.handlers.stream.info": "LLM Stream",
|
||||
"llm.handlers.stream.raw_output": "LLM Raw Output",
|
||||
|
||||
// Output: adapters/openai/writer.go
|
||||
"output.openai.writer.sending_chunk": "Sending chunk to client",
|
||||
"output.openai.writer.sending_done": "Sending [DONE] to client",
|
||||
"output.openai.writer.adapt_error": "Failed to adapt message",
|
||||
"output.openai.writer.chunk_error": "Failed to send chunk",
|
||||
"output.openai.writer.group_error": "Failed to write message in group",
|
||||
"output.openai.writer.send_error": "Failed to send data to client",
|
||||
"output.openai.writer.marshal_error": "Failed to marshal chunk",
|
||||
"output.openai.writer.done_error": "Failed to send [DONE] to client",
|
||||
|
||||
// Output: adapters/cui/writer.go
|
||||
"output.cui.writer.sending_chunk": "Sending chunk to client",
|
||||
"output.cui.writer.adapt_error": "Failed to adapt message",
|
||||
"output.cui.writer.chunk_error": "Failed to send chunk",
|
||||
"output.cui.writer.group_error": "Failed to send message group",
|
||||
"output.cui.writer.send_error": "Failed to send data to client",
|
||||
"output.cui.writer.marshal_error": "Failed to marshal chunk",
|
||||
|
||||
// Common status messages
|
||||
"common.status.processing": "Processing",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.retrying": "Retrying",
|
||||
},
|
||||
}
|
||||
|
||||
// Built-in Chinese (Simplified) messages
|
||||
Locales["__global__"]["zh-cn"] = I18n{
|
||||
Locale: "zh-cn",
|
||||
Messages: map[string]any{
|
||||
// Assistant: agent.go Stream() function
|
||||
"assistant.agent.stream.label": "助手 {{name}}",
|
||||
"assistant.agent.stream.description": "助手 {{name}} 正在处理请求",
|
||||
"assistant.agent.stream.history": "获取聊天历史",
|
||||
"assistant.agent.stream.capabilities": "获取连接器能力",
|
||||
"assistant.agent.stream.create_hook": "调用 Create Hook",
|
||||
"assistant.agent.stream.closing": "关闭输出(根调用)",
|
||||
"assistant.agent.stream.skipping": "跳过输出关闭(嵌套调用)",
|
||||
"assistant.agent.stream.close_error": "关闭输出失败",
|
||||
|
||||
// LLM: providers/openai/openai.go Stream() function
|
||||
"llm.openai.stream.label": "LLM %s",
|
||||
"llm.openai.stream.description": "LLM %s 正在处理请求",
|
||||
"llm.openai.stream.starting": "开始流式请求",
|
||||
"llm.openai.stream.request": "流式请求",
|
||||
"llm.openai.stream.retry": "流式请求失败,正在重试",
|
||||
"llm.openai.stream.api_error": "OpenAI API 返回错误响应",
|
||||
"llm.openai.stream.error": "OpenAI 流错误",
|
||||
"llm.openai.stream.no_data": "导致空响应的请求体",
|
||||
"llm.openai.stream.no_data_info": "请求详情",
|
||||
"llm.openai.post.api_error": "OpenAI API 错误响应",
|
||||
|
||||
// LLM: handlers/stream.go (general LLM stream handler)
|
||||
"llm.handlers.stream.info": "LLM 流式输出",
|
||||
"llm.handlers.stream.raw_output": "LLM 原始输出",
|
||||
|
||||
// Output: adapters/openai/writer.go
|
||||
"output.openai.writer.sending_chunk": "向客户端发送数据块",
|
||||
"output.openai.writer.sending_done": "向客户端发送 [DONE]",
|
||||
"output.openai.writer.adapt_error": "适配消息失败",
|
||||
"output.openai.writer.chunk_error": "发送数据块失败",
|
||||
"output.openai.writer.group_error": "写入消息组中的消息失败",
|
||||
"output.openai.writer.send_error": "发送数据到客户端失败",
|
||||
"output.openai.writer.marshal_error": "序列化数据块失败",
|
||||
"output.openai.writer.done_error": "发送 [DONE] 到客户端失败",
|
||||
|
||||
// Output: adapters/cui/writer.go
|
||||
"output.cui.writer.sending_chunk": "向客户端发送数据块",
|
||||
"output.cui.writer.adapt_error": "适配消息失败",
|
||||
"output.cui.writer.chunk_error": "发送数据块失败",
|
||||
"output.cui.writer.group_error": "发送消息组失败",
|
||||
"output.cui.writer.send_error": "发送数据到客户端失败",
|
||||
"output.cui.writer.marshal_error": "序列化数据块失败",
|
||||
|
||||
// Common status messages
|
||||
"common.status.processing": "处理中",
|
||||
"common.status.completed": "已完成",
|
||||
"common.status.failed": "失败",
|
||||
"common.status.retrying": "重试中",
|
||||
},
|
||||
}
|
||||
|
||||
// Built-in Chinese (short code) - same as zh-cn
|
||||
Locales["__global__"]["zh"] = I18n{
|
||||
Locale: "zh",
|
||||
Messages: map[string]any{
|
||||
// Assistant: agent.go Stream() function
|
||||
"assistant.agent.stream.label": "助手 {{name}}",
|
||||
"assistant.agent.stream.description": "助手 {{name}} 正在处理请求",
|
||||
"assistant.agent.stream.history": "获取聊天历史",
|
||||
"assistant.agent.stream.capabilities": "获取连接器能力",
|
||||
"assistant.agent.stream.create_hook": "调用 Create Hook",
|
||||
"assistant.agent.stream.closing": "关闭输出(根调用)",
|
||||
"assistant.agent.stream.skipping": "跳过输出关闭(嵌套调用)",
|
||||
"assistant.agent.stream.close_error": "关闭输出失败",
|
||||
|
||||
// LLM: providers/openai/openai.go Stream() function
|
||||
"llm.openai.stream.label": "LLM %s",
|
||||
"llm.openai.stream.description": "LLM %s 正在处理请求",
|
||||
"llm.openai.stream.starting": "开始流式请求",
|
||||
"llm.openai.stream.request": "流式请求",
|
||||
"llm.openai.stream.retry": "流式请求失败,正在重试",
|
||||
"llm.openai.stream.api_error": "OpenAI API 返回错误响应",
|
||||
"llm.openai.stream.error": "OpenAI 流错误",
|
||||
"llm.openai.stream.no_data": "导致空响应的请求体",
|
||||
"llm.openai.stream.no_data_info": "请求详情",
|
||||
"llm.openai.post.api_error": "OpenAI API 错误响应",
|
||||
|
||||
// LLM: handlers/stream.go (general LLM stream handler)
|
||||
"llm.handlers.stream.info": "LLM 流式输出",
|
||||
"llm.handlers.stream.raw_output": "LLM 原始输出",
|
||||
|
||||
// Output: adapters/openai/writer.go
|
||||
"output.openai.writer.sending_chunk": "向客户端发送数据块",
|
||||
"output.openai.writer.sending_done": "向客户端发送 [DONE]",
|
||||
"output.openai.writer.adapt_error": "适配消息失败",
|
||||
"output.openai.writer.chunk_error": "发送数据块失败",
|
||||
"output.openai.writer.group_error": "写入消息组中的消息失败",
|
||||
"output.openai.writer.send_error": "发送数据到客户端失败",
|
||||
"output.openai.writer.marshal_error": "序列化数据块失败",
|
||||
"output.openai.writer.done_error": "发送 [DONE] 到客户端失败",
|
||||
|
||||
// Output: adapters/cui/writer.go
|
||||
"output.cui.writer.sending_chunk": "向客户端发送数据块",
|
||||
"output.cui.writer.adapt_error": "适配消息失败",
|
||||
"output.cui.writer.chunk_error": "发送数据块失败",
|
||||
"output.cui.writer.group_error": "发送消息组失败",
|
||||
"output.cui.writer.send_error": "发送数据到客户端失败",
|
||||
"output.cui.writer.marshal_error": "序列化数据块失败",
|
||||
|
||||
// Common status messages
|
||||
"common.status.processing": "处理中",
|
||||
"common.status.completed": "已完成",
|
||||
"common.status.failed": "失败",
|
||||
"common.status.retrying": "重试中",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package i18n
|
|||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
|
|
@ -68,11 +69,9 @@ func (i18n I18n) Parse(input any) any {
|
|||
func (i18n I18n) parseString(in string) string {
|
||||
trimed := strings.TrimSpace(in)
|
||||
|
||||
// Check if it's a template expression {{...}}
|
||||
hasExp := strings.HasPrefix(trimed, "{{") && strings.HasSuffix(trimed, "}}")
|
||||
if hasExp {
|
||||
exp := strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(trimed, "}}"), "{{"))
|
||||
if val, ok := i18n.Messages[exp]; ok {
|
||||
// Check if it's a direct message key (no template markers)
|
||||
if !strings.Contains(trimed, "{{") && !strings.Contains(trimed, "}}") {
|
||||
if val, ok := i18n.Messages[trimed]; ok {
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
|
|
@ -80,13 +79,48 @@ func (i18n I18n) parseString(in string) string {
|
|||
return in
|
||||
}
|
||||
|
||||
// Check if it's a direct message key
|
||||
if val, ok := i18n.Messages[trimed]; ok {
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
// Check if it's a full template expression {{...}} (exact match - entire string is one template)
|
||||
hasExp := strings.HasPrefix(trimed, "{{") && strings.HasSuffix(trimed, "}}")
|
||||
if hasExp {
|
||||
// Check if there's only ONE template pattern (no text before/after or multiple templates)
|
||||
re := regexp.MustCompile(`\{\{\s*([^}]+?)\s*\}\}`)
|
||||
matches := re.FindAllString(trimed, -1)
|
||||
|
||||
// Only treat as full template if there's exactly one match and it equals the trimmed string
|
||||
if len(matches) == 1 && matches[0] == trimed {
|
||||
exp := strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(trimed, "}}"), "{{"))
|
||||
if val, ok := i18n.Messages[exp]; ok {
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
}
|
||||
|
||||
// Handle embedded template variables: "text {{var}} more {{var2}}"
|
||||
if strings.Contains(in, "{{") && strings.Contains(in, "}}") {
|
||||
result := in
|
||||
// Use regex to find all {{...}} patterns
|
||||
re := regexp.MustCompile(`\{\{\s*([^}]+?)\s*\}\}`)
|
||||
matches := re.FindAllStringSubmatch(in, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) >= 2 {
|
||||
fullMatch := match[0] // Full match including {{ }}
|
||||
varName := strings.TrimSpace(match[1]) // Variable name without {{ }}
|
||||
|
||||
// Try to replace with value from Messages
|
||||
if val, ok := i18n.Messages[varName]; ok {
|
||||
if s, ok := val.(string); ok {
|
||||
result = strings.Replace(result, fullMatch, s, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
@ -123,97 +157,167 @@ func GetLocales(path string) (Map, error) {
|
|||
return i18ns, nil
|
||||
}
|
||||
|
||||
// Flatten flatten the i18n map
|
||||
func (i18ns Map) Flatten() Map {
|
||||
new := Map{}
|
||||
for lang, i18n := range i18ns {
|
||||
new[lang] = I18n{Locale: lang, Messages: maps.MapOf(i18n.Messages).Dot()}
|
||||
// Flatten flattens the map of locales by adding short language codes and region codes
|
||||
// e.g., "en-us" will also create "en" and "us" entries
|
||||
// If __global__ locales exist, they are merged (local/user messages override global built-in messages)
|
||||
func (m Map) Flatten() Map {
|
||||
flattened := make(Map)
|
||||
|
||||
// Add short lang
|
||||
parts := strings.Split(lang, "-")
|
||||
|
||||
// en
|
||||
if parts[0] != lang {
|
||||
new[parts[0]] = new[lang]
|
||||
// First, process local messages with Dot() flattening
|
||||
for localeCode, i18n := range m {
|
||||
// Flatten nested messages to dot notation (e.g., {"local": {"key": "value"}} -> {"local.key": "value"})
|
||||
flattened[localeCode] = I18n{
|
||||
Locale: localeCode,
|
||||
Messages: maps.MapOf(i18n.Messages).Dot(),
|
||||
}
|
||||
|
||||
// us
|
||||
// Add short language codes
|
||||
parts := strings.Split(localeCode, "-")
|
||||
if len(parts) > 1 {
|
||||
new[parts[1]] = new[lang]
|
||||
}
|
||||
}
|
||||
return new
|
||||
}
|
||||
|
||||
// FlattenWithGlobal flatten the i18n map with global i18n
|
||||
func (i18ns Map) FlattenWithGlobal() Map {
|
||||
|
||||
// New i18n map
|
||||
new := Map{}
|
||||
|
||||
// Global i18n
|
||||
globalI18ns, hasGlobal := Locales["__global__"]
|
||||
|
||||
// Extend the i18n map with global i18n
|
||||
for lang, i18n := range i18ns {
|
||||
new[lang] = I18n{Locale: lang, Messages: maps.MapOf(i18n.Messages).Dot()}
|
||||
if hasGlobal {
|
||||
if global, has := globalI18ns[lang]; has {
|
||||
for key, value := range global.Messages {
|
||||
if _, ok := new[lang].Messages[key]; !ok {
|
||||
new[lang].Messages[key] = value
|
||||
}
|
||||
}
|
||||
// Add short language code (e.g., "en" from "en-us")
|
||||
if _, ok := flattened[parts[0]]; !ok {
|
||||
flattened[parts[0]] = flattened[localeCode]
|
||||
}
|
||||
// Add region code (e.g., "us" from "en-us")
|
||||
if _, ok := flattened[parts[1]]; !ok {
|
||||
flattened[parts[1]] = flattened[localeCode]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add short lang
|
||||
parts := strings.Split(lang, "-")
|
||||
// Merge with global locales if they exist
|
||||
// Strategy: Start with global (built-in), then override with local (user)
|
||||
globalLocales, hasGlobal := Locales["__global__"]
|
||||
if !hasGlobal {
|
||||
return flattened
|
||||
}
|
||||
|
||||
// en
|
||||
if parts[0] != lang {
|
||||
new[parts[0]] = new[lang]
|
||||
}
|
||||
for globalLocaleCode, globalI18n := range globalLocales {
|
||||
// Ensure global messages are also flattened (though builtin.go already uses flat keys)
|
||||
globalFlattened := maps.MapOf(globalI18n.Messages).Dot()
|
||||
|
||||
// us
|
||||
if len(parts) > 1 {
|
||||
new[parts[1]] = new[lang]
|
||||
if localI18n, ok := flattened[globalLocaleCode]; ok {
|
||||
// Both global and local exist: merge with local overriding global
|
||||
mergedMessages := make(map[string]any)
|
||||
// First copy all global messages
|
||||
for k, v := range globalFlattened {
|
||||
mergedMessages[k] = v
|
||||
}
|
||||
// Then override with local messages
|
||||
for k, v := range localI18n.Messages {
|
||||
mergedMessages[k] = v
|
||||
}
|
||||
flattened[globalLocaleCode] = I18n{
|
||||
Locale: globalLocaleCode,
|
||||
Messages: mergedMessages,
|
||||
}
|
||||
} else {
|
||||
// Only global exists, add it with flattened messages
|
||||
flattened[globalLocaleCode] = I18n{
|
||||
Locale: globalLocaleCode,
|
||||
Messages: globalFlattened,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
return flattened
|
||||
}
|
||||
|
||||
// Translate translate the input
|
||||
// FlattenWithGlobal is deprecated. Use Flatten() instead, which now automatically merges with global locales.
|
||||
// Kept for backward compatibility.
|
||||
func (m Map) FlattenWithGlobal() Map {
|
||||
return m.Flatten()
|
||||
}
|
||||
|
||||
// Translate translate the input with recursive variable resolution
|
||||
// Fallback strategy: assistant locale -> assistant short codes -> global locale -> global short codes
|
||||
func Translate(assistantID string, locale string, input any) any {
|
||||
|
||||
locale = strings.ToLower(strings.TrimSpace(locale))
|
||||
i18ns, has := Locales[assistantID]
|
||||
if !has {
|
||||
i18ns = map[string]I18n{}
|
||||
|
||||
// Helper function to try translation with a specific i18n object
|
||||
tryTranslate := func(i18n I18n, input any) (any, bool) {
|
||||
result := i18n.Parse(input)
|
||||
// For string input, check if translation was found by comparing with input
|
||||
// For other types, Parse always returns a result (transformed or original)
|
||||
if inputStr, ok := input.(string); ok {
|
||||
if resultStr, ok := result.(string); ok {
|
||||
// Translation found if result is different from input
|
||||
if resultStr != inputStr {
|
||||
return result, true
|
||||
}
|
||||
return input, false
|
||||
}
|
||||
}
|
||||
// For non-string inputs (maps, slices), Parse always processes them
|
||||
return result, true
|
||||
}
|
||||
|
||||
i18n, has := i18ns[locale]
|
||||
if !has {
|
||||
// Helper function to process recursive templates
|
||||
processTemplates := func(result any, assistantID string, locale string) any {
|
||||
if resultStr, ok := result.(string); ok && strings.Contains(resultStr, "{{") && strings.Contains(resultStr, "}}") {
|
||||
re := regexp.MustCompile(`\{\{\s*([^}]+?)\s*\}\}`)
|
||||
resultStr = re.ReplaceAllStringFunc(resultStr, func(match string) string {
|
||||
varName := strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(match, "}}"), "{{"))
|
||||
translated := Translate(assistantID, locale, varName)
|
||||
if translatedStr, ok := translated.(string); ok && translatedStr != varName {
|
||||
return translatedStr
|
||||
}
|
||||
return match
|
||||
})
|
||||
return resultStr
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Try assistant locale first
|
||||
if i18ns, has := Locales[assistantID]; has {
|
||||
// Try exact locale
|
||||
if i18n, hasLocale := i18ns[locale]; hasLocale {
|
||||
if result, found := tryTranslate(i18n, input); found {
|
||||
return processTemplates(result, assistantID, locale)
|
||||
}
|
||||
}
|
||||
|
||||
// Try short codes
|
||||
parts := strings.Split(locale, "-")
|
||||
if len(parts) > 1 {
|
||||
i18n, has = i18ns[parts[1]]
|
||||
}
|
||||
if !has {
|
||||
i18n, has = i18ns[parts[0]]
|
||||
if i18n, hasLocale := i18ns[parts[1]]; hasLocale {
|
||||
if result, found := tryTranslate(i18n, input); found {
|
||||
return processTemplates(result, assistantID, locale)
|
||||
}
|
||||
}
|
||||
if i18n, hasLocale := i18ns[parts[0]]; hasLocale {
|
||||
if result, found := tryTranslate(i18n, input); found {
|
||||
return processTemplates(result, assistantID, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has {
|
||||
var hasGlobal bool = false
|
||||
i18ns, hasGlobal = Locales["__global__"]
|
||||
if hasGlobal {
|
||||
i18n, has = i18ns[locale]
|
||||
// Fallback to global locales
|
||||
if globalI18ns, hasGlobal := Locales["__global__"]; hasGlobal {
|
||||
// Try exact locale
|
||||
if i18n, hasLocale := globalI18ns[locale]; hasLocale {
|
||||
if result, found := tryTranslate(i18n, input); found {
|
||||
return processTemplates(result, assistantID, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has {
|
||||
return i18n.Parse(input)
|
||||
// Try short codes
|
||||
parts := strings.Split(locale, "-")
|
||||
if len(parts) > 1 {
|
||||
if i18n, hasLocale := globalI18ns[parts[1]]; hasLocale {
|
||||
if result, found := tryTranslate(i18n, input); found {
|
||||
return processTemplates(result, assistantID, locale)
|
||||
}
|
||||
}
|
||||
if i18n, hasLocale := globalI18ns[parts[0]]; hasLocale {
|
||||
if result, found := tryTranslate(i18n, input); found {
|
||||
return processTemplates(result, assistantID, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
|
|
@ -243,3 +347,25 @@ func TranslateGlobal(locale string, input any) any {
|
|||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// T is a short alias for TranslateGlobal that returns string
|
||||
// Usage: i18n.T(ctx.Locale, "assistant.agent.stream.label")
|
||||
// Variables in templates like {{variable}} will be recursively resolved from the global language pack
|
||||
func T(locale string, key string) string {
|
||||
result := TranslateGlobal(locale, key)
|
||||
if str, ok := result.(string); ok {
|
||||
return str
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Tr translates with assistantID and returns string
|
||||
// Supports recursive translation of {{variable}} templates
|
||||
// Usage: i18n.Tr(assistantID, locale, "key")
|
||||
func Tr(assistantID string, locale string, key string) string {
|
||||
result := Translate(assistantID, locale, key)
|
||||
if str, ok := result.(string); ok {
|
||||
return str
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,37 @@ func TestParseString(t *testing.T) {
|
|||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
// Embedded template tests (new feature)
|
||||
{
|
||||
name: "Embedded single template",
|
||||
input: "Hello {{hello}}",
|
||||
expected: "Hello Hello",
|
||||
},
|
||||
{
|
||||
name: "Embedded multiple templates",
|
||||
input: "{{hello}} {{world}}!",
|
||||
expected: "Hello World!",
|
||||
},
|
||||
{
|
||||
name: "Embedded template with spaces",
|
||||
input: "Say {{ hello }} to the {{ world }}",
|
||||
expected: "Say Hello to the World",
|
||||
},
|
||||
{
|
||||
name: "Embedded template mixed with text",
|
||||
input: "Message: {{greeting}} - {{description}}",
|
||||
expected: "Message: Hello, World! - This is a test",
|
||||
},
|
||||
{
|
||||
name: "Embedded template not found",
|
||||
input: "Hello {{notfound}} World",
|
||||
expected: "Hello {{notfound}} World",
|
||||
},
|
||||
{
|
||||
name: "Embedded template partial match",
|
||||
input: "{{hello}} {{notfound}} {{world}}",
|
||||
expected: "Hello {{notfound}} World",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -358,6 +389,12 @@ func TestMapFlatten(t *testing.T) {
|
|||
|
||||
// TestMapFlattenWithGlobal tests the FlattenWithGlobal method
|
||||
func TestMapFlattenWithGlobal(t *testing.T) {
|
||||
// Save and restore __global__
|
||||
originalGlobal := Locales["__global__"]
|
||||
defer func() {
|
||||
Locales["__global__"] = originalGlobal
|
||||
}()
|
||||
|
||||
// Setup global locales
|
||||
Locales["__global__"] = map[string]I18n{
|
||||
"en": {
|
||||
|
|
@ -369,8 +406,6 @@ func TestMapFlattenWithGlobal(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
defer delete(Locales, "__global__")
|
||||
|
||||
i18ns := Map{
|
||||
"en": I18n{
|
||||
Locale: "en",
|
||||
|
|
@ -405,6 +440,12 @@ func TestMapFlattenWithGlobal(t *testing.T) {
|
|||
|
||||
// TestMapFlattenWithGlobalNoGlobal tests FlattenWithGlobal when no global exists
|
||||
func TestMapFlattenWithGlobalNoGlobal(t *testing.T) {
|
||||
// Save and restore __global__
|
||||
originalGlobal := Locales["__global__"]
|
||||
defer func() {
|
||||
Locales["__global__"] = originalGlobal
|
||||
}()
|
||||
|
||||
// Make sure no global exists
|
||||
delete(Locales, "__global__")
|
||||
|
||||
|
|
@ -430,6 +471,12 @@ func TestMapFlattenWithGlobalNoGlobal(t *testing.T) {
|
|||
|
||||
// TestMapFlattenWithGlobalKeyConflict tests FlattenWithGlobal when local keys already exist
|
||||
func TestMapFlattenWithGlobalKeyConflict(t *testing.T) {
|
||||
// Save and restore __global__
|
||||
originalGlobal := Locales["__global__"]
|
||||
defer func() {
|
||||
Locales["__global__"] = originalGlobal
|
||||
}()
|
||||
|
||||
// Setup global with keys in flat format (after Dot())
|
||||
Locales["__global__"] = map[string]I18n{
|
||||
"en": {
|
||||
|
|
@ -441,7 +488,6 @@ func TestMapFlattenWithGlobalKeyConflict(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
defer delete(Locales, "__global__")
|
||||
|
||||
// Local messages in nested format (will be flattened by Dot())
|
||||
i18ns := Map{
|
||||
|
|
@ -543,6 +589,12 @@ func TestTranslate(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("Translate with fallback to global", func(t *testing.T) {
|
||||
// Save and restore __global__
|
||||
originalGlobal := Locales["__global__"]
|
||||
defer func() {
|
||||
Locales["__global__"] = originalGlobal
|
||||
}()
|
||||
|
||||
Locales["__global__"] = map[string]I18n{
|
||||
"es": {
|
||||
Locale: "es",
|
||||
|
|
@ -551,7 +603,6 @@ func TestTranslate(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
defer delete(Locales, "__global__")
|
||||
|
||||
result := Translate(assistantID, "es", "{{greeting}}")
|
||||
if result != "Hola" {
|
||||
|
|
@ -578,35 +629,68 @@ func TestTranslate(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestTranslateGlobal tests the TranslateGlobal function
|
||||
// TestTranslateGlobal tests the TranslateGlobal function with custom messages
|
||||
func TestTranslateGlobal(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
Locales["__global__"] = map[string]I18n{
|
||||
"en": {
|
||||
Locale: "en",
|
||||
Messages: map[string]any{
|
||||
"button.ok": "OK",
|
||||
"button.cancel": "Cancel",
|
||||
},
|
||||
},
|
||||
"zh-cn": {
|
||||
Locale: "zh-cn",
|
||||
Messages: map[string]any{
|
||||
"button.ok": "确定",
|
||||
"button.cancel": "取消",
|
||||
},
|
||||
},
|
||||
"zh": {
|
||||
Locale: "zh",
|
||||
Messages: map[string]any{
|
||||
"button.ok": "确定",
|
||||
"button.cancel": "取消",
|
||||
},
|
||||
},
|
||||
// Save existing __global__ and restore after test
|
||||
originalGlobal := make(map[string]I18n)
|
||||
if existing, ok := Locales["__global__"]; ok {
|
||||
for k, v := range existing {
|
||||
originalGlobal[k] = v
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
Locales["__global__"] = originalGlobal
|
||||
}()
|
||||
|
||||
// Add custom test messages to existing global (not replacing)
|
||||
if Locales["__global__"] == nil {
|
||||
Locales["__global__"] = make(map[string]I18n)
|
||||
}
|
||||
|
||||
// Extend existing English messages
|
||||
enMessages := make(map[string]any)
|
||||
if existing, ok := Locales["__global__"]["en"]; ok {
|
||||
for k, v := range existing.Messages {
|
||||
enMessages[k] = v
|
||||
}
|
||||
}
|
||||
enMessages["button.ok"] = "OK"
|
||||
enMessages["button.cancel"] = "Cancel"
|
||||
Locales["__global__"]["en"] = I18n{
|
||||
Locale: "en",
|
||||
Messages: enMessages,
|
||||
}
|
||||
|
||||
// Extend existing Chinese messages
|
||||
zhcnMessages := make(map[string]any)
|
||||
if existing, ok := Locales["__global__"]["zh-cn"]; ok {
|
||||
for k, v := range existing.Messages {
|
||||
zhcnMessages[k] = v
|
||||
}
|
||||
}
|
||||
zhcnMessages["button.ok"] = "确定"
|
||||
zhcnMessages["button.cancel"] = "取消"
|
||||
Locales["__global__"]["zh-cn"] = I18n{
|
||||
Locale: "zh-cn",
|
||||
Messages: zhcnMessages,
|
||||
}
|
||||
|
||||
// Extend existing Chinese short code messages
|
||||
zhMessages := make(map[string]any)
|
||||
if existing, ok := Locales["__global__"]["zh"]; ok {
|
||||
for k, v := range existing.Messages {
|
||||
zhMessages[k] = v
|
||||
}
|
||||
}
|
||||
zhMessages["button.ok"] = "确定"
|
||||
zhMessages["button.cancel"] = "取消"
|
||||
Locales["__global__"]["zh"] = I18n{
|
||||
Locale: "zh",
|
||||
Messages: zhMessages,
|
||||
}
|
||||
defer delete(Locales, "__global__")
|
||||
|
||||
t.Run("TranslateGlobal with match", func(t *testing.T) {
|
||||
result := TranslateGlobal("en", "{{button.ok}}")
|
||||
|
|
@ -637,13 +721,17 @@ func TestTranslateGlobal(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("TranslateGlobal no global", func(t *testing.T) {
|
||||
// Temporarily remove global
|
||||
temp := Locales["__global__"]
|
||||
delete(Locales, "__global__")
|
||||
|
||||
result := TranslateGlobal("en", "{{button.ok}}")
|
||||
if result != "{{button.ok}}" {
|
||||
t.Errorf("Expected '{{button.ok}}', got %v", result)
|
||||
}
|
||||
// Restore for cleanup
|
||||
Locales["__global__"] = map[string]I18n{}
|
||||
|
||||
// Restore
|
||||
Locales["__global__"] = temp
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -815,3 +903,341 @@ func TestEdgeCases(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuiltinMessages tests the built-in global messages
|
||||
func TestBuiltinMessages(t *testing.T) {
|
||||
// Save and restore __global__ to avoid test interference
|
||||
originalGlobal := make(map[string]I18n)
|
||||
if existing, ok := Locales["__global__"]; ok {
|
||||
for k, v := range existing {
|
||||
originalGlobal[k] = v
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
Locales["__global__"] = originalGlobal
|
||||
}()
|
||||
|
||||
t.Run("English built-in messages", func(t *testing.T) {
|
||||
// Test assistant messages
|
||||
result := TranslateGlobal("en", "{{assistant.agent.stream.label}}")
|
||||
expected := "Assistant {{name}}"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
result = TranslateGlobal("en", "{{assistant.agent.stream.description}}")
|
||||
expected = "Assistant {{name}} is processing the request"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
result = TranslateGlobal("en", "{{assistant.agent.stream.history}}")
|
||||
expected = "Get Chat History"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
// Test LLM messages (note: LLM uses %s for fmt.Sprintf, not {{name}} for recursive translation)
|
||||
result = TranslateGlobal("en", "{{llm.openai.stream.label}}")
|
||||
expected = "LLM %s"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
result = TranslateGlobal("en", "{{llm.handlers.stream.info}}")
|
||||
expected = "LLM Stream"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
// Test common messages
|
||||
result = TranslateGlobal("en", "{{common.status.processing}}")
|
||||
expected = "Processing"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Chinese (zh-cn) built-in messages", func(t *testing.T) {
|
||||
// Test assistant messages
|
||||
result := TranslateGlobal("zh-cn", "{{assistant.agent.stream.label}}")
|
||||
expected := "助手 {{name}}"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
result = TranslateGlobal("zh-cn", "{{assistant.agent.stream.description}}")
|
||||
expected = "助手 {{name}} 正在处理请求"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
result = TranslateGlobal("zh-cn", "{{assistant.agent.stream.history}}")
|
||||
expected = "获取聊天历史"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
// Test LLM messages
|
||||
result = TranslateGlobal("zh-cn", "{{llm.handlers.stream.info}}")
|
||||
expected = "LLM 流式输出"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
// Test common messages
|
||||
result = TranslateGlobal("zh-cn", "{{common.status.processing}}")
|
||||
expected = "处理中"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Chinese (zh) short code", func(t *testing.T) {
|
||||
result := TranslateGlobal("zh", "{{assistant.agent.stream.label}}")
|
||||
expected := "助手 {{name}}"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
result = TranslateGlobal("zh", "{{common.status.processing}}")
|
||||
expected = "处理中"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Embedded template with built-in messages", func(t *testing.T) {
|
||||
// English
|
||||
result := TranslateGlobal("en", "Status: {{common.status.processing}}")
|
||||
expected := "Status: Processing"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
// Chinese
|
||||
result = TranslateGlobal("zh-cn", "状态: {{common.status.processing}}")
|
||||
expected = "状态: 处理中"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Non-existent key in global", func(t *testing.T) {
|
||||
result := TranslateGlobal("en", "{{unknown.key}}")
|
||||
if result != "{{unknown.key}}" {
|
||||
t.Errorf("Expected '{{unknown.key}}', got '%v'", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTAlias tests the T function alias
|
||||
func TestTr(t *testing.T) {
|
||||
// Save original global locales
|
||||
originalGlobal := Locales["__global__"]
|
||||
defer func() {
|
||||
if originalGlobal != nil {
|
||||
Locales["__global__"] = originalGlobal
|
||||
} else {
|
||||
delete(Locales, "__global__")
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup test locales with nested templates
|
||||
Locales["__global__"] = map[string]I18n{
|
||||
"en": {
|
||||
Locale: "en",
|
||||
Messages: map[string]any{
|
||||
"assistant.label": "Assistant {{assistant.name}}", // Use full key path
|
||||
"assistant.name": "AI Helper",
|
||||
"assistant.description": "{{assistant.label}} is processing",
|
||||
"llm.label": "LLM {{model.deepseek}}", // Use full key path
|
||||
"model.deepseek": "DeepSeek",
|
||||
"deeply.nested": "Level1 {{level2}}",
|
||||
"level2": "Level2 {{level3}}",
|
||||
"level3": "Level3 End",
|
||||
"simple.message": "Hello World",
|
||||
},
|
||||
},
|
||||
"zh-cn": {
|
||||
Locale: "zh-cn",
|
||||
Messages: map[string]any{
|
||||
"assistant.label": "助手 {{assistant.name}}", // Use full key path
|
||||
"assistant.name": "智能助手",
|
||||
"assistant.description": "{{assistant.label}} 正在处理",
|
||||
"llm.label": "模型 {{model.deepseek}}", // Use full key path
|
||||
"model.deepseek": "深度求索",
|
||||
"deeply.nested": "第一层 {{level2}}",
|
||||
"level2": "第二层 {{level3}}",
|
||||
"level3": "第三层结束",
|
||||
"simple.message": "你好世界",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Setup assistant-specific locale (overrides assistant.name, but inherits assistant.label from global)
|
||||
Locales["test-assistant"] = map[string]I18n{
|
||||
"en": {
|
||||
Locale: "en",
|
||||
Messages: map[string]any{
|
||||
"assistant.name": "Custom Assistant", // This will override global when assistant.label is resolved
|
||||
},
|
||||
},
|
||||
}
|
||||
defer delete(Locales, "test-assistant")
|
||||
|
||||
t.Run("Simple translation without variables", func(t *testing.T) {
|
||||
result := Tr("__global__", "en", "simple.message")
|
||||
if result != "Hello World" {
|
||||
t.Errorf("Expected 'Hello World', got '%s'", result)
|
||||
}
|
||||
|
||||
result = Tr("__global__", "zh-cn", "simple.message")
|
||||
if result != "你好世界" {
|
||||
t.Errorf("Expected '你好世界', got '%s'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("One level nested variable", func(t *testing.T) {
|
||||
// "Assistant {{name}}" -> "Assistant AI Helper"
|
||||
result := Tr("__global__", "en", "assistant.label")
|
||||
if result != "Assistant AI Helper" {
|
||||
t.Errorf("Expected 'Assistant AI Helper', got '%s'", result)
|
||||
}
|
||||
|
||||
result = Tr("__global__", "zh-cn", "assistant.label")
|
||||
if result != "助手 智能助手" {
|
||||
t.Errorf("Expected '助手 智能助手', got '%s'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Two levels nested variables", func(t *testing.T) {
|
||||
// "{{assistant.label}} is processing" -> "Assistant AI Helper is processing"
|
||||
result := Tr("__global__", "en", "assistant.description")
|
||||
if result != "Assistant AI Helper is processing" {
|
||||
t.Errorf("Expected 'Assistant AI Helper is processing', got '%s'", result)
|
||||
}
|
||||
|
||||
result = Tr("__global__", "zh-cn", "assistant.description")
|
||||
if result != "助手 智能助手 正在处理" {
|
||||
t.Errorf("Expected '助手 智能助手 正在处理', got '%s'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Three levels deeply nested", func(t *testing.T) {
|
||||
// "Level1 {{level2}}" -> "Level1 Level2 {{level3}}" -> "Level1 Level2 Level3 End"
|
||||
result := Tr("__global__", "en", "deeply.nested")
|
||||
if result != "Level1 Level2 Level3 End" {
|
||||
t.Errorf("Expected 'Level1 Level2 Level3 End', got '%s'", result)
|
||||
}
|
||||
|
||||
result = Tr("__global__", "zh-cn", "deeply.nested")
|
||||
if result != "第一层 第二层 第三层结束" {
|
||||
t.Errorf("Expected '第一层 第二层 第三层结束', got '%s'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Assistant-specific override", func(t *testing.T) {
|
||||
// When assistant locale exists but doesn't have a key, it WILL fallback to global
|
||||
// This is key-level fallback: try assistant first, then fallback to global
|
||||
result := Tr("test-assistant", "en", "assistant.label")
|
||||
// "Assistant {{assistant.name}}" from global, then {{assistant.name}} -> "Custom Assistant" from assistant
|
||||
if result != "Assistant Custom Assistant" {
|
||||
t.Errorf("Expected 'Assistant Custom Assistant' (fallback to global with assistant override), got '%s'", result)
|
||||
}
|
||||
|
||||
// assistant has 'en' locale but doesn't have this key, fallback to global
|
||||
result = Tr("test-assistant", "en", "simple.message")
|
||||
if result != "Hello World" {
|
||||
t.Errorf("Expected 'Hello World' (fallback to global), got '%s'", result)
|
||||
}
|
||||
|
||||
// If assistant locale has the key, it will use assistant's value
|
||||
result = Tr("test-assistant", "en", "assistant.name")
|
||||
if result != "Custom Assistant" {
|
||||
t.Errorf("Expected 'Custom Assistant' (from assistant locale), got '%s'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Non-existent key returns original", func(t *testing.T) {
|
||||
result := Tr("__global__", "en", "non.existent.key")
|
||||
if result != "non.existent.key" {
|
||||
t.Errorf("Expected 'non.existent.key', got '%s'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LLM with model variable", func(t *testing.T) {
|
||||
result := Tr("__global__", "en", "llm.label")
|
||||
if result != "LLM DeepSeek" {
|
||||
t.Errorf("Expected 'LLM DeepSeek', got '%s'", result)
|
||||
}
|
||||
|
||||
result = Tr("__global__", "zh-cn", "llm.label")
|
||||
if result != "模型 深度求索" {
|
||||
t.Errorf("Expected '模型 深度求索', got '%s'", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTAlias(t *testing.T) {
|
||||
// Save and restore __global__ to avoid test interference
|
||||
originalGlobal := make(map[string]I18n)
|
||||
if existing, ok := Locales["__global__"]; ok {
|
||||
for k, v := range existing {
|
||||
originalGlobal[k] = v
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
Locales["__global__"] = originalGlobal
|
||||
}()
|
||||
|
||||
t.Run("T alias works like TranslateGlobal", func(t *testing.T) {
|
||||
// Test that T and TranslateGlobal return the same results
|
||||
input := "{{assistant.agent.stream.label}}"
|
||||
|
||||
resultT := T("en", input)
|
||||
resultGlobal := TranslateGlobal("en", input)
|
||||
|
||||
if resultT != resultGlobal {
|
||||
t.Errorf("T and TranslateGlobal should return same result. T: %v, TranslateGlobal: %v", resultT, resultGlobal)
|
||||
}
|
||||
|
||||
expected := "Assistant {{name}}"
|
||||
if resultT != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, resultT)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("T alias with Chinese", func(t *testing.T) {
|
||||
result := T("zh-cn", "{{assistant.agent.stream.history}}")
|
||||
expected := "获取聊天历史"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("T alias with embedded template", func(t *testing.T) {
|
||||
result := T("en", "Status: {{common.status.completed}}")
|
||||
expected := "Status: Completed"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("T with nested template (template in template value)", func(t *testing.T) {
|
||||
// assistant.agent.stream.label = "Assistant {{name}}" (contains {{name}} template)
|
||||
// This tests if we can get the template string itself
|
||||
result := T("en", "{{assistant.agent.stream.label}}")
|
||||
expected := "Assistant {{name}}"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%v'", expected, result)
|
||||
}
|
||||
|
||||
// Verify Chinese version too
|
||||
resultZh := T("zh-cn", "{{assistant.agent.stream.label}}")
|
||||
expectedZh := "助手 {{name}}"
|
||||
if resultZh != expectedZh {
|
||||
t.Errorf("Expected '%s', got '%v'", expectedZh, resultZh)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package handlers
|
|||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/output"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
|
@ -20,7 +21,7 @@ func DefaultStreamHandler(ctx *context.Context) context.StreamFunc {
|
|||
return func(chunkType context.StreamChunkType, data []byte) int {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace != nil {
|
||||
trace.Info("LLM Stream", map[string]any{"data": string(data)})
|
||||
trace.Info(i18n.T(ctx.Locale, "llm.handlers.stream.info"), map[string]any{"data": string(data)}) // "LLM Stream"
|
||||
}
|
||||
|
||||
// Handle different chunk types
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/gou/http"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/llm/adapters"
|
||||
"github.com/yaoapp/yao/agent/llm/providers/base"
|
||||
"github.com/yaoapp/yao/utils/jsonschema"
|
||||
|
|
@ -686,9 +687,7 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
|
|||
if errorDetected && errorBuffer.Len() > 0 {
|
||||
errorJSON := errorBuffer.String()
|
||||
if trace != nil {
|
||||
trace.Error("OpenAI API returned error response", map[string]any{
|
||||
"response": errorJSON,
|
||||
})
|
||||
trace.Error(i18n.T(ctx.Locale, "llm.openai.stream.api_error"), map[string]any{"response": errorJSON}) // "OpenAI API returned error response"
|
||||
}
|
||||
|
||||
// Try to parse error
|
||||
|
|
@ -711,9 +710,7 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
|
|||
|
||||
// Log any error from streaming
|
||||
if err != nil && trace != nil {
|
||||
trace.Error("OpenAI Stream Error", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(ctx.Locale, "llm.openai.stream.error"), map[string]any{"error": err.Error()}) // "OpenAI Stream Error"
|
||||
}
|
||||
|
||||
// Check if error is due to context cancellation
|
||||
|
|
@ -768,11 +765,9 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
|
|||
|
||||
// Log request details for debugging
|
||||
if requestBodyJSON, err := jsoniter.Marshal(requestBody); err == nil {
|
||||
trace.Error("Request body that caused empty response", map[string]any{
|
||||
"body": string(requestBodyJSON),
|
||||
})
|
||||
trace.Error(i18n.T(ctx.Locale, "llm.openai.stream.no_data"), map[string]any{"body": string(requestBodyJSON)}) // "Request body that caused empty response"
|
||||
}
|
||||
trace.Error("Request details", map[string]any{
|
||||
trace.Error(i18n.T(ctx.Locale, "llm.openai.stream.no_data_info"), map[string]any{ // "Request details"
|
||||
"url": url,
|
||||
"model": accumulator.model,
|
||||
"created": accumulator.created,
|
||||
|
|
@ -1058,9 +1053,7 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
|
|||
// Log full response data for debugging
|
||||
if trace != nil {
|
||||
if respJSON, err := jsoniter.Marshal(resp.Data); err == nil {
|
||||
trace.Error("OpenAI API error response", map[string]any{
|
||||
"response": string(respJSON),
|
||||
})
|
||||
trace.Error(i18n.T(ctx.Locale, "llm.openai.post.api_error"), map[string]any{"response": string(respJSON)}) // "OpenAI API error response"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
|
|
@ -27,7 +28,7 @@ func (w *Writer) Write(msg *message.Message) error {
|
|||
chunks, err := w.adapter.Adapt(msg)
|
||||
if err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("CUI Writer: Failed to adapt message", map[string]any{
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.cui.writer.adapt_error"), map[string]any{ // "CUI Writer: Failed to adapt message"
|
||||
"error": err.Error(),
|
||||
"message_type": msg.Type,
|
||||
})
|
||||
|
|
@ -39,9 +40,7 @@ func (w *Writer) Write(msg *message.Message) error {
|
|||
for _, chunk := range chunks {
|
||||
if err := w.sendChunk(chunk); err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("CUI Writer: Failed to send chunk", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.cui.writer.chunk_error"), map[string]any{"error": err.Error()}) // "CUI Writer: Failed to send chunk"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -58,7 +57,7 @@ func (w *Writer) WriteGroup(group *message.MessageGroup) error {
|
|||
// Send the group
|
||||
if err := w.sendChunk(group); err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("CUI Writer: Failed to send message group", map[string]any{
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.cui.writer.group_error"), map[string]any{ // "CUI Writer: Failed to send message group"
|
||||
"error": err.Error(),
|
||||
"group_id": group.ID,
|
||||
})
|
||||
|
|
@ -88,9 +87,7 @@ func (w *Writer) sendChunk(chunk interface{}) error {
|
|||
data, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("CUI Writer: Failed to marshal chunk", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.cui.writer.marshal_error"), map[string]any{"error": err.Error()}) // "CUI Writer: Failed to marshal chunk"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -106,9 +103,7 @@ func (w *Writer) sendChunk(chunk interface{}) error {
|
|||
// The context knows how to send data based on the connection type (SSE, WebSocket, etc.)
|
||||
if err := w.ctx.Send(data); err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("CUI Writer: Failed to send data to client", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.cui.writer.send_error"), map[string]any{"error": err.Error()}) // "CUI Writer: Failed to send data to client"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ func (w *Writer) Write(msg *message.Message) error {
|
|||
chunks, err := w.adapter.Adapt(msg)
|
||||
if err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("OpenAI Writer: Failed to adapt message", map[string]any{
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.openai.writer.adapt_error"), map[string]any{ // "OpenAI Writer: Failed to adapt message"
|
||||
"error": err.Error(),
|
||||
"message_type": msg.Type,
|
||||
})
|
||||
|
|
@ -56,9 +57,7 @@ func (w *Writer) Write(msg *message.Message) error {
|
|||
|
||||
if err := w.sendChunk(chunk); err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("OpenAI Writer: Failed to send chunk", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.openai.writer.chunk_error"), map[string]any{"error": err.Error()}) // "OpenAI Writer: Failed to send chunk"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -74,7 +73,7 @@ func (w *Writer) WriteGroup(group *message.MessageGroup) error {
|
|||
for _, msg := range group.Messages {
|
||||
if err := w.Write(msg); err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("OpenAI Writer: Failed to write message in group", map[string]any{
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.openai.writer.group_error"), map[string]any{ // "OpenAI Writer: Failed to write message in group"
|
||||
"error": err.Error(),
|
||||
"group_id": group.ID,
|
||||
"message_type": msg.Type,
|
||||
|
|
@ -106,9 +105,7 @@ func (w *Writer) sendChunk(chunk interface{}) error {
|
|||
data, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("OpenAI Writer: Failed to marshal chunk", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.openai.writer.marshal_error"), map[string]any{"error": err.Error()}) // "OpenAI Writer: Failed to marshal chunk"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -127,9 +124,7 @@ func (w *Writer) sendChunk(chunk interface{}) error {
|
|||
// Send via context's writer
|
||||
if err := w.ctx.Send(sseData); err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("OpenAI Writer: Failed to send data to client", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.openai.writer.send_error"), map[string]any{"error": err.Error()}) // "OpenAI Writer: Failed to send data to client"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -148,9 +143,7 @@ func (w *Writer) sendDone() error {
|
|||
doneData := []byte("data: [DONE]\n\n")
|
||||
if err := w.ctx.Send(doneData); err != nil {
|
||||
if trace, _ := w.ctx.Trace(); trace != nil {
|
||||
trace.Error("OpenAI Writer: Failed to send [DONE] to client", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
trace.Error(i18n.T(w.ctx.Locale, "output.openai.writer.done_error"), map[string]any{"error": err.Error()}) // "OpenAI Writer: Failed to send [DONE] to client"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue