diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index 2ce9f6fe..3fca5878 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -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 { diff --git a/agent/i18n/builtin.go b/agent/i18n/builtin.go new file mode 100644 index 00000000..a466e440 --- /dev/null +++ b/agent/i18n/builtin.go @@ -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": "重试中", + }, + } +} diff --git a/agent/i18n/i18n.go b/agent/i18n/i18n.go index c29e3bb7..d5dfb97d 100644 --- a/agent/i18n/i18n.go +++ b/agent/i18n/i18n.go @@ -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 +} diff --git a/agent/i18n/i18n_test.go b/agent/i18n/i18n_test.go index 682b8636..d91f7ba2 100644 --- a/agent/i18n/i18n_test.go +++ b/agent/i18n/i18n_test.go @@ -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) + } + }) +} diff --git a/agent/llm/handlers/stream.go b/agent/llm/handlers/stream.go index 05617928..46c6bb8b 100644 --- a/agent/llm/handlers/stream.go +++ b/agent/llm/handlers/stream.go @@ -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 diff --git a/agent/llm/providers/openai/openai.go b/agent/llm/providers/openai/openai.go index 1e7b0e9c..9d1b7dc9 100644 --- a/agent/llm/providers/openai/openai.go +++ b/agent/llm/providers/openai/openai.go @@ -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" } } } diff --git a/agent/output/adapters/cui/writer.go b/agent/output/adapters/cui/writer.go index dcee6f58..695a817f 100644 --- a/agent/output/adapters/cui/writer.go +++ b/agent/output/adapters/cui/writer.go @@ -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 } diff --git a/agent/output/adapters/openai/writer.go b/agent/output/adapters/openai/writer.go index d9ebd09d..bdbd83bb 100644 --- a/agent/output/adapters/openai/writer.go +++ b/agent/output/adapters/openai/writer.go @@ -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 }