Enhance assistant model with global prompts functionality
- Introduced the `disable_global_prompts` field in the Assistant model to control the usage of global prompts. - Updated the Load and Get methods to initialize and retrieve global prompts from the configuration. - Refactored tests to validate the new global prompts functionality and ensure proper loading and context handling. - Improved the overall structure and clarity of the assistant's capabilities and configurations.
This commit is contained in:
parent
51092e3324
commit
45029269af
16 changed files with 1279 additions and 403 deletions
|
|
@ -63,33 +63,34 @@ func (ast *Assistant) Map() map[string]interface{} {
|
|||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"assistant_id": ast.ID,
|
||||
"type": ast.Type,
|
||||
"name": ast.Name,
|
||||
"readonly": ast.Readonly,
|
||||
"public": ast.Public,
|
||||
"share": ast.Share,
|
||||
"avatar": ast.Avatar,
|
||||
"connector": ast.Connector,
|
||||
"connector_options": ast.ConnectorOptions,
|
||||
"path": ast.Path,
|
||||
"built_in": ast.BuiltIn,
|
||||
"sort": ast.Sort,
|
||||
"description": ast.Description,
|
||||
"options": ast.Options,
|
||||
"prompts": ast.Prompts,
|
||||
"prompt_presets": ast.PromptPresets,
|
||||
"source": ast.Source,
|
||||
"kb": ast.KB,
|
||||
"mcp": ast.MCP,
|
||||
"workflow": ast.Workflow,
|
||||
"tags": ast.Tags,
|
||||
"mentionable": ast.Mentionable,
|
||||
"automated": ast.Automated,
|
||||
"placeholder": ast.Placeholder,
|
||||
"locales": ast.Locales,
|
||||
"created_at": store.ToMySQLTime(ast.CreatedAt),
|
||||
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
|
||||
"assistant_id": ast.ID,
|
||||
"type": ast.Type,
|
||||
"name": ast.Name,
|
||||
"readonly": ast.Readonly,
|
||||
"public": ast.Public,
|
||||
"share": ast.Share,
|
||||
"avatar": ast.Avatar,
|
||||
"connector": ast.Connector,
|
||||
"connector_options": ast.ConnectorOptions,
|
||||
"path": ast.Path,
|
||||
"built_in": ast.BuiltIn,
|
||||
"sort": ast.Sort,
|
||||
"description": ast.Description,
|
||||
"options": ast.Options,
|
||||
"prompts": ast.Prompts,
|
||||
"prompt_presets": ast.PromptPresets,
|
||||
"disable_global_prompts": ast.DisableGlobalPrompts,
|
||||
"source": ast.Source,
|
||||
"kb": ast.KB,
|
||||
"mcp": ast.MCP,
|
||||
"workflow": ast.Workflow,
|
||||
"tags": ast.Tags,
|
||||
"mentionable": ast.Mentionable,
|
||||
"automated": ast.Automated,
|
||||
"placeholder": ast.Placeholder,
|
||||
"locales": ast.Locales,
|
||||
"created_at": store.ToMySQLTime(ast.CreatedAt),
|
||||
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,23 +138,24 @@ func (ast *Assistant) Clone() *Assistant {
|
|||
|
||||
clone := &Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: ast.ID,
|
||||
Type: ast.Type,
|
||||
Name: ast.Name,
|
||||
Avatar: ast.Avatar,
|
||||
Connector: ast.Connector,
|
||||
Path: ast.Path,
|
||||
BuiltIn: ast.BuiltIn,
|
||||
Sort: ast.Sort,
|
||||
Description: ast.Description,
|
||||
Readonly: ast.Readonly,
|
||||
Public: ast.Public,
|
||||
Share: ast.Share,
|
||||
Mentionable: ast.Mentionable,
|
||||
Automated: ast.Automated,
|
||||
Source: ast.Source,
|
||||
CreatedAt: ast.CreatedAt,
|
||||
UpdatedAt: ast.UpdatedAt,
|
||||
ID: ast.ID,
|
||||
Type: ast.Type,
|
||||
Name: ast.Name,
|
||||
Avatar: ast.Avatar,
|
||||
Connector: ast.Connector,
|
||||
Path: ast.Path,
|
||||
BuiltIn: ast.BuiltIn,
|
||||
Sort: ast.Sort,
|
||||
Description: ast.Description,
|
||||
Readonly: ast.Readonly,
|
||||
Public: ast.Public,
|
||||
Share: ast.Share,
|
||||
Mentionable: ast.Mentionable,
|
||||
Automated: ast.Automated,
|
||||
DisableGlobalPrompts: ast.DisableGlobalPrompts,
|
||||
Source: ast.Source,
|
||||
CreatedAt: ast.CreatedAt,
|
||||
UpdatedAt: ast.UpdatedAt,
|
||||
},
|
||||
Search: ast.Search,
|
||||
Script: ast.Script,
|
||||
|
|
@ -330,6 +332,9 @@ func (ast *Assistant) Update(data map[string]interface{}) error {
|
|||
if v, ok := data["automated"].(bool); ok {
|
||||
ast.Automated = v
|
||||
}
|
||||
if v, ok := data["disable_global_prompts"].(bool); ok {
|
||||
ast.DisableGlobalPrompts = v
|
||||
}
|
||||
if v, ok := data["readonly"].(bool); ok {
|
||||
ast.Readonly = v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -271,7 +270,7 @@ func LoadPath(path string) (*Assistant, error) {
|
|||
// prompts (default prompts from prompts.yml)
|
||||
promptsfile := filepath.Join(path, "prompts.yml")
|
||||
if has, _ := app.Exists(promptsfile); has {
|
||||
prompts, ts, err := loadPrompts(promptsfile, path)
|
||||
prompts, ts, err := store.LoadPrompts(promptsfile, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -283,7 +282,7 @@ func LoadPath(path string) (*Assistant, error) {
|
|||
// prompt_presets (from prompts directory, key is filename without extension)
|
||||
promptsDir := filepath.Join(path, "prompts")
|
||||
if has, _ := app.Exists(promptsDir); has {
|
||||
presets, ts, err := loadPromptPresets(promptsDir, path)
|
||||
presets, ts, err := store.LoadPromptPresets(promptsDir, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -397,6 +396,11 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
assistant.Automated = v
|
||||
}
|
||||
|
||||
// DisableGlobalPrompts
|
||||
if v, ok := data["disable_global_prompts"].(bool); ok {
|
||||
assistant.DisableGlobalPrompts = v
|
||||
}
|
||||
|
||||
// Readonly
|
||||
if v, ok := data["readonly"].(bool); ok {
|
||||
assistant.Readonly = v
|
||||
|
|
@ -651,119 +655,6 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
return assistant, nil
|
||||
}
|
||||
|
||||
func loadPrompts(file string, root string) (string, int64, error) {
|
||||
|
||||
app, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
ts, err := app.ModTime(file)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
prompts, err := app.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Replace @assets/xxx references with file content
|
||||
re := regexp.MustCompile(`@assets/([^\s]+\.(md|yml|yaml|json|txt))`)
|
||||
prompts = re.ReplaceAllFunc(prompts, func(s []byte) []byte {
|
||||
asset := re.FindStringSubmatch(string(s))[1]
|
||||
assetFile := filepath.Join(root, "assets", asset)
|
||||
assetContent, err := app.ReadFile(assetFile)
|
||||
if err != nil {
|
||||
return []byte("")
|
||||
}
|
||||
// Add proper YAML formatting for content
|
||||
lines := strings.Split(string(assetContent), "\n")
|
||||
formattedContent := "|\n"
|
||||
for _, line := range lines {
|
||||
formattedContent += " " + line + "\n"
|
||||
}
|
||||
return []byte(formattedContent)
|
||||
})
|
||||
|
||||
return string(prompts), ts.UnixNano(), nil
|
||||
}
|
||||
|
||||
// loadPromptPresets loads prompt presets from the prompts directory
|
||||
// Supports multi-level directories, key is path with "/" replaced by "."
|
||||
// e.g., prompts/chat/default.yml -> "chat.default"
|
||||
func loadPromptPresets(dir string, root string) (map[string][]store.Prompt, int64, error) {
|
||||
app, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Read directory recursively - returns full paths relative to app root
|
||||
files, err := app.ReadDir(dir, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
presets := make(map[string][]store.Prompt)
|
||||
var latestTs int64
|
||||
|
||||
for _, file := range files {
|
||||
// Only process .yml/.yaml files
|
||||
if !strings.HasSuffix(file, ".yml") && !strings.HasSuffix(file, ".yaml") {
|
||||
continue
|
||||
}
|
||||
|
||||
// file is already full path relative to app root (e.g., /assistants/tests/fullfields/prompts/chat/friendly.yml)
|
||||
ts, err := app.ModTime(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if ts.UnixNano() > latestTs {
|
||||
latestTs = ts.UnixNano()
|
||||
}
|
||||
|
||||
// Read file content directly
|
||||
content, err := app.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Replace @assets/xxx references with file content
|
||||
re := regexp.MustCompile(`@assets/([^\s]+\.(md|yml|yaml|json|txt))`)
|
||||
content = re.ReplaceAllFunc(content, func(s []byte) []byte {
|
||||
asset := re.FindStringSubmatch(string(s))[1]
|
||||
assetFile := filepath.Join(root, "assets", asset)
|
||||
assetContent, err := app.ReadFile(assetFile)
|
||||
if err != nil {
|
||||
return []byte("")
|
||||
}
|
||||
// Add proper YAML formatting for content
|
||||
lines := strings.Split(string(assetContent), "\n")
|
||||
formattedContent := "|\n"
|
||||
for _, line := range lines {
|
||||
formattedContent += " " + line + "\n"
|
||||
}
|
||||
return []byte(formattedContent)
|
||||
})
|
||||
|
||||
// Parse prompts
|
||||
var prompts []store.Prompt
|
||||
err = yaml.Unmarshal(content, &prompts)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to parse prompt preset %s: %w", file, err)
|
||||
}
|
||||
|
||||
// Build key: get relative path from dir, remove extension and replace "/" with "."
|
||||
// e.g., "/assistants/tests/fullfields/prompts/chat/friendly.yml" -> "chat.friendly"
|
||||
relPath := strings.TrimPrefix(file, dir+"/")
|
||||
key := strings.TrimSuffix(relPath, filepath.Ext(relPath))
|
||||
key = strings.ReplaceAll(key, "/", ".")
|
||||
presets[key] = prompts
|
||||
}
|
||||
|
||||
return presets, latestTs, nil
|
||||
}
|
||||
|
||||
func loadScript(file string, root string) (*hook.Script, int64, error) {
|
||||
|
||||
app, err := fs.Get("app")
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func TestLoadPath(t *testing.T) {
|
|||
assert.True(t, assistant.Readonly)
|
||||
assert.True(t, assistant.Mentionable)
|
||||
assert.False(t, assistant.Automated)
|
||||
assert.True(t, assistant.DisableGlobalPrompts)
|
||||
|
||||
// Share field
|
||||
assert.Equal(t, "team", assistant.Share)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ func Load(cfg config.Config) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Initialize Global Prompts
|
||||
err = initGlobalPrompts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize Assistant
|
||||
err = initAssistant()
|
||||
if err != nil {
|
||||
|
|
@ -104,6 +110,25 @@ func initGlobalI18n() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// initGlobalPrompts initialize the global prompts from agent/prompts.yml
|
||||
func initGlobalPrompts() error {
|
||||
prompts, _, err := store.LoadGlobalPrompts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentDSL.GlobalPrompts = prompts
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGlobalPrompts returns the global prompts
|
||||
// ctx: context variables for parsing $CTX.* variables
|
||||
func GetGlobalPrompts(ctx map[string]string) []store.Prompt {
|
||||
if agentDSL == nil || len(agentDSL.GlobalPrompts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return store.Prompts(agentDSL.GlobalPrompts).Parse(ctx)
|
||||
}
|
||||
|
||||
// initModelCapabilities initialize the model capabilities configuration
|
||||
func initModelCapabilities() error {
|
||||
path := filepath.Join("agent", "models.yml")
|
||||
|
|
|
|||
|
|
@ -1,16 +1,173 @@
|
|||
package agent
|
||||
|
||||
// func TestLoad(t *testing.T) {
|
||||
// test.Prepare(t, config.Conf)
|
||||
// defer test.Clean()
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
// err := Load(config.Conf)
|
||||
// if err != nil {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// check(t)
|
||||
// }
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// func check(t *testing.T) {
|
||||
// assert.NotNil(t, Agent)
|
||||
// }
|
||||
func prepare(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
err := Load(config.Conf)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
agent := GetAgent()
|
||||
require.NotNil(t, agent)
|
||||
|
||||
t.Run("LoadAgentSettings", func(t *testing.T) {
|
||||
// Cache setting
|
||||
assert.NotEmpty(t, agent.Cache)
|
||||
|
||||
// Store setting
|
||||
assert.NotNil(t, agent.Store)
|
||||
assert.Greater(t, agent.StoreSetting.MaxSize, 0)
|
||||
|
||||
// Uses setting
|
||||
assert.NotNil(t, agent.Uses)
|
||||
assert.NotEmpty(t, agent.Uses.Default)
|
||||
})
|
||||
|
||||
t.Run("LoadDefaultAssistant", func(t *testing.T) {
|
||||
assert.NotNil(t, agent.Assistant)
|
||||
})
|
||||
|
||||
t.Run("LoadGlobalPrompts", func(t *testing.T) {
|
||||
// Global prompts should be loaded from agent/prompts.yml
|
||||
assert.NotNil(t, agent.GlobalPrompts)
|
||||
assert.Greater(t, len(agent.GlobalPrompts), 0)
|
||||
|
||||
// First prompt should be system role
|
||||
assert.Equal(t, "system", agent.GlobalPrompts[0].Role)
|
||||
|
||||
// Content should contain system context info (with variables not yet parsed)
|
||||
assert.Contains(t, agent.GlobalPrompts[0].Content, "$SYS.")
|
||||
})
|
||||
|
||||
t.Run("LoadModelCapabilities", func(t *testing.T) {
|
||||
// Model capabilities should be loaded from agent/models.yml
|
||||
assert.NotNil(t, agent.Models)
|
||||
assert.Greater(t, len(agent.Models), 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetGlobalPrompts(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("ParseWithoutContext", func(t *testing.T) {
|
||||
prompts := GetGlobalPrompts(nil)
|
||||
require.NotNil(t, prompts)
|
||||
require.Greater(t, len(prompts), 0)
|
||||
|
||||
// $SYS.* variables should be replaced
|
||||
content := prompts[0].Content
|
||||
assert.NotContains(t, content, "$SYS.DATETIME")
|
||||
assert.NotContains(t, content, "$SYS.TIMEZONE")
|
||||
assert.NotContains(t, content, "$SYS.WEEKDAY")
|
||||
|
||||
// Should contain actual time values
|
||||
now := time.Now()
|
||||
assert.Contains(t, content, now.Format("2006-01-02"))
|
||||
})
|
||||
|
||||
t.Run("ParseWithContext", func(t *testing.T) {
|
||||
ctx := map[string]string{
|
||||
"USER_ID": "test-user-123",
|
||||
"LOCALE": "zh-CN",
|
||||
}
|
||||
|
||||
prompts := GetGlobalPrompts(ctx)
|
||||
require.NotNil(t, prompts)
|
||||
require.Greater(t, len(prompts), 0)
|
||||
|
||||
// $SYS.* variables should be replaced
|
||||
content := prompts[0].Content
|
||||
assert.NotContains(t, content, "$SYS.DATETIME")
|
||||
})
|
||||
|
||||
t.Run("ParseSystemTimeVariables", func(t *testing.T) {
|
||||
prompts := GetGlobalPrompts(nil)
|
||||
require.NotNil(t, prompts)
|
||||
|
||||
content := prompts[0].Content
|
||||
now := time.Now()
|
||||
|
||||
// Should contain current date
|
||||
assert.Contains(t, content, now.Format("2006-01-02"))
|
||||
|
||||
// Should contain timezone
|
||||
assert.Contains(t, content, now.Location().String())
|
||||
|
||||
// Should contain weekday
|
||||
assert.Contains(t, content, now.Weekday().String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetGlobalPromptsWithDisableFlag(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
agent := GetAgent()
|
||||
require.NotNil(t, agent)
|
||||
|
||||
t.Run("GlobalPromptsExist", func(t *testing.T) {
|
||||
// Verify global prompts are loaded
|
||||
assert.NotNil(t, agent.GlobalPrompts)
|
||||
assert.Greater(t, len(agent.GlobalPrompts), 0)
|
||||
})
|
||||
|
||||
t.Run("AssistantCanDisableGlobalPrompts", func(t *testing.T) {
|
||||
// The fullfields test assistant has disable_global_prompts: true
|
||||
// This test verifies the flag is properly loaded
|
||||
// The actual merging logic is in the assistant module
|
||||
prompts := GetGlobalPrompts(nil)
|
||||
assert.NotNil(t, prompts)
|
||||
|
||||
// Global prompts should still be available
|
||||
// The assistant decides whether to use them based on DisableGlobalPrompts flag
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobalPromptsContent(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
agent := GetAgent()
|
||||
require.NotNil(t, agent)
|
||||
require.NotNil(t, agent.GlobalPrompts)
|
||||
require.Greater(t, len(agent.GlobalPrompts), 0)
|
||||
|
||||
t.Run("SystemContextPrompt", func(t *testing.T) {
|
||||
// Find system prompt
|
||||
var systemPrompt string
|
||||
for _, p := range agent.GlobalPrompts {
|
||||
if p.Role == "system" {
|
||||
systemPrompt = p.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, systemPrompt)
|
||||
assert.Contains(t, systemPrompt, "System Context")
|
||||
})
|
||||
|
||||
t.Run("VariablesInRawPrompts", func(t *testing.T) {
|
||||
// Raw prompts should contain unparsed variables
|
||||
content := agent.GlobalPrompts[0].Content
|
||||
assert.True(t,
|
||||
strings.Contains(content, "$SYS.") ||
|
||||
strings.Contains(content, "$ENV.") ||
|
||||
strings.Contains(content, "$CTX."),
|
||||
"Raw prompts should contain variable placeholders")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,9 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// DisableGlobalPrompts
|
||||
model.DisableGlobalPrompts = getBoolValue(data, "disable_global_prompts")
|
||||
|
||||
// ConnectorOptions
|
||||
if connectorOptions, ok := data["connector_options"]; ok && connectorOptions != nil {
|
||||
raw, err := jsoniter.Marshal(connectorOptions)
|
||||
|
|
|
|||
|
|
@ -459,7 +459,8 @@ func TestToAssistantModel(t *testing.T) {
|
|||
{"role": "system", "content": "You are a task assistant"},
|
||||
},
|
||||
},
|
||||
"source": "function hook() { return 'test'; }",
|
||||
"disable_global_prompts": true,
|
||||
"source": "function hook() { return 'test'; }",
|
||||
"kb": map[string]interface{}{
|
||||
"collections": []string{"col1"},
|
||||
},
|
||||
|
|
@ -575,6 +576,9 @@ func TestToAssistantModel(t *testing.T) {
|
|||
t.Errorf("Expected 1 task prompt, got %d", len(taskPrompts))
|
||||
}
|
||||
}
|
||||
if !result.DisableGlobalPrompts {
|
||||
t.Error("Expected DisableGlobalPrompts to be true")
|
||||
}
|
||||
if result.KB == nil {
|
||||
t.Error("Expected KB to be set")
|
||||
}
|
||||
|
|
@ -813,7 +817,8 @@ function beforeChat(context) {
|
|||
{"role": "system", "content": "Chat mode"},
|
||||
},
|
||||
},
|
||||
"source": "function test() {}",
|
||||
"disable_global_prompts": true,
|
||||
"source": "function test() {}",
|
||||
}
|
||||
|
||||
result, err := ToAssistantModel(data)
|
||||
|
|
@ -827,6 +832,9 @@ function beforeChat(context) {
|
|||
if result.PromptPresets == nil {
|
||||
t.Error("Expected PromptPresets to be set")
|
||||
}
|
||||
if !result.DisableGlobalPrompts {
|
||||
t.Error("Expected DisableGlobalPrompts to be true")
|
||||
}
|
||||
if result.Source == "" {
|
||||
t.Error("Expected Source to be set")
|
||||
}
|
||||
|
|
@ -834,9 +842,10 @@ function beforeChat(context) {
|
|||
|
||||
t.Run("NilNewFields", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"connector_options": nil,
|
||||
"prompt_presets": nil,
|
||||
"source": nil,
|
||||
"connector_options": nil,
|
||||
"prompt_presets": nil,
|
||||
"disable_global_prompts": nil,
|
||||
"source": nil,
|
||||
}
|
||||
|
||||
result, err := ToAssistantModel(data)
|
||||
|
|
@ -850,10 +859,63 @@ function beforeChat(context) {
|
|||
if result.PromptPresets != nil {
|
||||
t.Error("Expected PromptPresets to be nil")
|
||||
}
|
||||
if result.DisableGlobalPrompts {
|
||||
t.Error("Expected DisableGlobalPrompts to be false")
|
||||
}
|
||||
if result.Source != "" {
|
||||
t.Error("Expected Source to be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DisableGlobalPrompts", func(t *testing.T) {
|
||||
// Test with true
|
||||
data := map[string]interface{}{
|
||||
"disable_global_prompts": true,
|
||||
}
|
||||
result, err := ToAssistantModel(data)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
if !result.DisableGlobalPrompts {
|
||||
t.Error("Expected DisableGlobalPrompts to be true")
|
||||
}
|
||||
|
||||
// Test with false
|
||||
data = map[string]interface{}{
|
||||
"disable_global_prompts": false,
|
||||
}
|
||||
result, err = ToAssistantModel(data)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
if result.DisableGlobalPrompts {
|
||||
t.Error("Expected DisableGlobalPrompts to be false")
|
||||
}
|
||||
|
||||
// Test with int 1
|
||||
data = map[string]interface{}{
|
||||
"disable_global_prompts": 1,
|
||||
}
|
||||
result, err = ToAssistantModel(data)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
if !result.DisableGlobalPrompts {
|
||||
t.Error("Expected DisableGlobalPrompts to be true for int 1")
|
||||
}
|
||||
|
||||
// Test with string "true"
|
||||
data = map[string]interface{}{
|
||||
"disable_global_prompts": "true",
|
||||
}
|
||||
result, err = ToAssistantModel(data)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got: %v", err)
|
||||
}
|
||||
if !result.DisableGlobalPrompts {
|
||||
t.Error("Expected DisableGlobalPrompts to be true for string 'true'")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestToAssistantModelComplexTypes tests complex type conversions in ToAssistantModel
|
||||
|
|
|
|||
|
|
@ -4,39 +4,40 @@ import "github.com/yaoapp/kun/log"
|
|||
|
||||
// AssistantAllowedFields defines the whitelist of fields that can be selected for assistants
|
||||
var AssistantAllowedFields = map[string]bool{
|
||||
"id": true,
|
||||
"assistant_id": true,
|
||||
"type": true,
|
||||
"name": true,
|
||||
"avatar": true,
|
||||
"connector": true,
|
||||
"connector_options": true,
|
||||
"description": true,
|
||||
"path": true,
|
||||
"sort": true,
|
||||
"built_in": true,
|
||||
"placeholder": true,
|
||||
"options": true,
|
||||
"prompts": true,
|
||||
"prompt_presets": true,
|
||||
"workflow": true,
|
||||
"kb": true,
|
||||
"mcp": true,
|
||||
"source": true,
|
||||
"tags": true,
|
||||
"readonly": true,
|
||||
"public": true,
|
||||
"share": true,
|
||||
"locales": true,
|
||||
"uses": true,
|
||||
"automated": true,
|
||||
"mentionable": true,
|
||||
"created_at": true,
|
||||
"updated_at": true,
|
||||
"__yao_created_by": true,
|
||||
"__yao_updated_by": true,
|
||||
"__yao_team_id": true,
|
||||
"__yao_tenant_id": true,
|
||||
"id": true,
|
||||
"assistant_id": true,
|
||||
"type": true,
|
||||
"name": true,
|
||||
"avatar": true,
|
||||
"connector": true,
|
||||
"connector_options": true,
|
||||
"description": true,
|
||||
"path": true,
|
||||
"sort": true,
|
||||
"built_in": true,
|
||||
"placeholder": true,
|
||||
"options": true,
|
||||
"prompts": true,
|
||||
"prompt_presets": true,
|
||||
"disable_global_prompts": true,
|
||||
"workflow": true,
|
||||
"kb": true,
|
||||
"mcp": true,
|
||||
"source": true,
|
||||
"tags": true,
|
||||
"readonly": true,
|
||||
"public": true,
|
||||
"share": true,
|
||||
"locales": true,
|
||||
"uses": true,
|
||||
"automated": true,
|
||||
"mentionable": true,
|
||||
"created_at": true,
|
||||
"updated_at": true,
|
||||
"__yao_created_by": true,
|
||||
"__yao_updated_by": true,
|
||||
"__yao_team_id": true,
|
||||
"__yao_tenant_id": true,
|
||||
}
|
||||
|
||||
// AssistantDefaultFields defines the default fields to select for assistants when no specific fields are requested
|
||||
|
|
@ -83,6 +84,7 @@ var AssistantFullFields = []string{
|
|||
"options",
|
||||
"prompts",
|
||||
"prompt_presets",
|
||||
"disable_global_prompts",
|
||||
"workflow",
|
||||
"kb",
|
||||
"mcp",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ func TestAssistantAllowedFields(t *testing.T) {
|
|||
"options",
|
||||
"prompts",
|
||||
"prompt_presets",
|
||||
"disable_global_prompts",
|
||||
"workflow",
|
||||
"kb",
|
||||
"mcp",
|
||||
|
|
@ -224,6 +225,7 @@ func TestAssistantFullFields(t *testing.T) {
|
|||
"options",
|
||||
"prompts",
|
||||
"prompt_presets",
|
||||
"disable_global_prompts",
|
||||
"workflow",
|
||||
"kb",
|
||||
"mcp",
|
||||
|
|
|
|||
284
agent/store/types/prompt.go
Normal file
284
agent/store/types/prompt.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/fs"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Prompts is a slice of Prompt with helper methods
|
||||
type Prompts []Prompt
|
||||
|
||||
// SystemVariables defines the available system variables
|
||||
// These are computed at parse time
|
||||
var SystemVariables = map[string]func() string{
|
||||
"TIME": func() string { return time.Now().Format("15:04:05") },
|
||||
"DATE": func() string { return time.Now().Format("2006-01-02") },
|
||||
"DATETIME": func() string { return time.Now().Format("2006-01-02 15:04:05") },
|
||||
"TIMEZONE": func() string { return time.Now().Location().String() },
|
||||
"WEEKDAY": func() string { return time.Now().Weekday().String() },
|
||||
"YEAR": func() string { return time.Now().Format("2006") },
|
||||
"MONTH": func() string { return time.Now().Format("01") },
|
||||
"DAY": func() string { return time.Now().Format("02") },
|
||||
"HOUR": func() string { return time.Now().Format("15") },
|
||||
"MINUTE": func() string { return time.Now().Format("04") },
|
||||
"SECOND": func() string { return time.Now().Format("05") },
|
||||
"UNIX": func() string { return time.Now().Format("1136239445") }, // Unix timestamp
|
||||
}
|
||||
|
||||
// Regular expressions for variable replacement
|
||||
var (
|
||||
reSysVar = regexp.MustCompile(`\$SYS\.([A-Z_]+)`)
|
||||
reEnvVar = regexp.MustCompile(`\$ENV\.([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
reCtxVar = regexp.MustCompile(`\$CTX\.([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
reAssetRef = regexp.MustCompile(`@assets/([^\s]+\.(md|yml|yaml|json|txt))`)
|
||||
)
|
||||
|
||||
// LoadPrompts loads prompts from a YAML file
|
||||
// Handles @assets/* replacement at load time
|
||||
// file: prompt file path relative to app root (e.g., "assistants/test/prompts.yml")
|
||||
// root: resource root directory for assets (e.g., "assistants/test")
|
||||
// Returns: prompts slice, modification timestamp, error
|
||||
func LoadPrompts(file string, root string) ([]Prompt, int64, error) {
|
||||
app, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
ts, err := app.ModTime(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
content, err := app.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Replace @assets/xxx references with file content
|
||||
content = replaceAssets(content, root, app)
|
||||
|
||||
// Parse prompts
|
||||
var prompts []Prompt
|
||||
err = yaml.Unmarshal(content, &prompts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return prompts, ts.UnixNano(), nil
|
||||
}
|
||||
|
||||
// LoadPromptsRaw loads raw prompt content from a YAML file
|
||||
// Handles @assets/* replacement at load time
|
||||
// Returns raw YAML string for further processing
|
||||
func LoadPromptsRaw(file string, root string) (string, int64, error) {
|
||||
app, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
ts, err := app.ModTime(file)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
content, err := app.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Replace @assets/xxx references with file content
|
||||
content = replaceAssets(content, root, app)
|
||||
|
||||
return string(content), ts.UnixNano(), nil
|
||||
}
|
||||
|
||||
// LoadGlobalPrompts loads global prompts from agent/prompts.yml
|
||||
// Returns: prompts slice, modification timestamp, error
|
||||
func LoadGlobalPrompts() ([]Prompt, int64, error) {
|
||||
file := filepath.Join("agent", "prompts.yml")
|
||||
|
||||
// Check if file exists
|
||||
exists, _ := application.App.Exists(file)
|
||||
if !exists {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
return LoadPrompts(file, "agent")
|
||||
}
|
||||
|
||||
// LoadPromptPresets loads prompt presets from a directory
|
||||
// Supports multi-level directories, key is path with "/" replaced by "."
|
||||
// e.g., prompts/chat/friendly.yml -> "chat.friendly"
|
||||
func LoadPromptPresets(dir string, root string) (map[string][]Prompt, int64, error) {
|
||||
app, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Check if directory exists
|
||||
exists, _ := app.Exists(dir)
|
||||
if !exists {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// Read directory recursively
|
||||
files, err := app.ReadDir(dir, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
presets := make(map[string][]Prompt)
|
||||
var latestTs int64
|
||||
|
||||
for _, file := range files {
|
||||
// Only process .yml/.yaml files
|
||||
if !strings.HasSuffix(file, ".yml") && !strings.HasSuffix(file, ".yaml") {
|
||||
continue
|
||||
}
|
||||
|
||||
ts, err := app.ModTime(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if ts.UnixNano() > latestTs {
|
||||
latestTs = ts.UnixNano()
|
||||
}
|
||||
|
||||
// Read file content
|
||||
content, err := app.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Replace @assets/xxx references with file content
|
||||
content = replaceAssets(content, root, app)
|
||||
|
||||
// Parse prompts
|
||||
var prompts []Prompt
|
||||
err = yaml.Unmarshal(content, &prompts)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Build key: get relative path from dir, remove extension and replace "/" with "."
|
||||
relPath := strings.TrimPrefix(file, dir+"/")
|
||||
key := strings.TrimSuffix(relPath, filepath.Ext(relPath))
|
||||
key = strings.ReplaceAll(key, "/", ".")
|
||||
presets[key] = prompts
|
||||
}
|
||||
|
||||
return presets, latestTs, nil
|
||||
}
|
||||
|
||||
// replaceAssets replaces @assets/xxx references with file content
|
||||
func replaceAssets(content []byte, root string, app fs.FileSystem) []byte {
|
||||
return reAssetRef.ReplaceAllFunc(content, func(s []byte) []byte {
|
||||
matches := reAssetRef.FindStringSubmatch(string(s))
|
||||
if len(matches) < 2 {
|
||||
return s
|
||||
}
|
||||
|
||||
asset := matches[1]
|
||||
assetFile := filepath.Join(root, "assets", asset)
|
||||
assetContent, err := app.ReadFile(assetFile)
|
||||
if err != nil {
|
||||
return []byte("")
|
||||
}
|
||||
|
||||
// Add proper YAML formatting for content (multiline string)
|
||||
lines := strings.Split(string(assetContent), "\n")
|
||||
formattedContent := "|\n"
|
||||
for _, line := range lines {
|
||||
formattedContent += " " + line + "\n"
|
||||
}
|
||||
return []byte(formattedContent)
|
||||
})
|
||||
}
|
||||
|
||||
// Parse parses a single prompt, replacing variables
|
||||
// ctx: context variables map, key corresponds to $CTX.{key}
|
||||
// Returns a new Prompt with variables replaced
|
||||
func (p Prompt) Parse(ctx map[string]string) Prompt {
|
||||
result := Prompt{
|
||||
Role: p.Role,
|
||||
Content: parseVariables(p.Content, ctx),
|
||||
Name: p.Name,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Parse parses all prompts in the slice, replacing variables
|
||||
// ctx: context variables map, key corresponds to $CTX.{key}
|
||||
// Returns a new Prompts slice with variables replaced
|
||||
func (ps Prompts) Parse(ctx map[string]string) Prompts {
|
||||
result := make(Prompts, len(ps))
|
||||
for i, p := range ps {
|
||||
result[i] = p.Parse(ctx)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseVariables replaces all variable types in content
|
||||
func parseVariables(content string, ctx map[string]string) string {
|
||||
// Replace $SYS.* variables
|
||||
content = reSysVar.ReplaceAllStringFunc(content, func(s string) string {
|
||||
matches := reSysVar.FindStringSubmatch(s)
|
||||
if len(matches) < 2 {
|
||||
return s
|
||||
}
|
||||
varName := matches[1]
|
||||
if fn, ok := SystemVariables[varName]; ok {
|
||||
return fn()
|
||||
}
|
||||
return s // Keep original if not found
|
||||
})
|
||||
|
||||
// Replace $ENV.* variables
|
||||
content = reEnvVar.ReplaceAllStringFunc(content, func(s string) string {
|
||||
matches := reEnvVar.FindStringSubmatch(s)
|
||||
if len(matches) < 2 {
|
||||
return s
|
||||
}
|
||||
varName := matches[1]
|
||||
return os.Getenv(varName)
|
||||
})
|
||||
|
||||
// Replace $CTX.* variables
|
||||
if ctx != nil {
|
||||
content = reCtxVar.ReplaceAllStringFunc(content, func(s string) string {
|
||||
matches := reCtxVar.FindStringSubmatch(s)
|
||||
if len(matches) < 2 {
|
||||
return s
|
||||
}
|
||||
varName := matches[1]
|
||||
if val, ok := ctx[varName]; ok {
|
||||
return val
|
||||
}
|
||||
return "" // Empty string if not found in ctx
|
||||
})
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// Merge merges two prompt slices
|
||||
// globalPrompts are prepended to assistantPrompts
|
||||
func Merge(globalPrompts, assistantPrompts []Prompt) []Prompt {
|
||||
if len(globalPrompts) == 0 {
|
||||
return assistantPrompts
|
||||
}
|
||||
if len(assistantPrompts) == 0 {
|
||||
return globalPrompts
|
||||
}
|
||||
result := make([]Prompt, 0, len(globalPrompts)+len(assistantPrompts))
|
||||
result = append(result, globalPrompts...)
|
||||
result = append(result, assistantPrompts...)
|
||||
return result
|
||||
}
|
||||
432
agent/store/types/prompt_test.go
Normal file
432
agent/store/types/prompt_test.go
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPromptParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt Prompt
|
||||
ctx map[string]string
|
||||
validate func(t *testing.T, result Prompt)
|
||||
}{
|
||||
{
|
||||
name: "ParseSysTimeVariables",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Current time: $SYS.TIME, Date: $SYS.DATE",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.Equal(t, "system", result.Role)
|
||||
// Check that variables are replaced (not exact match due to time)
|
||||
assert.NotContains(t, result.Content, "$SYS.TIME")
|
||||
assert.NotContains(t, result.Content, "$SYS.DATE")
|
||||
assert.Contains(t, result.Content, "Current time:")
|
||||
assert.Contains(t, result.Content, "Date:")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseSysDatetimeVariable",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Now: $SYS.DATETIME, Timezone: $SYS.TIMEZONE",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.NotContains(t, result.Content, "$SYS.DATETIME")
|
||||
assert.NotContains(t, result.Content, "$SYS.TIMEZONE")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseSysWeekdayVariable",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Today is $SYS.WEEKDAY",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
weekday := time.Now().Weekday().String()
|
||||
assert.Contains(t, result.Content, weekday)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseSysYearMonthDay",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Year: $SYS.YEAR, Month: $SYS.MONTH, Day: $SYS.DAY",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
now := time.Now()
|
||||
assert.Contains(t, result.Content, now.Format("2006"))
|
||||
assert.Contains(t, result.Content, now.Format("01"))
|
||||
assert.Contains(t, result.Content, now.Format("02"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseSysHourMinuteSecond",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Hour: $SYS.HOUR, Minute: $SYS.MINUTE, Second: $SYS.SECOND",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.NotContains(t, result.Content, "$SYS.HOUR")
|
||||
assert.NotContains(t, result.Content, "$SYS.MINUTE")
|
||||
assert.NotContains(t, result.Content, "$SYS.SECOND")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseEnvVariable",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "App: $ENV.TEST_APP_NAME",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.Contains(t, result.Content, "App: TestApp")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseEnvVariableNotFound",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Value: $ENV.NOT_EXIST_VAR_12345",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
// Should be replaced with empty string
|
||||
assert.Equal(t, "Value: ", result.Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseCtxVariables",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "User: $CTX.USER_ID, Locale: $CTX.LOCALE",
|
||||
},
|
||||
ctx: map[string]string{
|
||||
"USER_ID": "user-123",
|
||||
"LOCALE": "zh-CN",
|
||||
},
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.Equal(t, "User: user-123, Locale: zh-CN", result.Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseCtxVariableNotFound",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Value: $CTX.NOT_EXIST",
|
||||
},
|
||||
ctx: map[string]string{
|
||||
"OTHER": "value",
|
||||
},
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
// Should be replaced with empty string
|
||||
assert.Equal(t, "Value: ", result.Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseCtxWithNilMap",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Value: $CTX.SOMETHING",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
// Should keep original when ctx is nil
|
||||
assert.Equal(t, "Value: $CTX.SOMETHING", result.Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseMixedVariables",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Time: $SYS.TIME, App: $ENV.TEST_APP_NAME, User: $CTX.USER_ID",
|
||||
},
|
||||
ctx: map[string]string{
|
||||
"USER_ID": "user-456",
|
||||
},
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.NotContains(t, result.Content, "$SYS.TIME")
|
||||
assert.Contains(t, result.Content, "App: TestApp")
|
||||
assert.Contains(t, result.Content, "User: user-456")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseUnknownSysVariable",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Value: $SYS.UNKNOWN_VAR",
|
||||
},
|
||||
ctx: nil,
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
// Should keep original if not found
|
||||
assert.Equal(t, "Value: $SYS.UNKNOWN_VAR", result.Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParsePreservesRoleAndName",
|
||||
prompt: Prompt{
|
||||
Role: "user",
|
||||
Content: "Hello $CTX.NAME",
|
||||
Name: "test_user",
|
||||
},
|
||||
ctx: map[string]string{
|
||||
"NAME": "World",
|
||||
},
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.Equal(t, "user", result.Role)
|
||||
assert.Equal(t, "Hello World", result.Content)
|
||||
assert.Equal(t, "test_user", result.Name)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseCustomCtxVariables",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: "Custom: $CTX.MY_CUSTOM_VAR, Another: $CTX.ANOTHER_VAR",
|
||||
},
|
||||
ctx: map[string]string{
|
||||
"MY_CUSTOM_VAR": "custom-value",
|
||||
"ANOTHER_VAR": "another-value",
|
||||
},
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.Equal(t, "Custom: custom-value, Another: another-value", result.Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ParseMultilineContent",
|
||||
prompt: Prompt{
|
||||
Role: "system",
|
||||
Content: `# System Context
|
||||
Current Time: $SYS.TIME
|
||||
User: $CTX.USER_ID
|
||||
App: $ENV.TEST_APP_NAME`,
|
||||
},
|
||||
ctx: map[string]string{
|
||||
"USER_ID": "user-789",
|
||||
},
|
||||
validate: func(t *testing.T, result Prompt) {
|
||||
assert.Contains(t, result.Content, "# System Context")
|
||||
assert.NotContains(t, result.Content, "$SYS.TIME")
|
||||
assert.Contains(t, result.Content, "User: user-789")
|
||||
assert.Contains(t, result.Content, "App: TestApp")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Set test environment variable
|
||||
os.Setenv("TEST_APP_NAME", "TestApp")
|
||||
defer os.Unsetenv("TEST_APP_NAME")
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.prompt.Parse(tt.ctx)
|
||||
tt.validate(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptsParse(t *testing.T) {
|
||||
os.Setenv("TEST_APP_NAME", "TestApp")
|
||||
defer os.Unsetenv("TEST_APP_NAME")
|
||||
|
||||
prompts := Prompts{
|
||||
{Role: "system", Content: "Time: $SYS.TIME"},
|
||||
{Role: "system", Content: "User: $CTX.USER_ID"},
|
||||
{Role: "user", Content: "App: $ENV.TEST_APP_NAME"},
|
||||
}
|
||||
|
||||
ctx := map[string]string{
|
||||
"USER_ID": "user-123",
|
||||
}
|
||||
|
||||
result := prompts.Parse(ctx)
|
||||
|
||||
assert.Len(t, result, 3)
|
||||
assert.NotContains(t, result[0].Content, "$SYS.TIME")
|
||||
assert.Equal(t, "User: user-123", result[1].Content)
|
||||
assert.Equal(t, "App: TestApp", result[2].Content)
|
||||
}
|
||||
|
||||
func TestMergePrompts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
globalPrompts []Prompt
|
||||
assistantPrompts []Prompt
|
||||
expectedLen int
|
||||
validate func(t *testing.T, result []Prompt)
|
||||
}{
|
||||
{
|
||||
name: "MergeBothNonEmpty",
|
||||
globalPrompts: []Prompt{
|
||||
{Role: "system", Content: "Global prompt 1"},
|
||||
{Role: "system", Content: "Global prompt 2"},
|
||||
},
|
||||
assistantPrompts: []Prompt{
|
||||
{Role: "system", Content: "Assistant prompt 1"},
|
||||
},
|
||||
expectedLen: 3,
|
||||
validate: func(t *testing.T, result []Prompt) {
|
||||
assert.Equal(t, "Global prompt 1", result[0].Content)
|
||||
assert.Equal(t, "Global prompt 2", result[1].Content)
|
||||
assert.Equal(t, "Assistant prompt 1", result[2].Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MergeGlobalEmpty",
|
||||
globalPrompts: []Prompt{},
|
||||
assistantPrompts: []Prompt{
|
||||
{Role: "system", Content: "Assistant prompt"},
|
||||
},
|
||||
expectedLen: 1,
|
||||
validate: func(t *testing.T, result []Prompt) {
|
||||
assert.Equal(t, "Assistant prompt", result[0].Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MergeAssistantEmpty",
|
||||
globalPrompts: []Prompt{
|
||||
{Role: "system", Content: "Global prompt"},
|
||||
},
|
||||
assistantPrompts: []Prompt{},
|
||||
expectedLen: 1,
|
||||
validate: func(t *testing.T, result []Prompt) {
|
||||
assert.Equal(t, "Global prompt", result[0].Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MergeBothEmpty",
|
||||
globalPrompts: []Prompt{},
|
||||
assistantPrompts: []Prompt{},
|
||||
expectedLen: 0,
|
||||
validate: func(t *testing.T, result []Prompt) {
|
||||
assert.Empty(t, result)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MergeGlobalNil",
|
||||
globalPrompts: nil,
|
||||
assistantPrompts: []Prompt{
|
||||
{Role: "system", Content: "Assistant prompt"},
|
||||
},
|
||||
expectedLen: 1,
|
||||
validate: func(t *testing.T, result []Prompt) {
|
||||
assert.Equal(t, "Assistant prompt", result[0].Content)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MergeAssistantNil",
|
||||
globalPrompts: []Prompt{
|
||||
{Role: "system", Content: "Global prompt"},
|
||||
},
|
||||
assistantPrompts: nil,
|
||||
expectedLen: 1,
|
||||
validate: func(t *testing.T, result []Prompt) {
|
||||
assert.Equal(t, "Global prompt", result[0].Content)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := Merge(tt.globalPrompts, tt.assistantPrompts)
|
||||
assert.Len(t, result, tt.expectedLen)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemVariables(t *testing.T) {
|
||||
// Test that all system variables are defined and return non-empty values
|
||||
expectedVars := []string{
|
||||
"TIME", "DATE", "DATETIME", "TIMEZONE", "WEEKDAY",
|
||||
"YEAR", "MONTH", "DAY", "HOUR", "MINUTE", "SECOND", "UNIX",
|
||||
}
|
||||
|
||||
for _, varName := range expectedVars {
|
||||
t.Run(varName, func(t *testing.T) {
|
||||
fn, ok := SystemVariables[varName]
|
||||
assert.True(t, ok, "SystemVariables should contain %s", varName)
|
||||
value := fn()
|
||||
assert.NotEmpty(t, value, "SystemVariables[%s]() should return non-empty value", varName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVariablesEdgeCases(t *testing.T) {
|
||||
os.Setenv("TEST_VAR", "test-value")
|
||||
defer os.Unsetenv("TEST_VAR")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
ctx map[string]string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "EmptyContent",
|
||||
content: "",
|
||||
ctx: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "NoVariables",
|
||||
content: "Hello, World!",
|
||||
ctx: nil,
|
||||
expected: "Hello, World!",
|
||||
},
|
||||
{
|
||||
name: "PartialVariableSyntax",
|
||||
content: "Value: $SYS Value: $ENV Value: $CTX",
|
||||
ctx: nil,
|
||||
expected: "Value: $SYS Value: $ENV Value: $CTX",
|
||||
},
|
||||
{
|
||||
name: "VariableInMiddleOfWord",
|
||||
content: "prefix$SYS.TIMEsuffix",
|
||||
ctx: nil,
|
||||
expected: "prefix$SYS.TIMEsuffix", // Should not match - variable must be followed by valid char
|
||||
},
|
||||
{
|
||||
name: "MultipleOccurrences",
|
||||
content: "$CTX.VAR and $CTX.VAR again",
|
||||
ctx: map[string]string{"VAR": "value"},
|
||||
expected: "value and value again",
|
||||
},
|
||||
{
|
||||
name: "SpecialCharactersInValue",
|
||||
content: "User: $CTX.USER",
|
||||
ctx: map[string]string{"USER": "user@example.com"},
|
||||
expected: "User: user@example.com",
|
||||
},
|
||||
{
|
||||
name: "UnicodeInValue",
|
||||
content: "Name: $CTX.NAME",
|
||||
ctx: map[string]string{"NAME": "用户名"},
|
||||
expected: "Name: 用户名",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseVariables(tt.content, tt.ctx)
|
||||
if tt.name == "VariableInMiddleOfWord" {
|
||||
// This case depends on regex behavior - just check it doesn't crash
|
||||
assert.NotEmpty(t, result)
|
||||
} else {
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -246,34 +246,35 @@ type ConnectorOptions struct {
|
|||
|
||||
// AssistantModel the assistant database model
|
||||
type AssistantModel struct {
|
||||
ID string `json:"assistant_id"` // Assistant ID
|
||||
Type string `json:"type,omitempty"` // Assistant Type, default is assistant
|
||||
Name string `json:"name,omitempty"` // Assistant Name
|
||||
Avatar string `json:"avatar,omitempty"` // Assistant Avatar
|
||||
Connector string `json:"connector"` // AI Connector (default connector)
|
||||
ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from
|
||||
Path string `json:"path,omitempty"` // Assistant Path
|
||||
BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant
|
||||
Sort int `json:"sort,omitempty"` // Assistant Sort
|
||||
Description string `json:"description,omitempty"` // Assistant Description
|
||||
Tags []string `json:"tags,omitempty"` // Assistant Tags
|
||||
Readonly bool `json:"readonly,omitempty"` // Whether this assistant is readonly
|
||||
Public bool `json:"public,omitempty"` // Whether this assistant is shared across all teams in the platform
|
||||
Share string `json:"share,omitempty"` // Assistant sharing scope (private/team)
|
||||
Mentionable bool `json:"mentionable,omitempty"` // Whether this assistant is mentionable
|
||||
Automated bool `json:"automated,omitempty"` // Whether this assistant is automated
|
||||
Options map[string]interface{} `json:"options,omitempty"` // AI Options
|
||||
Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts (default prompts)
|
||||
PromptPresets map[string][]Prompt `json:"prompt_presets,omitempty"` // Prompt presets organized by mode (e.g., "chat", "task", etc.)
|
||||
KB *KnowledgeBase `json:"kb,omitempty"` // Knowledge base configuration
|
||||
MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration
|
||||
Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration
|
||||
Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder
|
||||
Source string `json:"source,omitempty"` // Hook script source code
|
||||
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
|
||||
CreatedAt int64 `json:"created_at"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at"` // Last update timestamp
|
||||
ID string `json:"assistant_id"` // Assistant ID
|
||||
Type string `json:"type,omitempty"` // Assistant Type, default is assistant
|
||||
Name string `json:"name,omitempty"` // Assistant Name
|
||||
Avatar string `json:"avatar,omitempty"` // Assistant Avatar
|
||||
Connector string `json:"connector"` // AI Connector (default connector)
|
||||
ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from
|
||||
Path string `json:"path,omitempty"` // Assistant Path
|
||||
BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant
|
||||
Sort int `json:"sort,omitempty"` // Assistant Sort
|
||||
Description string `json:"description,omitempty"` // Assistant Description
|
||||
Tags []string `json:"tags,omitempty"` // Assistant Tags
|
||||
Readonly bool `json:"readonly,omitempty"` // Whether this assistant is readonly
|
||||
Public bool `json:"public,omitempty"` // Whether this assistant is shared across all teams in the platform
|
||||
Share string `json:"share,omitempty"` // Assistant sharing scope (private/team)
|
||||
Mentionable bool `json:"mentionable,omitempty"` // Whether this assistant is mentionable
|
||||
Automated bool `json:"automated,omitempty"` // Whether this assistant is automated
|
||||
Options map[string]interface{} `json:"options,omitempty"` // AI Options
|
||||
Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts (default prompts)
|
||||
PromptPresets map[string][]Prompt `json:"prompt_presets,omitempty"` // Prompt presets organized by mode (e.g., "chat", "task", etc.)
|
||||
DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Whether to disable global prompts, default is false
|
||||
KB *KnowledgeBase `json:"kb,omitempty"` // Knowledge base configuration
|
||||
MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration
|
||||
Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration
|
||||
Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder
|
||||
Source string `json:"source,omitempty"` // Hook script source code
|
||||
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
|
||||
CreatedAt int64 `json:"created_at"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at"` // Last update timestamp
|
||||
|
||||
// Permission management fields (not exposed in JSON API responses)
|
||||
YaoCreatedBy string `json:"-"` // User who created the assistant (not exposed in JSON)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ func (conv *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
|||
data["public"] = assistant.Public
|
||||
data["mentionable"] = assistant.Mentionable
|
||||
data["automated"] = assistant.Automated
|
||||
data["disable_global_prompts"] = assistant.DisableGlobalPrompts
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now().UnixNano()
|
||||
|
|
@ -502,27 +503,28 @@ func (conv *Xun) GetAssistant(assistantID string, fields []string, locale ...str
|
|||
|
||||
// Convert map to types.AssistantModel
|
||||
model := &types.AssistantModel{
|
||||
ID: getString(data, "assistant_id"),
|
||||
Type: getString(data, "type"),
|
||||
Name: getString(data, "name"),
|
||||
Avatar: getString(data, "avatar"),
|
||||
Connector: getString(data, "connector"),
|
||||
Path: getString(data, "path"),
|
||||
Source: getString(data, "source"),
|
||||
BuiltIn: getBool(data, "built_in"),
|
||||
Sort: getInt(data, "sort"),
|
||||
Description: getString(data, "description"),
|
||||
Readonly: getBool(data, "readonly"),
|
||||
Public: getBool(data, "public"),
|
||||
Share: getString(data, "share"),
|
||||
Mentionable: getBool(data, "mentionable"),
|
||||
Automated: getBool(data, "automated"),
|
||||
CreatedAt: getInt64(data, "created_at"),
|
||||
UpdatedAt: getInt64(data, "updated_at"),
|
||||
YaoCreatedBy: getString(data, "__yao_created_by"),
|
||||
YaoUpdatedBy: getString(data, "__yao_updated_by"),
|
||||
YaoTeamID: getString(data, "__yao_team_id"),
|
||||
YaoTenantID: getString(data, "__yao_tenant_id"),
|
||||
ID: getString(data, "assistant_id"),
|
||||
Type: getString(data, "type"),
|
||||
Name: getString(data, "name"),
|
||||
Avatar: getString(data, "avatar"),
|
||||
Connector: getString(data, "connector"),
|
||||
Path: getString(data, "path"),
|
||||
Source: getString(data, "source"),
|
||||
BuiltIn: getBool(data, "built_in"),
|
||||
Sort: getInt(data, "sort"),
|
||||
Description: getString(data, "description"),
|
||||
Readonly: getBool(data, "readonly"),
|
||||
Public: getBool(data, "public"),
|
||||
Share: getString(data, "share"),
|
||||
Mentionable: getBool(data, "mentionable"),
|
||||
Automated: getBool(data, "automated"),
|
||||
DisableGlobalPrompts: getBool(data, "disable_global_prompts"),
|
||||
CreatedAt: getInt64(data, "created_at"),
|
||||
UpdatedAt: getInt64(data, "updated_at"),
|
||||
YaoCreatedBy: getString(data, "__yao_created_by"),
|
||||
YaoUpdatedBy: getString(data, "__yao_updated_by"),
|
||||
YaoTeamID: getString(data, "__yao_team_id"),
|
||||
YaoTenantID: getString(data, "__yao_tenant_id"),
|
||||
}
|
||||
|
||||
// Handle Tags
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ type DSL struct {
|
|||
// Internal
|
||||
// ===============================
|
||||
// ID string `json:"-" yaml:"-"` // The id of the instance
|
||||
Assistant assistant.API `json:"-" yaml:"-"` // The default assistant
|
||||
Store store.Store `json:"-" yaml:"-"` // The store of the assistant
|
||||
Assistant assistant.API `json:"-" yaml:"-"` // The default assistant
|
||||
Store store.Store `json:"-" yaml:"-"` // The store of the assistant
|
||||
GlobalPrompts []store.Prompt `json:"-" yaml:"-"` // Global prompts loaded from agent/prompts.yml
|
||||
}
|
||||
|
||||
// Uses the default assistant settings
|
||||
|
|
|
|||
284
data/bindata.go
284
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -125,6 +125,14 @@
|
|||
"comment": "Prompt presets organized by mode (e.g., chat, task, etc.)",
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "disable_global_prompts",
|
||||
"type": "boolean",
|
||||
"label": "Disable Global Prompts",
|
||||
"comment": "Whether to disable global prompts for this assistant",
|
||||
"default": false,
|
||||
"index": true
|
||||
},
|
||||
{
|
||||
"name": "workflow",
|
||||
"type": "json",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue