Enhance i18n support in assistant model and storage
- Added null handling for input in the I18n Parse method to prevent errors with nil values. - Refactored string parsing logic into a separate method for improved clarity and maintainability. - Updated assistant translation logic to streamline the application of i18n translations for various fields, including name, description, and prompts. - Introduced new tests to validate the retrieval of assistants with locale-specific translations, ensuring correct behavior across different languages. - Enhanced the GetHistoryWithFilter method to maintain consistent silent message filtering and pagination logic.
This commit is contained in:
parent
e763fadc6e
commit
bbcec4d186
6 changed files with 1230 additions and 97 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -46,4 +46,5 @@ data/bindata.go.bak
|
||||||
share/const.go.bak
|
share/const.go.bak
|
||||||
share/const.goe
|
share/const.goe
|
||||||
.cursor
|
.cursor
|
||||||
openapi/*.md
|
openapi/*.md
|
||||||
|
coverage.html
|
||||||
|
|
|
||||||
|
|
@ -23,24 +23,13 @@ type Map map[string]I18n
|
||||||
|
|
||||||
// Parse parse the input
|
// Parse parse the input
|
||||||
func (i18n I18n) Parse(input any) any {
|
func (i18n I18n) Parse(input any) any {
|
||||||
|
if input == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
switch in := input.(type) {
|
switch in := input.(type) {
|
||||||
case string:
|
case string:
|
||||||
trimed := strings.TrimSpace(in)
|
return i18n.parseString(in)
|
||||||
hasExp := strings.HasPrefix(trimed, "{{") && strings.HasSuffix(trimed, "}}")
|
|
||||||
if hasExp {
|
|
||||||
exp := strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(trimed, "}}"), "{{"))
|
|
||||||
if _, ok := i18n.Messages[exp]; ok {
|
|
||||||
return i18n.Messages[exp]
|
|
||||||
}
|
|
||||||
return exp
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := i18n.Messages[trimed]; ok {
|
|
||||||
return i18n.Messages[trimed]
|
|
||||||
}
|
|
||||||
|
|
||||||
return in
|
|
||||||
|
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
new := map[string]any{}
|
new := map[string]any{}
|
||||||
|
|
@ -59,7 +48,15 @@ func (i18n I18n) Parse(input any) any {
|
||||||
case []string:
|
case []string:
|
||||||
new := []string{}
|
new := []string{}
|
||||||
for _, value := range in {
|
for _, value := range in {
|
||||||
new = append(new, i18n.Parse(value).(string))
|
if parsed := i18n.Parse(value); parsed != nil {
|
||||||
|
if s, ok := parsed.(string); ok {
|
||||||
|
new = append(new, s)
|
||||||
|
} else {
|
||||||
|
new = append(new, value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
new = append(new, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return new
|
return new
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +64,32 @@ func (i18n I18n) Parse(input any) any {
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseString parse a string value
|
||||||
|
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 {
|
||||||
|
if s, ok := val.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocales get the locales from path
|
// GetLocales get the locales from path
|
||||||
func GetLocales(path string) (Map, error) {
|
func GetLocales(path string) (Map, error) {
|
||||||
app, err := fs.Get("app")
|
app, err := fs.Get("app")
|
||||||
|
|
|
||||||
817
agent/i18n/i18n_test.go
Normal file
817
agent/i18n/i18n_test.go
Normal file
|
|
@ -0,0 +1,817 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseString tests the parseString method
|
||||||
|
func TestParseString(t *testing.T) {
|
||||||
|
i18n := I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"hello": "Hello",
|
||||||
|
"world": "World",
|
||||||
|
"greeting": "Hello, World!",
|
||||||
|
"description": "This is a test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Template expression with match",
|
||||||
|
input: "{{greeting}}",
|
||||||
|
expected: "Hello, World!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Template expression with spaces",
|
||||||
|
input: "{{ greeting }}",
|
||||||
|
expected: "Hello, World!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Template expression without match",
|
||||||
|
input: "{{notfound}}",
|
||||||
|
expected: "{{notfound}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Direct message key",
|
||||||
|
input: "hello",
|
||||||
|
expected: "Hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Direct message key with spaces",
|
||||||
|
input: " world ",
|
||||||
|
expected: "World",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-existent key",
|
||||||
|
input: "notfound",
|
||||||
|
expected: "notfound",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Regular text",
|
||||||
|
input: "Just some text",
|
||||||
|
expected: "Just some text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := i18n.parseString(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("parseString(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseStringNonStringValue tests parseString when message value is not a string
|
||||||
|
func TestParseStringNonStringValue(t *testing.T) {
|
||||||
|
i18n := I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"number": 123,
|
||||||
|
"object": map[string]any{"key": "value"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Template with number value",
|
||||||
|
input: "{{number}}",
|
||||||
|
expected: "{{number}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Direct key with number value",
|
||||||
|
input: "number",
|
||||||
|
expected: "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Template with object value",
|
||||||
|
input: "{{object}}",
|
||||||
|
expected: "{{object}}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := i18n.parseString(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("parseString(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParse tests the Parse method with various input types
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
i18n := I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"name": "John",
|
||||||
|
"description": "A developer",
|
||||||
|
"title": "Welcome",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Nil input", func(t *testing.T) {
|
||||||
|
result := i18n.Parse(nil)
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("Parse(nil) = %v, want nil", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("String input", func(t *testing.T) {
|
||||||
|
result := i18n.Parse("{{name}}")
|
||||||
|
if result != "John" {
|
||||||
|
t.Errorf("Parse({{name}}) = %v, want 'John'", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Map input", func(t *testing.T) {
|
||||||
|
input := map[string]any{
|
||||||
|
"name": "{{name}}",
|
||||||
|
"description": "{{description}}",
|
||||||
|
"age": 30,
|
||||||
|
}
|
||||||
|
result := i18n.Parse(input)
|
||||||
|
if resultMap, ok := result.(map[string]any); ok {
|
||||||
|
if resultMap["name"] != "John" {
|
||||||
|
t.Errorf("Expected name 'John', got %v", resultMap["name"])
|
||||||
|
}
|
||||||
|
if resultMap["description"] != "A developer" {
|
||||||
|
t.Errorf("Expected description 'A developer', got %v", resultMap["description"])
|
||||||
|
}
|
||||||
|
if resultMap["age"] != 30 {
|
||||||
|
t.Errorf("Expected age 30, got %v", resultMap["age"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected map[string]any, got %T", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Slice of any", func(t *testing.T) {
|
||||||
|
input := []any{"{{name}}", "{{description}}", 123}
|
||||||
|
result := i18n.Parse(input)
|
||||||
|
if resultSlice, ok := result.([]any); ok {
|
||||||
|
if len(resultSlice) != 3 {
|
||||||
|
t.Errorf("Expected 3 elements, got %d", len(resultSlice))
|
||||||
|
}
|
||||||
|
if resultSlice[0] != "John" {
|
||||||
|
t.Errorf("Expected 'John', got %v", resultSlice[0])
|
||||||
|
}
|
||||||
|
if resultSlice[1] != "A developer" {
|
||||||
|
t.Errorf("Expected 'A developer', got %v", resultSlice[1])
|
||||||
|
}
|
||||||
|
if resultSlice[2] != 123 {
|
||||||
|
t.Errorf("Expected 123, got %v", resultSlice[2])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected []any, got %T", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Slice of strings", func(t *testing.T) {
|
||||||
|
input := []string{"{{name}}", "{{description}}", "plain text"}
|
||||||
|
result := i18n.Parse(input)
|
||||||
|
if resultSlice, ok := result.([]string); ok {
|
||||||
|
if len(resultSlice) != 3 {
|
||||||
|
t.Errorf("Expected 3 elements, got %d", len(resultSlice))
|
||||||
|
}
|
||||||
|
if resultSlice[0] != "John" {
|
||||||
|
t.Errorf("Expected 'John', got %v", resultSlice[0])
|
||||||
|
}
|
||||||
|
if resultSlice[1] != "A developer" {
|
||||||
|
t.Errorf("Expected 'A developer', got %v", resultSlice[1])
|
||||||
|
}
|
||||||
|
if resultSlice[2] != "plain text" {
|
||||||
|
t.Errorf("Expected 'plain text', got %v", resultSlice[2])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected []string, got %T", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Nested structures", func(t *testing.T) {
|
||||||
|
input := map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"name": "{{name}}",
|
||||||
|
"info": []string{"{{title}}", "{{description}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result := i18n.Parse(input)
|
||||||
|
if resultMap, ok := result.(map[string]any); ok {
|
||||||
|
if userMap, ok := resultMap["user"].(map[string]any); ok {
|
||||||
|
if userMap["name"] != "John" {
|
||||||
|
t.Errorf("Expected nested name 'John', got %v", userMap["name"])
|
||||||
|
}
|
||||||
|
if infoSlice, ok := userMap["info"].([]any); ok {
|
||||||
|
if infoSlice[0] != "Welcome" {
|
||||||
|
t.Errorf("Expected 'Welcome', got %v", infoSlice[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Other types pass through", func(t *testing.T) {
|
||||||
|
input := 12345
|
||||||
|
result := i18n.Parse(input)
|
||||||
|
if result != input {
|
||||||
|
t.Errorf("Expected %v, got %v", input, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSliceStringWithNilAndNonString tests []string parsing edge cases
|
||||||
|
func TestParseSliceStringWithNilAndNonString(t *testing.T) {
|
||||||
|
i18n := I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"key1": "value1",
|
||||||
|
"key2": 123, // Non-string value
|
||||||
|
"key3": nil, // Nil value
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("String slice with fallback", func(t *testing.T) {
|
||||||
|
input := []string{"{{key1}}", "{{key2}}", "{{notfound}}"}
|
||||||
|
result := i18n.Parse(input)
|
||||||
|
if resultSlice, ok := result.([]string); ok {
|
||||||
|
if resultSlice[0] != "value1" {
|
||||||
|
t.Errorf("Expected 'value1', got %v", resultSlice[0])
|
||||||
|
}
|
||||||
|
// key2 has non-string value, should fallback to original
|
||||||
|
if resultSlice[1] != "{{key2}}" {
|
||||||
|
t.Errorf("Expected '{{key2}}', got %v", resultSlice[1])
|
||||||
|
}
|
||||||
|
if resultSlice[2] != "{{notfound}}" {
|
||||||
|
t.Errorf("Expected '{{notfound}}', got %v", resultSlice[2])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected []string, got %T", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("String slice with nil parsed result", func(t *testing.T) {
|
||||||
|
// This tests the case where Parse returns nil for a string
|
||||||
|
input := []string{"{{key3}}", "normal"}
|
||||||
|
result := i18n.Parse(input)
|
||||||
|
if resultSlice, ok := result.([]string); ok {
|
||||||
|
// When parsed is nil, should fallback to original
|
||||||
|
if resultSlice[0] != "{{key3}}" {
|
||||||
|
t.Errorf("Expected '{{key3}}' (tests nil parsed branch), got %v", resultSlice[0])
|
||||||
|
}
|
||||||
|
if resultSlice[1] != "normal" {
|
||||||
|
t.Errorf("Expected 'normal', got %v", resultSlice[1])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected []string, got %T", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("String slice with non-string parsed result from map", func(t *testing.T) {
|
||||||
|
i18nWithMap := I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"map_key": map[string]any{"nested": "value"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// When Parse returns a non-string type (like a map), should fallback
|
||||||
|
input := []string{"{{map_key}}", "text"}
|
||||||
|
result := i18nWithMap.Parse(input)
|
||||||
|
if resultSlice, ok := result.([]string); ok {
|
||||||
|
// Should fallback to original when parsed is not string
|
||||||
|
if resultSlice[0] != "{{map_key}}" {
|
||||||
|
t.Errorf("Expected '{{map_key}}' (tests non-string parsed branch), got %v", resultSlice[0])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected []string, got %T", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMapFlatten tests the Flatten method
|
||||||
|
func TestMapFlatten(t *testing.T) {
|
||||||
|
i18ns := Map{
|
||||||
|
"en-us": I18n{
|
||||||
|
Locale: "en-us",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"greeting": "Hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"zh-cn": I18n{
|
||||||
|
Locale: "zh-cn",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"greeting": "你好",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
flattened := i18ns.Flatten()
|
||||||
|
|
||||||
|
// Should have original keys
|
||||||
|
if _, ok := flattened["en-us"]; !ok {
|
||||||
|
t.Error("Expected 'en-us' key in flattened map")
|
||||||
|
}
|
||||||
|
if _, ok := flattened["zh-cn"]; !ok {
|
||||||
|
t.Error("Expected 'zh-cn' key in flattened map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have short lang codes
|
||||||
|
if _, ok := flattened["en"]; !ok {
|
||||||
|
t.Error("Expected 'en' short code in flattened map")
|
||||||
|
}
|
||||||
|
if _, ok := flattened["us"]; !ok {
|
||||||
|
t.Error("Expected 'us' region code in flattened map")
|
||||||
|
}
|
||||||
|
if _, ok := flattened["zh"]; !ok {
|
||||||
|
t.Error("Expected 'zh' short code in flattened map")
|
||||||
|
}
|
||||||
|
if _, ok := flattened["cn"]; !ok {
|
||||||
|
t.Error("Expected 'cn' region code in flattened map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify messages are preserved
|
||||||
|
if msg, ok := flattened["en"].Messages["greeting"].(string); !ok || msg != "Hello" {
|
||||||
|
t.Errorf("Expected 'Hello', got %v", flattened["en"].Messages["greeting"])
|
||||||
|
}
|
||||||
|
if msg, ok := flattened["zh"].Messages["greeting"].(string); !ok || msg != "你好" {
|
||||||
|
t.Errorf("Expected '你好', got %v", flattened["zh"].Messages["greeting"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMapFlattenWithGlobal tests the FlattenWithGlobal method
|
||||||
|
func TestMapFlattenWithGlobal(t *testing.T) {
|
||||||
|
// Setup global locales
|
||||||
|
Locales["__global__"] = map[string]I18n{
|
||||||
|
"en": {
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"global.key": "Global Value",
|
||||||
|
"common": "Common",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
defer delete(Locales, "__global__")
|
||||||
|
|
||||||
|
i18ns := Map{
|
||||||
|
"en": I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"local.key": "Local Value",
|
||||||
|
"common": "Local Common", // Should override global
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
flattened := i18ns.FlattenWithGlobal()
|
||||||
|
|
||||||
|
if _, ok := flattened["en"]; !ok {
|
||||||
|
t.Fatal("Expected 'en' key in flattened map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have local key
|
||||||
|
if val, ok := flattened["en"].Messages["local.key"].(string); !ok || val != "Local Value" {
|
||||||
|
t.Errorf("Expected 'Local Value', got %v", flattened["en"].Messages["local.key"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have global key
|
||||||
|
if val, ok := flattened["en"].Messages["global.key"].(string); !ok || val != "Global Value" {
|
||||||
|
t.Errorf("Expected 'Global Value', got %v", flattened["en"].Messages["global.key"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local should override global
|
||||||
|
if val, ok := flattened["en"].Messages["common"].(string); !ok || val != "Local Common" {
|
||||||
|
t.Errorf("Expected 'Local Common', got %v", flattened["en"].Messages["common"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMapFlattenWithGlobalNoGlobal tests FlattenWithGlobal when no global exists
|
||||||
|
func TestMapFlattenWithGlobalNoGlobal(t *testing.T) {
|
||||||
|
// Make sure no global exists
|
||||||
|
delete(Locales, "__global__")
|
||||||
|
|
||||||
|
i18ns := Map{
|
||||||
|
"en": I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
flattened := i18ns.FlattenWithGlobal()
|
||||||
|
|
||||||
|
if _, ok := flattened["en"]; !ok {
|
||||||
|
t.Fatal("Expected 'en' key in flattened map")
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := flattened["en"].Messages["key"].(string); !ok || val != "value" {
|
||||||
|
t.Errorf("Expected 'value', got %v", flattened["en"].Messages["key"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMapFlattenWithGlobalKeyConflict tests FlattenWithGlobal when local keys already exist
|
||||||
|
func TestMapFlattenWithGlobalKeyConflict(t *testing.T) {
|
||||||
|
// Setup global with keys in flat format (after Dot())
|
||||||
|
Locales["__global__"] = map[string]I18n{
|
||||||
|
"en": {
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"shared.key": "Global Shared",
|
||||||
|
"global.only": "Global Only",
|
||||||
|
"local.key": "Global Local", // Will be overridden
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer delete(Locales, "__global__")
|
||||||
|
|
||||||
|
// Local messages in nested format (will be flattened by Dot())
|
||||||
|
i18ns := Map{
|
||||||
|
"en": I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"local": map[string]any{
|
||||||
|
"key": "Local Value", // After Dot() becomes "local.key", should override global
|
||||||
|
},
|
||||||
|
"unique": map[string]any{
|
||||||
|
"key": "Local Unique",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
flattened := i18ns.FlattenWithGlobal()
|
||||||
|
|
||||||
|
if _, ok := flattened["en"]; !ok {
|
||||||
|
t.Fatal("Expected 'en' key in flattened map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local key should exist and NOT be overridden by global
|
||||||
|
if val, ok := flattened["en"].Messages["local.key"].(string); !ok || val != "Local Value" {
|
||||||
|
t.Errorf("Expected 'Local Value' (local should override global), got %v", flattened["en"].Messages["local.key"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global only key should exist
|
||||||
|
if val, ok := flattened["en"].Messages["global.only"].(string); !ok || val != "Global Only" {
|
||||||
|
t.Errorf("Expected 'Global Only', got %v", flattened["en"].Messages["global.only"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique local key should exist
|
||||||
|
if val, ok := flattened["en"].Messages["unique.key"].(string); !ok || val != "Local Unique" {
|
||||||
|
t.Errorf("Expected 'Local Unique', got %v", flattened["en"].Messages["unique.key"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared key from global should exist
|
||||||
|
if val, ok := flattened["en"].Messages["shared.key"].(string); !ok || val != "Global Shared" {
|
||||||
|
t.Errorf("Expected 'Global Shared', got %v", flattened["en"].Messages["shared.key"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTranslate tests the Translate function
|
||||||
|
func TestTranslate(t *testing.T) {
|
||||||
|
assistantID := "test-assistant"
|
||||||
|
Locales[assistantID] = map[string]I18n{
|
||||||
|
"en": {
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"greeting": "Hello",
|
||||||
|
"name": "John",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"zh-cn": {
|
||||||
|
Locale: "zh-cn",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"greeting": "你好",
|
||||||
|
"name": "张三",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer delete(Locales, assistantID)
|
||||||
|
|
||||||
|
t.Run("Translate with exact locale match", func(t *testing.T) {
|
||||||
|
result := Translate(assistantID, "en", "{{greeting}}")
|
||||||
|
if result != "Hello" {
|
||||||
|
t.Errorf("Expected 'Hello', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Translate with locale variant", func(t *testing.T) {
|
||||||
|
result := Translate(assistantID, "zh-CN", "{{greeting}}")
|
||||||
|
if result != "你好" {
|
||||||
|
t.Errorf("Expected '你好', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Translate with short locale code", func(t *testing.T) {
|
||||||
|
result := Translate(assistantID, "en-us", "{{name}}")
|
||||||
|
if result != "John" {
|
||||||
|
t.Errorf("Expected 'John', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Translate without locale match", func(t *testing.T) {
|
||||||
|
result := Translate(assistantID, "fr", "{{greeting}}")
|
||||||
|
// Should return original when no locale found
|
||||||
|
if result != "{{greeting}}" {
|
||||||
|
t.Errorf("Expected '{{greeting}}', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Translate non-existent assistant", func(t *testing.T) {
|
||||||
|
result := Translate("nonexistent", "en", "{{greeting}}")
|
||||||
|
if result != "{{greeting}}" {
|
||||||
|
t.Errorf("Expected '{{greeting}}', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Translate with fallback to global", func(t *testing.T) {
|
||||||
|
Locales["__global__"] = map[string]I18n{
|
||||||
|
"es": {
|
||||||
|
Locale: "es",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"greeting": "Hola",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer delete(Locales, "__global__")
|
||||||
|
|
||||||
|
result := Translate(assistantID, "es", "{{greeting}}")
|
||||||
|
if result != "Hola" {
|
||||||
|
t.Errorf("Expected 'Hola', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Translate complex structure", func(t *testing.T) {
|
||||||
|
input := map[string]any{
|
||||||
|
"title": "{{greeting}}",
|
||||||
|
"user": "{{name}}",
|
||||||
|
}
|
||||||
|
result := Translate(assistantID, "zh-cn", input)
|
||||||
|
if resultMap, ok := result.(map[string]any); ok {
|
||||||
|
if resultMap["title"] != "你好" {
|
||||||
|
t.Errorf("Expected '你好', got %v", resultMap["title"])
|
||||||
|
}
|
||||||
|
if resultMap["user"] != "张三" {
|
||||||
|
t.Errorf("Expected '张三', got %v", resultMap["user"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Expected map[string]any, got %T", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTranslateGlobal tests the TranslateGlobal function
|
||||||
|
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": "取消",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer delete(Locales, "__global__")
|
||||||
|
|
||||||
|
t.Run("TranslateGlobal with match", func(t *testing.T) {
|
||||||
|
result := TranslateGlobal("en", "{{button.ok}}")
|
||||||
|
if result != "OK" {
|
||||||
|
t.Errorf("Expected 'OK', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TranslateGlobal with Chinese", func(t *testing.T) {
|
||||||
|
result := TranslateGlobal("zh-cn", "{{button.cancel}}")
|
||||||
|
if result != "取消" {
|
||||||
|
t.Errorf("Expected '取消', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TranslateGlobal with short code", func(t *testing.T) {
|
||||||
|
result := TranslateGlobal("zh-TW", "{{button.ok}}")
|
||||||
|
if result != "确定" {
|
||||||
|
t.Errorf("Expected '确定', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TranslateGlobal without match", func(t *testing.T) {
|
||||||
|
result := TranslateGlobal("fr", "{{button.ok}}")
|
||||||
|
if result != "{{button.ok}}" {
|
||||||
|
t.Errorf("Expected '{{button.ok}}', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TranslateGlobal no global", func(t *testing.T) {
|
||||||
|
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{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetLocalesIntegration tests GetLocales with real assistant data
|
||||||
|
func TestGetLocalesIntegration(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Use the real mohe assistant path (relative to app root)
|
||||||
|
assistantPath := "/assistants/mohe"
|
||||||
|
|
||||||
|
t.Run("Load real locale files", func(t *testing.T) {
|
||||||
|
locales, err := GetLocales(assistantPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least 2 locales (en-us and zh-cn)
|
||||||
|
if len(locales) < 2 {
|
||||||
|
t.Errorf("Expected at least 2 locales, got %d", len(locales))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check en-us locale
|
||||||
|
if enUS, ok := locales["en-us"]; ok {
|
||||||
|
if enUS.Locale != "en-us" {
|
||||||
|
t.Errorf("Expected locale 'en-us', got %s", enUS.Locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check some messages
|
||||||
|
if desc, ok := enUS.Messages["description"].(string); ok {
|
||||||
|
if desc == "" {
|
||||||
|
t.Error("Expected non-empty description")
|
||||||
|
}
|
||||||
|
t.Logf("English description: %s", desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chat, ok := enUS.Messages["chat"].(map[string]interface{}); ok {
|
||||||
|
if title, ok := chat["title"].(string); ok {
|
||||||
|
if title != "New Chat" {
|
||||||
|
t.Errorf("Expected 'New Chat', got %s", title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("Expected 'en-us' locale")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check zh-cn locale
|
||||||
|
if zhCN, ok := locales["zh-cn"]; ok {
|
||||||
|
if zhCN.Locale != "zh-cn" {
|
||||||
|
t.Errorf("Expected locale 'zh-cn', got %s", zhCN.Locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check some messages
|
||||||
|
if desc, ok := zhCN.Messages["description"].(string); ok {
|
||||||
|
if desc == "" {
|
||||||
|
t.Error("Expected non-empty description")
|
||||||
|
}
|
||||||
|
t.Logf("Chinese description: %s", desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chat, ok := zhCN.Messages["chat"].(map[string]interface{}); ok {
|
||||||
|
if title, ok := chat["title"].(string); ok {
|
||||||
|
if title != "新对话" {
|
||||||
|
t.Errorf("Expected '新对话', got %s", title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Error("Expected 'zh-cn' locale")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Loaded %d locales successfully", len(locales))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Flatten loaded locales", func(t *testing.T) {
|
||||||
|
locales, err := GetLocales(assistantPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flattened := locales.Flatten()
|
||||||
|
|
||||||
|
// Should have short codes
|
||||||
|
if _, ok := flattened["en"]; !ok {
|
||||||
|
t.Error("Expected 'en' short code after flatten")
|
||||||
|
}
|
||||||
|
if _, ok := flattened["zh"]; !ok {
|
||||||
|
t.Error("Expected 'zh' short code after flatten")
|
||||||
|
}
|
||||||
|
if _, ok := flattened["us"]; !ok {
|
||||||
|
t.Error("Expected 'us' region code after flatten")
|
||||||
|
}
|
||||||
|
if _, ok := flattened["cn"]; !ok {
|
||||||
|
t.Error("Expected 'cn' region code after flatten")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify flattened messages structure
|
||||||
|
if en, ok := flattened["en"]; ok {
|
||||||
|
if _, ok := en.Messages["chat.title"]; !ok {
|
||||||
|
t.Error("Expected flattened 'chat.title' key")
|
||||||
|
}
|
||||||
|
if _, ok := en.Messages["chat.description"]; !ok {
|
||||||
|
t.Error("Expected flattened 'chat.description' key")
|
||||||
|
}
|
||||||
|
if _, ok := en.Messages["chat.prompts.0"]; !ok {
|
||||||
|
t.Error("Expected flattened 'chat.prompts.0' key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Flattened to %d locale codes", len(flattened))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEdgeCases tests various edge cases
|
||||||
|
func TestEdgeCases(t *testing.T) {
|
||||||
|
t.Run("Empty Messages map", func(t *testing.T) {
|
||||||
|
i18n := I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{},
|
||||||
|
}
|
||||||
|
result := i18n.Parse("{{key}}")
|
||||||
|
if result != "{{key}}" {
|
||||||
|
t.Errorf("Expected '{{key}}', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Nil Messages map", func(t *testing.T) {
|
||||||
|
i18n := I18n{
|
||||||
|
Locale: "en",
|
||||||
|
Messages: nil,
|
||||||
|
}
|
||||||
|
result := i18n.Parse("{{key}}")
|
||||||
|
if result != "{{key}}" {
|
||||||
|
t.Errorf("Expected '{{key}}', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty locale string", func(t *testing.T) {
|
||||||
|
Locales["test"] = map[string]I18n{
|
||||||
|
"en": {
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{"key": "value"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer delete(Locales, "test")
|
||||||
|
|
||||||
|
result := Translate("test", "", "{{key}}")
|
||||||
|
// Should still work with empty string after trim
|
||||||
|
if result != "{{key}}" {
|
||||||
|
t.Logf("Result: %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Locale with only spaces", func(t *testing.T) {
|
||||||
|
Locales["test"] = map[string]I18n{
|
||||||
|
"": {
|
||||||
|
Locale: "",
|
||||||
|
Messages: map[string]any{"key": "value"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer delete(Locales, "test")
|
||||||
|
|
||||||
|
result := Translate("test", " ", "{{key}}")
|
||||||
|
if result != "value" {
|
||||||
|
t.Errorf("Expected 'value', got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,6 @@ package xun
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
|
|
@ -326,23 +324,8 @@ func (conv *Xun) GetAssistants(filter types.AssistantFilter, locale ...string) (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply i18n translations if locale is provided
|
// Apply i18n translations if locale is provided
|
||||||
if len(locale) > 0 && model != nil {
|
if len(locale) > 0 && locale[0] != "" && model != nil {
|
||||||
lang := strings.ToLower(locale[0])
|
conv.translate(model, model.ID, locale[0])
|
||||||
// Translate name if locales are available
|
|
||||||
if model.Locales != nil {
|
|
||||||
if localeData, ok := model.Locales[lang]; ok {
|
|
||||||
if messages, ok := localeData.Messages["name"]; ok {
|
|
||||||
if nameStr, ok := messages.(string); ok {
|
|
||||||
model.Name = nameStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if messages, ok := localeData.Messages["description"]; ok {
|
|
||||||
if descStr, ok := messages.(string); ok {
|
|
||||||
model.Description = descStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assistants = append(assistants, model)
|
assistants = append(assistants, model)
|
||||||
|
|
@ -484,6 +467,11 @@ func (conv *Xun) GetAssistant(assistantID string, locale ...string) (*types.Assi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply i18n translation if locale is provided
|
||||||
|
if len(locale) > 0 && locale[0] != "" {
|
||||||
|
conv.translate(model, assistantID, locale[0])
|
||||||
|
}
|
||||||
|
|
||||||
return model, nil
|
return model, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -584,73 +572,65 @@ func (conv *Xun) GetAssistantTags(locale ...string) ([]types.Tag, error) {
|
||||||
return tags, nil
|
return tags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHistoryWithFilter get the history with filter options
|
// translate applies i18n translation to assistant model fields
|
||||||
func (conv *Xun) GetHistoryWithFilter(sid string, cid string, filter types.ChatFilter, locale ...string) ([]map[string]interface{}, error) {
|
func (conv *Xun) translate(model *types.AssistantModel, assistantID string, locale string) {
|
||||||
userID, err := conv.getUserID(sid)
|
if model == nil {
|
||||||
if err != nil {
|
return
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
qb := conv.newQuery().
|
// Translate name
|
||||||
Select("role", "name", "content", "context", "assistant_id", "assistant_name", "assistant_avatar", "mentions", "uid", "silent", "created_at", "updated_at").
|
if translated := i18n.Translate(assistantID, locale, model.Name); translated != nil {
|
||||||
Where("sid", userID).
|
if s, ok := translated.(string); ok {
|
||||||
Where("cid", cid).
|
model.Name = s
|
||||||
OrderBy("id", "desc")
|
|
||||||
|
|
||||||
// Apply silent filter if provided, otherwise exclude silent messages by default
|
|
||||||
if filter.Silent != nil {
|
|
||||||
if *filter.Silent {
|
|
||||||
// Include all messages (both silent and non-silent)
|
|
||||||
} else {
|
|
||||||
// Only include non-silent messages
|
|
||||||
qb.Where("silent", false)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Default behavior: exclude silent messages
|
|
||||||
qb.Where("silent", false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if conv.setting.TTL > 0 {
|
// Translate description
|
||||||
qb.Where("expired_at", ">", time.Now())
|
if translated := i18n.Translate(assistantID, locale, model.Description); translated != nil {
|
||||||
}
|
if s, ok := translated.(string); ok {
|
||||||
|
model.Description = s
|
||||||
limit := 20
|
|
||||||
if conv.setting.MaxSize > 0 {
|
|
||||||
limit = conv.setting.MaxSize
|
|
||||||
}
|
|
||||||
if filter.PageSize > 0 {
|
|
||||||
limit = filter.PageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply pagination if provided
|
|
||||||
if filter.Page > 0 {
|
|
||||||
offset := (filter.Page - 1) * limit
|
|
||||||
qb.Offset(offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := qb.Limit(limit).Get()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res := []map[string]interface{}{}
|
|
||||||
for _, row := range rows {
|
|
||||||
message := map[string]interface{}{
|
|
||||||
"role": row.Get("role"),
|
|
||||||
"name": row.Get("name"),
|
|
||||||
"content": row.Get("content"),
|
|
||||||
"context": row.Get("context"),
|
|
||||||
"assistant_id": row.Get("assistant_id"),
|
|
||||||
"assistant_name": row.Get("assistant_name"),
|
|
||||||
"assistant_avatar": row.Get("assistant_avatar"),
|
|
||||||
"mentions": row.Get("mentions"),
|
|
||||||
"uid": row.Get("uid"),
|
|
||||||
"silent": row.Get("silent"),
|
|
||||||
"created_at": row.Get("created_at"),
|
|
||||||
"updated_at": row.Get("updated_at"),
|
|
||||||
}
|
}
|
||||||
res = append([]map[string]interface{}{message}, res...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res, nil
|
// Translate prompts
|
||||||
|
if model.Prompts != nil {
|
||||||
|
for i := range model.Prompts {
|
||||||
|
if translated := i18n.Translate(assistantID, locale, model.Prompts[i].Name); translated != nil {
|
||||||
|
if s, ok := translated.(string); ok {
|
||||||
|
model.Prompts[i].Name = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if translated := i18n.Translate(assistantID, locale, model.Prompts[i].Content); translated != nil {
|
||||||
|
if s, ok := translated.(string); ok {
|
||||||
|
model.Prompts[i].Content = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate placeholder
|
||||||
|
if model.Placeholder != nil {
|
||||||
|
if translated := i18n.Translate(assistantID, locale, model.Placeholder.Title); translated != nil {
|
||||||
|
if s, ok := translated.(string); ok {
|
||||||
|
model.Placeholder.Title = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if translated := i18n.Translate(assistantID, locale, model.Placeholder.Description); translated != nil {
|
||||||
|
if s, ok := translated.(string); ok {
|
||||||
|
model.Placeholder.Description = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if translated := i18n.Translate(assistantID, locale, model.Placeholder.Prompts); translated != nil {
|
||||||
|
if prompts, ok := translated.([]string); ok {
|
||||||
|
model.Placeholder.Prompts = prompts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate tags
|
||||||
|
if translated := i18n.Translate(assistantID, locale, model.Tags); translated != nil {
|
||||||
|
if tags, ok := translated.([]string); ok {
|
||||||
|
model.Tags = tags
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
"github.com/yaoapp/yao/agent/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetHistory get the history
|
// GetHistory get the history
|
||||||
|
|
@ -248,3 +249,74 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHistoryWithFilter get the history with filter options
|
||||||
|
func (conv *Xun) GetHistoryWithFilter(sid string, cid string, filter types.ChatFilter, locale ...string) ([]map[string]interface{}, error) {
|
||||||
|
userID, err := conv.getUserID(sid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
qb := conv.newQuery().
|
||||||
|
Select("role", "name", "content", "context", "assistant_id", "assistant_name", "assistant_avatar", "mentions", "uid", "silent", "created_at", "updated_at").
|
||||||
|
Where("sid", userID).
|
||||||
|
Where("cid", cid).
|
||||||
|
OrderBy("id", "desc")
|
||||||
|
|
||||||
|
// Apply silent filter if provided, otherwise exclude silent messages by default
|
||||||
|
if filter.Silent != nil {
|
||||||
|
if *filter.Silent {
|
||||||
|
// Include all messages (both silent and non-silent)
|
||||||
|
} else {
|
||||||
|
// Only include non-silent messages
|
||||||
|
qb.Where("silent", false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default behavior: exclude silent messages
|
||||||
|
qb.Where("silent", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if conv.setting.TTL > 0 {
|
||||||
|
qb.Where("expired_at", ">", time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 20
|
||||||
|
if conv.setting.MaxSize > 0 {
|
||||||
|
limit = conv.setting.MaxSize
|
||||||
|
}
|
||||||
|
if filter.PageSize > 0 {
|
||||||
|
limit = filter.PageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination if provided
|
||||||
|
if filter.Page > 0 {
|
||||||
|
offset := (filter.Page - 1) * limit
|
||||||
|
qb.Offset(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := qb.Limit(limit).Get()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := []map[string]interface{}{}
|
||||||
|
for _, row := range rows {
|
||||||
|
message := map[string]interface{}{
|
||||||
|
"role": row.Get("role"),
|
||||||
|
"name": row.Get("name"),
|
||||||
|
"content": row.Get("content"),
|
||||||
|
"context": row.Get("context"),
|
||||||
|
"assistant_id": row.Get("assistant_id"),
|
||||||
|
"assistant_name": row.Get("assistant_name"),
|
||||||
|
"assistant_avatar": row.Get("assistant_avatar"),
|
||||||
|
"mentions": row.Get("mentions"),
|
||||||
|
"uid": row.Get("uid"),
|
||||||
|
"silent": row.Get("silent"),
|
||||||
|
"created_at": row.Get("created_at"),
|
||||||
|
"updated_at": row.Get("updated_at"),
|
||||||
|
}
|
||||||
|
res = append([]map[string]interface{}{message}, res...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
"github.com/yaoapp/yao/agent/store/types"
|
"github.com/yaoapp/yao/agent/store/types"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
|
|
@ -1098,6 +1099,245 @@ func TestEmptyStringAsNull(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetAssistantWithLocale tests retrieving assistant with locale translation
|
||||||
|
func TestGetAssistantWithLocale(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
store, err := NewXun(types.Setting{
|
||||||
|
Connector: "default",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
t.Run("GetAssistantWithLocaleTranslation", func(t *testing.T) {
|
||||||
|
// Create assistant with i18n locales
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "{{name}}",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Description: "{{description}}",
|
||||||
|
Tags: []string{"test"},
|
||||||
|
Share: "private",
|
||||||
|
Placeholder: &types.Placeholder{
|
||||||
|
Title: "{{chat.title}}",
|
||||||
|
Description: "{{chat.description}}",
|
||||||
|
Prompts: []string{"{{chat.prompts.0}}", "{{chat.prompts.1}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.Locales[id] = map[string]i18n.I18n{
|
||||||
|
"en": {
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"name": "Test Assistant",
|
||||||
|
"description": "This is a test assistant",
|
||||||
|
"chat.title": "Chat with me",
|
||||||
|
"chat.description": "Start a conversation",
|
||||||
|
"chat.prompts.0": "How can I help you?",
|
||||||
|
"chat.prompts.1": "What would you like to know?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"zh-cn": {
|
||||||
|
Locale: "zh-cn",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"name": "测试助手",
|
||||||
|
"description": "这是一个测试助手",
|
||||||
|
"chat.title": "与我聊天",
|
||||||
|
"chat.description": "开始对话",
|
||||||
|
"chat.prompts.0": "我能帮你什么?",
|
||||||
|
"chat.prompts.1": "你想了解什么?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test English locale
|
||||||
|
retrievedEN, err := store.GetAssistant(id, "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant with EN locale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedEN.Name != "Test Assistant" {
|
||||||
|
t.Errorf("Expected name 'Test Assistant', got '%s'", retrievedEN.Name)
|
||||||
|
}
|
||||||
|
if retrievedEN.Description != "This is a test assistant" {
|
||||||
|
t.Errorf("Expected description 'This is a test assistant', got '%s'", retrievedEN.Description)
|
||||||
|
}
|
||||||
|
if retrievedEN.Placeholder == nil {
|
||||||
|
t.Fatal("Expected placeholder to be set")
|
||||||
|
}
|
||||||
|
if retrievedEN.Placeholder.Title != "Chat with me" {
|
||||||
|
t.Errorf("Expected placeholder title 'Chat with me', got '%s'", retrievedEN.Placeholder.Title)
|
||||||
|
}
|
||||||
|
if retrievedEN.Placeholder.Description != "Start a conversation" {
|
||||||
|
t.Errorf("Expected placeholder description 'Start a conversation', got '%s'", retrievedEN.Placeholder.Description)
|
||||||
|
}
|
||||||
|
if len(retrievedEN.Placeholder.Prompts) != 2 {
|
||||||
|
t.Errorf("Expected 2 placeholder prompts, got %d", len(retrievedEN.Placeholder.Prompts))
|
||||||
|
}
|
||||||
|
if retrievedEN.Placeholder.Prompts[0] != "How can I help you?" {
|
||||||
|
t.Errorf("Expected first prompt 'How can I help you?', got '%s'", retrievedEN.Placeholder.Prompts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Chinese locale
|
||||||
|
retrievedZH, err := store.GetAssistant(id, "zh-cn")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant with ZH locale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedZH.Name != "测试助手" {
|
||||||
|
t.Errorf("Expected name '测试助手', got '%s'", retrievedZH.Name)
|
||||||
|
}
|
||||||
|
if retrievedZH.Description != "这是一个测试助手" {
|
||||||
|
t.Errorf("Expected description '这是一个测试助手', got '%s'", retrievedZH.Description)
|
||||||
|
}
|
||||||
|
if retrievedZH.Placeholder == nil {
|
||||||
|
t.Fatal("Expected placeholder to be set")
|
||||||
|
}
|
||||||
|
if retrievedZH.Placeholder.Title != "与我聊天" {
|
||||||
|
t.Errorf("Expected placeholder title '与我聊天', got '%s'", retrievedZH.Placeholder.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test without locale (should return original {{...}} values)
|
||||||
|
retrievedNoLocale, err := store.GetAssistant(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant without locale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedNoLocale.Name != "{{name}}" {
|
||||||
|
t.Errorf("Expected original name '{{name}}', got '%s'", retrievedNoLocale.Name)
|
||||||
|
}
|
||||||
|
if retrievedNoLocale.Description != "{{description}}" {
|
||||||
|
t.Errorf("Expected original description '{{description}}', got '%s'", retrievedNoLocale.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
delete(i18n.Locales, id)
|
||||||
|
t.Logf("Successfully tested locale translation for assistant %s", id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetAssistantsWithLocale tests retrieving multiple assistants with locale translation
|
||||||
|
func TestGetAssistantsWithLocale(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
store, err := NewXun(types.Setting{
|
||||||
|
Connector: "default",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
t.Run("GetAssistantsWithLocaleTranslation", func(t *testing.T) {
|
||||||
|
// Create assistant with i18n locales
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "{{name}}",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Description: "{{description}}",
|
||||||
|
Tags: []string{"locale-test"},
|
||||||
|
Share: "private",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup i18n for testing
|
||||||
|
i18n.Locales[id] = map[string]i18n.I18n{
|
||||||
|
"en": {
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"name": "List Test Assistant",
|
||||||
|
"description": "This appears in the list",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"zh-cn": {
|
||||||
|
Locale: "zh-cn",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"name": "列表测试助手",
|
||||||
|
"description": "这出现在列表中",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetAssistants with English locale
|
||||||
|
responseEN, err := store.GetAssistants(types.AssistantFilter{
|
||||||
|
Tags: []string{"locale-test"},
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
}, "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistants with EN locale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(responseEN.Data) < 1 {
|
||||||
|
t.Fatal("Expected at least 1 assistant in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, asst := range responseEN.Data {
|
||||||
|
if asst.ID == id {
|
||||||
|
found = true
|
||||||
|
if asst.Name != "List Test Assistant" {
|
||||||
|
t.Errorf("Expected name 'List Test Assistant', got '%s'", asst.Name)
|
||||||
|
}
|
||||||
|
if asst.Description != "This appears in the list" {
|
||||||
|
t.Errorf("Expected description 'This appears in the list', got '%s'", asst.Description)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Error("Expected to find the test assistant in the list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetAssistants with Chinese locale
|
||||||
|
responseZH, err := store.GetAssistants(types.AssistantFilter{
|
||||||
|
Tags: []string{"locale-test"},
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
}, "zh-cn")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistants with ZH locale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false
|
||||||
|
for _, asst := range responseZH.Data {
|
||||||
|
if asst.ID == id {
|
||||||
|
found = true
|
||||||
|
if asst.Name != "列表测试助手" {
|
||||||
|
t.Errorf("Expected name '列表测试助手', got '%s'", asst.Name)
|
||||||
|
}
|
||||||
|
if asst.Description != "这出现在列表中" {
|
||||||
|
t.Errorf("Expected description '这出现在列表中', got '%s'", asst.Description)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Error("Expected to find the test assistant in the list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
delete(i18n.Locales, id)
|
||||||
|
t.Logf("Successfully tested locale translation for assistants list")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestAssistantCompleteWorkflow tests a complete workflow
|
// TestAssistantCompleteWorkflow tests a complete workflow
|
||||||
func TestAssistantCompleteWorkflow(t *testing.T) {
|
func TestAssistantCompleteWorkflow(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue