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:
Max 2025-11-21 11:17:49 +08:00
parent fe0d93fa04
commit 30778bbf2f
8 changed files with 863 additions and 150 deletions

View file

@ -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
View 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": "重试中",
},
}
}

View file

@ -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
}

View file

@ -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)
}
})
}

View file

@ -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

View file

@ -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"
}
}
}

View file

@ -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
}

View file

@ -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
}