Add dependencies management to Assistant model and related functionalities

- Introduce a new field for dependencies in the Assistant model to manage external MCP client dependencies with version constraints.
- Implement deep copy functionality for dependencies in the Clone method to ensure integrity during assistant cloning.
- Enhance the Update method to handle dependencies input from data maps, supporting both string and interface types.
- Update the Map method to include dependencies in the serialized output.
- Add comprehensive tests to validate loading, cloning, and mapping of dependencies, ensuring correct behavior across various scenarios.
This commit is contained in:
Max 2026-03-02 14:42:59 +08:00
parent 87f80b0ccc
commit e8720f0421
11 changed files with 518 additions and 381 deletions

View file

@ -146,6 +146,7 @@ func (ast *Assistant) Map() map[string]interface{} {
"locales": ast.Locales,
"uses": ast.Uses,
"search": ast.Search,
"dependencies": ast.Dependencies,
"created_at": store.ToMySQLTime(ast.CreatedAt),
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
}
@ -455,6 +456,14 @@ func (ast *Assistant) Clone() *Assistant {
}
}
// Deep copy dependencies
if ast.Dependencies != nil {
clone.Dependencies = make(map[string]string, len(ast.Dependencies))
for k, v := range ast.Dependencies {
clone.Dependencies[k] = v
}
}
return clone
}
@ -639,6 +648,26 @@ func (ast *Assistant) Update(data map[string]interface{}) error {
ast.Search = search
}
// Dependencies
if v, has := data["dependencies"]; has {
if v == nil {
ast.Dependencies = nil
} else {
switch d := v.(type) {
case map[string]string:
ast.Dependencies = d
case map[string]interface{}:
deps := make(map[string]string, len(d))
for k, val := range d {
if s, ok := val.(string); ok {
deps[k] = s
}
}
ast.Dependencies = deps
}
}
}
return ast.Validate()
}

View file

@ -729,6 +729,30 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
assistant.Sandbox = sb
}
// dependencies (name -> version constraint, like npm dependencies)
if deps, has := data["dependencies"]; has {
switch v := deps.(type) {
case map[string]string:
assistant.Dependencies = v
case map[string]interface{}:
d := make(map[string]string, len(v))
for k, val := range v {
d[k] = cast.ToString(val)
}
assistant.Dependencies = d
default:
raw, err := jsoniter.Marshal(v)
if err != nil {
return nil, err
}
var d map[string]string
if err := jsoniter.Unmarshal(raw, &d); err != nil {
return nil, err
}
assistant.Dependencies = d
}
}
// uses (wrapper configurations for vision, audio, etc.)
// Merge hierarchy: global uses < assistant uses
if uses, has := data["uses"]; has {

View file

@ -494,6 +494,10 @@ func TestLoadStoreWithAllFields(t *testing.T) {
Description: "This is a test placeholder",
Prompts: []string{"Test prompt 1", "Test prompt 2"},
},
Dependencies: map[string]string{
"echo": "^1.0.0",
"customer": ">=2.0.0",
},
Source: `
// @ts-nocheck
function Create(ctx: any, messages: any[]): any {
@ -571,6 +575,12 @@ function Create(ctx: any, messages: any[]): any {
assert.NotNil(t, loaded.HookScript)
assert.NotEmpty(t, loaded.Source)
// Dependencies
require.NotNil(t, loaded.Dependencies)
assert.Len(t, loaded.Dependencies, 2)
assert.Equal(t, "^1.0.0", loaded.Dependencies["echo"])
assert.Equal(t, ">=2.0.0", loaded.Dependencies["customer"])
// Execute the Create hook to verify it works
ctx := newStoreTestContext("test-chat-all-fields", assistantID)
messages := []context.Message{{Role: "user", Content: "Test message"}}

View file

@ -193,6 +193,18 @@ func TestLoadPath(t *testing.T) {
assert.NotNil(t, zhLocale)
})
t.Run("LoadDependencies", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// Dependencies
assert.NotNil(t, assistant.Dependencies)
assert.Len(t, assistant.Dependencies, 2)
assert.Equal(t, "^1.0.0", assistant.Dependencies["echo"])
assert.Equal(t, ">=2.0.0", assistant.Dependencies["customer"])
})
t.Run("LoadNonExistentAssistant", func(t *testing.T) {
_, err := assistant.LoadPath("/assistants/non-existent")
assert.Error(t, err)
@ -333,6 +345,13 @@ func TestClone(t *testing.T) {
assert.False(t, exists, "Clone should not have modified key")
delete(original.Options, "test_key") // cleanup
}
if original.Dependencies != nil {
original.Dependencies["test_dep"] = "^9.9.9"
_, exists := clone.Dependencies["test_dep"]
assert.False(t, exists, "Clone dependencies should not have modified key")
delete(original.Dependencies, "test_dep") // cleanup
}
})
t.Run("CloneNil", func(t *testing.T) {
@ -457,6 +476,7 @@ func TestMap(t *testing.T) {
assert.Equal(t, assistant.ConnectorOptions, m["connector_options"])
assert.Equal(t, assistant.PromptPresets, m["prompt_presets"])
assert.Equal(t, assistant.Source, m["source"])
assert.Equal(t, assistant.Dependencies, m["dependencies"])
}
// TestLoadSystemAgents tests loading system agents from bindata

View file

@ -454,6 +454,17 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) {
}
}
// Dependencies
if deps, ok := data["dependencies"]; ok && deps != nil {
raw, err := jsoniter.Marshal(deps)
if err == nil {
var d map[string]string
if err := jsoniter.Unmarshal(raw, &d); err == nil {
model.Dependencies = d
}
}
}
// Permission fields
if createdBy, ok := data["__yao_created_by"].(string); ok {
model.YaoCreatedBy = createdBy

View file

@ -583,6 +583,10 @@ func TestToAssistantModel(t *testing.T) {
"name": "English Name",
},
},
"dependencies": map[string]interface{}{
"echo": "^1.0.0",
"customer": ">=2.0.0",
},
}
result, err := ToAssistantModel(data)
@ -711,6 +715,19 @@ func TestToAssistantModel(t *testing.T) {
if result.Locales == nil {
t.Error("Expected Locales to be set")
}
if result.Dependencies == nil {
t.Error("Expected Dependencies to be set")
} else {
if len(result.Dependencies) != 2 {
t.Errorf("Expected 2 dependencies, got %d", len(result.Dependencies))
}
if result.Dependencies["echo"] != "^1.0.0" {
t.Errorf("Expected echo dependency '^1.0.0', got '%s'", result.Dependencies["echo"])
}
if result.Dependencies["customer"] != ">=2.0.0" {
t.Errorf("Expected customer dependency '>=2.0.0', got '%s'", result.Dependencies["customer"])
}
}
})
t.Run("MapWithFloatNumbers", func(t *testing.T) {
@ -750,6 +767,7 @@ func TestToAssistantModel(t *testing.T) {
"workflow": nil,
"placeholder": nil,
"locales": nil,
"dependencies": nil,
}
result, err := ToAssistantModel(data)
@ -764,6 +782,9 @@ func TestToAssistantModel(t *testing.T) {
if result.Tags != nil {
t.Error("Expected Tags to be nil")
}
if result.Dependencies != nil {
t.Error("Expected Dependencies to be nil")
}
if result.Modes != nil {
t.Error("Expected Modes to be nil")
}

View file

@ -36,6 +36,7 @@ var AssistantAllowedFields = map[string]bool{
"locales": true,
"uses": true,
"search": true,
"dependencies": true,
"automated": true,
"mentionable": true,
"created_at": true,
@ -66,10 +67,11 @@ var AssistantDefaultFields = []string{
"share",
"automated",
"mentionable",
"sandbox", // Sandbox configuration presence (lightweight)
"kb", // Knowledge base configuration (lightweight)
"db", // Database configuration (lightweight)
"mcp", // MCP servers configuration (lightweight)
"sandbox", // Sandbox configuration presence (lightweight)
"kb", // Knowledge base configuration (lightweight)
"db", // Database configuration (lightweight)
"mcp", // MCP servers configuration (lightweight)
"dependencies", // Dependencies on other MCP Clients (lightweight)
"created_at",
"updated_at",
"__yao_created_by", // Permission: creator user ID
@ -112,6 +114,7 @@ var AssistantFullFields = []string{
"locales",
"uses",
"search",
"dependencies",
"automated",
"mentionable",
"created_at",

View file

@ -454,6 +454,7 @@ type AssistantModel struct {
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales
Uses *context.Uses `json:"uses,omitempty"` // Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings
Search *searchTypes.Config `json:"search,omitempty"` // Search configuration (web, kb, db, citation, weights, etc.)
Dependencies map[string]string `json:"dependencies,omitempty"` // Dependencies on other MCP Clients (name -> version constraint)
CreatedAt int64 `json:"created_at"` // Creation timestamp
UpdatedAt int64 `json:"updated_at"` // Last update timestamp

View file

@ -191,6 +191,7 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
"locales": assistant.Locales,
"uses": assistant.Uses,
"search": assistant.Search,
"dependencies": assistant.Dependencies,
}
for field, value := range jsonFields {
@ -249,7 +250,7 @@ func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interfa
data := make(map[string]interface{})
// List of fields that need JSON marshaling
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "sandbox", "placeholder", "locales", "uses", "search"}
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "sandbox", "placeholder", "locales", "uses", "search", "dependencies"}
jsonFieldSet := make(map[string]bool)
for _, field := range jsonFields {
jsonFieldSet[field] = true
@ -470,7 +471,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
// Convert rows to types.AssistantModel slice
assistants := make([]*types.AssistantModel, 0, len(rows))
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "mcp", "placeholder", "locales", "uses", "search"}
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "mcp", "placeholder", "locales", "uses", "search", "dependencies"}
for _, row := range rows {
data := row.ToMap()
@ -543,7 +544,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
}
// Parse JSON fields
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "db", "mcp", "placeholder", "locales", "uses", "search"}
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "db", "mcp", "placeholder", "locales", "uses", "search", "dependencies"}
store.parseJSONFields(data, jsonFields)
// Convert map to types.AssistantModel
@ -706,6 +707,16 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
}
}
if deps, has := data["dependencies"]; has && deps != nil {
raw, err := jsoniter.Marshal(deps)
if err == nil {
var d map[string]string
if err := jsoniter.Unmarshal(raw, &d); err == nil {
model.Dependencies = d
}
}
}
// Apply i18n translation if locale is provided
if len(locale) > 0 && locale[0] != "" {
store.translate(model, assistantID, locale[0])

File diff suppressed because one or more lines are too long

View file

@ -258,6 +258,13 @@
"comment": "Search configuration (web, kb, db, citation, weights, etc.)",
"nullable": true
},
{
"name": "dependencies",
"type": "json",
"label": "Dependencies",
"comment": "Dependencies on other MCP Clients (name -> version constraint)",
"nullable": true
},
{
"name": "automated",
"type": "boolean",