Implement System Agents Configuration and Loading Mechanism
- Added configuration support for system agents in the assistant initialization process, allowing for custom connectors for agents like __yao.keyword and __yao.querydsl. - Implemented the loading mechanism for system agents from bindata, ensuring that essential agents are available during runtime. - Updated the LoadBuiltIn function to exclude system agents from being removed, enhancing the management of built-in and system agents. - Enhanced test coverage by introducing tests for loading system agents, verifying their presence and correctness in the cache. - Updated documentation to reflect the new system agents configuration and loading processes.
This commit is contained in:
parent
a2182eda1d
commit
c86ccb55b1
18 changed files with 1205 additions and 193 deletions
|
|
@ -45,7 +45,7 @@ func LoadBuiltIn() error {
|
|||
// Get all existing built-in assistants
|
||||
deletedBuiltIn := map[string]bool{}
|
||||
|
||||
// Remove the built-in assistants
|
||||
// Remove the built-in assistants (exclude system agents with __yao. prefix)
|
||||
if storage != nil {
|
||||
|
||||
builtIn := true
|
||||
|
|
@ -54,8 +54,12 @@ func LoadBuiltIn() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Get all existing built-in assistants
|
||||
// Get all existing built-in assistants (exclude system agents)
|
||||
for _, assistant := range res.Data {
|
||||
// Skip system agents (they are managed by LoadSystemAgents)
|
||||
if strings.HasPrefix(assistant.ID, "__yao.") {
|
||||
continue
|
||||
}
|
||||
deletedBuiltIn[assistant.ID] = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
367
agent/assistant/load_system.go
Normal file
367
agent/assistant/load_system.go
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/connector"
|
||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// systemAgents defines the system agents loaded from bindata
|
||||
// These are internal agents used by the system (e.g., keyword extraction, querydsl generation)
|
||||
// The directory name is without __yao. prefix, prefix is added during loading
|
||||
// Format: directory name -> bindata path prefix
|
||||
var systemAgents = []string{
|
||||
"keyword",
|
||||
"querydsl",
|
||||
"title",
|
||||
"prompt",
|
||||
"needsearch",
|
||||
"entity",
|
||||
}
|
||||
|
||||
// SystemConfig holds the system agents connector configuration
|
||||
// This is set from agent.yml system block
|
||||
type SystemConfig struct {
|
||||
Default string // Default connector for all system agents
|
||||
Keyword string // Connector for __yao.keyword agent
|
||||
QueryDSL string // Connector for __yao.querydsl agent
|
||||
Title string // Connector for __yao.title agent
|
||||
Prompt string // Connector for __yao.prompt agent
|
||||
NeedSearch string // Connector for __yao.needsearch agent
|
||||
Entity string // Connector for __yao.entity agent
|
||||
}
|
||||
|
||||
// systemConfig holds the system agents configuration (global variable like others in load.go)
|
||||
var systemConfig *SystemConfig = nil
|
||||
|
||||
// SetSystemConfig sets the system agents configuration
|
||||
func SetSystemConfig(config *SystemConfig) {
|
||||
systemConfig = config
|
||||
}
|
||||
|
||||
// GetSystemConfig returns the system agents configuration
|
||||
func GetSystemConfig() *SystemConfig {
|
||||
return systemConfig
|
||||
}
|
||||
|
||||
// LoadSystemAgents loads the system agents from bindata
|
||||
// These are internal agents like __yao.keyword and __yao.querydsl
|
||||
// They are loaded before application assistants
|
||||
// Behavior is same as LoadBuiltIn, just reads from bindata instead of filesystem
|
||||
func LoadSystemAgents() error {
|
||||
|
||||
// Get all existing system agents (for cleanup)
|
||||
deletedSystem := map[string]bool{}
|
||||
if storage != nil {
|
||||
// System agents have "system" tag
|
||||
tags := []string{"system"}
|
||||
builtIn := true
|
||||
res, err := storage.GetAssistants(store.AssistantFilter{
|
||||
Tags: tags,
|
||||
BuiltIn: &builtIn,
|
||||
Select: []string{"assistant_id", "id"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("Failed to get existing system agents: %v", err)
|
||||
} else {
|
||||
for _, assistant := range res.Data {
|
||||
deletedSystem[assistant.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort := 1
|
||||
for _, name := range systemAgents {
|
||||
// Build agent ID with __yao. prefix
|
||||
id := "__yao." + name
|
||||
pathPrefix := "yao/assistants/" + name
|
||||
|
||||
assistant, err := loadSystemAgent(id, pathPrefix)
|
||||
if err != nil {
|
||||
log.Warn("Failed to load system agent %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set sort order
|
||||
if assistant.Sort == 0 {
|
||||
assistant.Sort = sort
|
||||
}
|
||||
|
||||
// Save to storage
|
||||
if err := assistant.Save(); err != nil {
|
||||
log.Warn("Failed to save system agent %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Initialize the assistant
|
||||
if err := assistant.initialize(); err != nil {
|
||||
log.Warn("Failed to initialize system agent %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sort++
|
||||
loaded.Put(assistant)
|
||||
log.Trace("Loaded system agent: %s", id)
|
||||
|
||||
// Remove from deleted list
|
||||
delete(deletedSystem, id)
|
||||
}
|
||||
|
||||
// Remove deleted system agents
|
||||
if len(deletedSystem) > 0 {
|
||||
assistantIDs := []string{}
|
||||
for assistantID := range deletedSystem {
|
||||
assistantIDs = append(assistantIDs, assistantID)
|
||||
}
|
||||
if _, err := storage.DeleteAssistants(store.AssistantFilter{AssistantIDs: assistantIDs}); err != nil {
|
||||
log.Warn("Failed to delete obsolete system agents: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSystemAgent loads a single system agent from bindata
|
||||
// This follows the same pattern as LoadPath but reads from bindata
|
||||
func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
|
||||
// Read package.yao from bindata
|
||||
pkgPath := pathPrefix + "/package.yao"
|
||||
pkgContent, err := data.Read(pkgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s: %w", pkgPath, err)
|
||||
}
|
||||
|
||||
// Parse package.yao
|
||||
var pkgData map[string]interface{}
|
||||
if err := application.Parse(pkgPath, pkgContent, &pkgData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s: %w", pkgPath, err)
|
||||
}
|
||||
|
||||
// Set assistant_id and path
|
||||
pkgData["assistant_id"] = id
|
||||
pkgData["path"] = "/" + pathPrefix
|
||||
|
||||
// Set type if not specified
|
||||
if _, has := pkgData["type"]; !has {
|
||||
pkgData["type"] = "assistant"
|
||||
}
|
||||
|
||||
// Resolve connector for this system agent
|
||||
connectorID := resolveSystemConnector(id)
|
||||
if connectorID != "" {
|
||||
pkgData["connector"] = connectorID
|
||||
}
|
||||
|
||||
// Read prompts.yml from bindata (default prompts)
|
||||
promptsPath := pathPrefix + "/prompts.yml"
|
||||
promptsContent, err := data.Read(promptsPath)
|
||||
if err == nil {
|
||||
var prompts []store.Prompt
|
||||
if err := yaml.Unmarshal(promptsContent, &prompts); err == nil && len(prompts) > 0 {
|
||||
pkgData["prompts"] = prompts
|
||||
}
|
||||
}
|
||||
|
||||
// Read prompt_presets from prompts directory
|
||||
presets := loadSystemPromptPresets(pathPrefix)
|
||||
if len(presets) > 0 {
|
||||
pkgData["prompt_presets"] = presets
|
||||
}
|
||||
|
||||
// Load scripts from src directory (hook script source and other scripts sources)
|
||||
// These will be compiled by loadMap -> LoadScriptsFromData
|
||||
hookScriptSource, scriptsSource := loadSystemScripts(pathPrefix)
|
||||
if hookScriptSource != "" {
|
||||
pkgData["script"] = hookScriptSource
|
||||
}
|
||||
if len(scriptsSource) > 0 {
|
||||
pkgData["scripts"] = scriptsSource
|
||||
}
|
||||
|
||||
// Read locales
|
||||
locales, err := loadSystemLocales(pathPrefix)
|
||||
if err == nil && len(locales) > 0 {
|
||||
pkgData["locales"] = locales
|
||||
}
|
||||
|
||||
// Mark as system agent
|
||||
pkgData["readonly"] = true
|
||||
pkgData["built_in"] = true
|
||||
pkgData["tags"] = []string{"system"}
|
||||
|
||||
// Load from map (same as LoadPath, includes initialize())
|
||||
return loadMap(pkgData)
|
||||
}
|
||||
|
||||
// resolveSystemConnector resolves the connector for a system agent
|
||||
// Priority: specific agent config > system.default > defaultConnector > fallback to first capable connector
|
||||
func resolveSystemConnector(agentID string) string {
|
||||
// Try specific agent config first
|
||||
if systemConfig != nil {
|
||||
switch agentID {
|
||||
case "__yao.keyword":
|
||||
if systemConfig.Keyword != "" {
|
||||
return systemConfig.Keyword
|
||||
}
|
||||
case "__yao.querydsl":
|
||||
if systemConfig.QueryDSL != "" {
|
||||
return systemConfig.QueryDSL
|
||||
}
|
||||
case "__yao.title":
|
||||
if systemConfig.Title != "" {
|
||||
return systemConfig.Title
|
||||
}
|
||||
case "__yao.prompt":
|
||||
if systemConfig.Prompt != "" {
|
||||
return systemConfig.Prompt
|
||||
}
|
||||
case "__yao.needsearch":
|
||||
if systemConfig.NeedSearch != "" {
|
||||
return systemConfig.NeedSearch
|
||||
}
|
||||
case "__yao.entity":
|
||||
if systemConfig.Entity != "" {
|
||||
return systemConfig.Entity
|
||||
}
|
||||
}
|
||||
|
||||
// Try system default
|
||||
if systemConfig.Default != "" {
|
||||
return systemConfig.Default
|
||||
}
|
||||
}
|
||||
|
||||
// Try global default connector
|
||||
if defaultConnector != "" {
|
||||
return defaultConnector
|
||||
}
|
||||
|
||||
// Fallback: find first connector that supports tool calling
|
||||
return findCapableConnector()
|
||||
}
|
||||
|
||||
// findCapableConnector finds the first connector that supports tool calling
|
||||
func findCapableConnector() string {
|
||||
// Get all registered connectors
|
||||
for id, conn := range connector.Connectors {
|
||||
if !conn.Is(connector.OPENAI) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check from modelCapabilities (user-defined in models.yml)
|
||||
if caps, exists := modelCapabilities[id]; exists {
|
||||
if caps.ToolCalls {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
// Check capabilities from connector's Options
|
||||
if connOpenAI, ok := conn.(*gouOpenAI.Connector); ok {
|
||||
if connOpenAI.Options.Capabilities != nil && connOpenAI.Options.Capabilities.ToolCalls {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No capable connector found, return empty
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadSystemPromptPresets loads prompt presets from bindata prompts directory
|
||||
func loadSystemPromptPresets(pathPrefix string) map[string][]store.Prompt {
|
||||
presets := make(map[string][]store.Prompt)
|
||||
promptsDir := pathPrefix + "/prompts"
|
||||
|
||||
// Try common preset files
|
||||
presetFiles := []string{"chat.yml", "task.yml", "code.yml", "analysis.yml"}
|
||||
for _, filename := range presetFiles {
|
||||
presetPath := promptsDir + "/" + filename
|
||||
content, err := data.Read(presetPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var prompts []store.Prompt
|
||||
if err := yaml.Unmarshal(content, &prompts); err == nil && len(prompts) > 0 {
|
||||
presetName := strings.TrimSuffix(filename, ".yml")
|
||||
presets[presetName] = prompts
|
||||
}
|
||||
}
|
||||
|
||||
return presets
|
||||
}
|
||||
|
||||
// loadSystemScripts loads scripts source from bindata src directory
|
||||
// Returns hook script source and other scripts sources (as strings)
|
||||
// These will be compiled by loadMap -> LoadScriptsFromData
|
||||
func loadSystemScripts(pathPrefix string) (string, map[string]string) {
|
||||
srcDir := pathPrefix + "/src"
|
||||
|
||||
// Try to load hook script (index.ts)
|
||||
var hookScriptSource string
|
||||
indexPath := srcDir + "/index.ts"
|
||||
indexContent, err := data.Read(indexPath)
|
||||
if err == nil && len(indexContent) > 0 {
|
||||
hookScriptSource = string(indexContent)
|
||||
}
|
||||
|
||||
// Try to load other scripts
|
||||
scripts := make(map[string]string)
|
||||
scriptFiles := []string{"utils.ts", "helpers.ts", "tools.ts"}
|
||||
for _, filename := range scriptFiles {
|
||||
scriptPath := srcDir + "/" + filename
|
||||
content, err := data.Read(scriptPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
scriptName := strings.TrimSuffix(filename, ".ts")
|
||||
scripts[scriptName] = string(content)
|
||||
}
|
||||
|
||||
if len(scripts) == 0 {
|
||||
scripts = nil
|
||||
}
|
||||
|
||||
return hookScriptSource, scripts
|
||||
}
|
||||
|
||||
// loadSystemLocales loads locales from bindata
|
||||
func loadSystemLocales(pathPrefix string) (i18n.Map, error) {
|
||||
locales := make(i18n.Map)
|
||||
|
||||
// Try to load common locale files
|
||||
localeFiles := []string{"en-us.yml", "zh-cn.yml", "en.yml", "zh.yml"}
|
||||
localesDir := pathPrefix + "/locales"
|
||||
|
||||
for _, filename := range localeFiles {
|
||||
localePath := filepath.Join(localesDir, filename)
|
||||
content, err := data.Read(localePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse locale file
|
||||
locale := strings.TrimSuffix(filename, ".yml")
|
||||
var messages map[string]any
|
||||
if err := yaml.Unmarshal(content, &messages); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
locales[locale] = i18n.I18n{
|
||||
Locale: locale,
|
||||
Messages: messages,
|
||||
}
|
||||
}
|
||||
|
||||
return locales, nil
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
package assistant
|
||||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
|
|
@ -14,13 +16,19 @@ func prepare(t *testing.T) {
|
|||
test.Prepare(t, config.Conf)
|
||||
}
|
||||
|
||||
func prepareAgent(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
err := agent.Load(config.Conf)
|
||||
require.NoError(t, err, "agent.Load should succeed")
|
||||
}
|
||||
|
||||
// TestLoadPath tests loading assistant from path
|
||||
func TestLoadPath(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("LoadFullFieldsAssistant", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -67,7 +75,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadConnectorOptions", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -84,7 +92,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadPromptPresets", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -116,7 +124,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadKnowledgeBase", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -129,7 +137,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadMCPServers", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -143,7 +151,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadWorkflow", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -156,7 +164,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadPlaceholder", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -169,7 +177,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadLocales", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -186,7 +194,7 @@ func TestLoadPath(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("LoadNonExistentAssistant", func(t *testing.T) {
|
||||
_, err := LoadPath("/assistants/non-existent")
|
||||
_, err := assistant.LoadPath("/assistants/non-existent")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
|
@ -196,7 +204,7 @@ func TestLoadPathMCPTest(t *testing.T) {
|
|||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
assistant, err := LoadPath("/assistants/tests/mcptest")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/mcptest")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -220,7 +228,7 @@ func TestLoadPathBuildRequest(t *testing.T) {
|
|||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
assistant, err := LoadPath("/assistants/tests/buildrequest")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/buildrequest")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
|
|
@ -238,57 +246,57 @@ func TestLoadPathBuildRequest(t *testing.T) {
|
|||
// TestCache tests the assistant cache functionality
|
||||
func TestCache(t *testing.T) {
|
||||
// Clear any existing cache
|
||||
ClearCache()
|
||||
assistant.ClearCache()
|
||||
|
||||
// Set small cache for testing
|
||||
SetCache(3)
|
||||
assert.NotNil(t, loaded)
|
||||
assistant.SetCache(3)
|
||||
assert.NotNil(t, assistant.GetCache())
|
||||
|
||||
// Create test assistants
|
||||
ast1 := &Assistant{AssistantModel: store.AssistantModel{ID: "id1", Name: "Assistant 1"}}
|
||||
ast2 := &Assistant{AssistantModel: store.AssistantModel{ID: "id2", Name: "Assistant 2"}}
|
||||
ast3 := &Assistant{AssistantModel: store.AssistantModel{ID: "id3", Name: "Assistant 3"}}
|
||||
ast4 := &Assistant{AssistantModel: store.AssistantModel{ID: "id4", Name: "Assistant 4"}}
|
||||
ast1 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id1", Name: "Assistant 1"}}
|
||||
ast2 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id2", Name: "Assistant 2"}}
|
||||
ast3 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id3", Name: "Assistant 3"}}
|
||||
ast4 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id4", Name: "Assistant 4"}}
|
||||
|
||||
t.Run("PutAndGet", func(t *testing.T) {
|
||||
loaded.Put(ast1)
|
||||
assert.Equal(t, 1, loaded.Len())
|
||||
assistant.GetCache().Put(ast1)
|
||||
assert.Equal(t, 1, assistant.GetCache().Len())
|
||||
|
||||
cached, exists := loaded.Get("id1")
|
||||
cached, exists := assistant.GetCache().Get("id1")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, ast1, cached)
|
||||
})
|
||||
|
||||
t.Run("CacheEviction", func(t *testing.T) {
|
||||
loaded.Put(ast2)
|
||||
loaded.Put(ast3)
|
||||
assert.Equal(t, 3, loaded.Len())
|
||||
assistant.GetCache().Put(ast2)
|
||||
assistant.GetCache().Put(ast3)
|
||||
assert.Equal(t, 3, assistant.GetCache().Len())
|
||||
|
||||
// Access ast1 to make it recently used
|
||||
loaded.Get("id1")
|
||||
assistant.GetCache().Get("id1")
|
||||
|
||||
// Add ast4, should evict ast2 (least recently used)
|
||||
loaded.Put(ast4)
|
||||
assert.Equal(t, 3, loaded.Len())
|
||||
assistant.GetCache().Put(ast4)
|
||||
assert.Equal(t, 3, assistant.GetCache().Len())
|
||||
|
||||
_, exists := loaded.Get("id2")
|
||||
_, exists := assistant.GetCache().Get("id2")
|
||||
assert.False(t, exists, "ast2 should be evicted")
|
||||
|
||||
_, exists = loaded.Get("id1")
|
||||
_, exists = assistant.GetCache().Get("id1")
|
||||
assert.True(t, exists, "ast1 should still exist")
|
||||
|
||||
_, exists = loaded.Get("id4")
|
||||
_, exists = assistant.GetCache().Get("id4")
|
||||
assert.True(t, exists, "ast4 should exist")
|
||||
})
|
||||
|
||||
t.Run("ClearCache", func(t *testing.T) {
|
||||
ClearCache()
|
||||
assert.Nil(t, loaded)
|
||||
assistant.ClearCache()
|
||||
assert.Nil(t, assistant.GetCache())
|
||||
})
|
||||
|
||||
t.Run("SetCacheAfterClear", func(t *testing.T) {
|
||||
SetCache(100)
|
||||
assert.NotNil(t, loaded)
|
||||
assistant.SetCache(100)
|
||||
assert.NotNil(t, assistant.GetCache())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +306,7 @@ func TestClone(t *testing.T) {
|
|||
defer test.Clean()
|
||||
|
||||
t.Run("CloneFullFieldsAssistant", func(t *testing.T) {
|
||||
original, err := LoadPath("/assistants/tests/fullfields")
|
||||
original, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
clone := original.Clone()
|
||||
|
|
@ -328,7 +336,7 @@ func TestClone(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("CloneNil", func(t *testing.T) {
|
||||
var nilAssistant *Assistant
|
||||
var nilAssistant *assistant.Assistant
|
||||
assert.Nil(t, nilAssistant.Clone())
|
||||
})
|
||||
}
|
||||
|
|
@ -339,7 +347,7 @@ func TestUpdate(t *testing.T) {
|
|||
defer test.Clean()
|
||||
|
||||
t.Run("UpdateBasicFields", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
|
|
@ -357,7 +365,7 @@ func TestUpdate(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("UpdateConnectorOptions", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
|
|
@ -377,7 +385,7 @@ func TestUpdate(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("UpdatePromptPresets", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
|
|
@ -398,7 +406,7 @@ func TestUpdate(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("UpdateSource", func(t *testing.T) {
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
|
|
@ -412,7 +420,7 @@ func TestUpdate(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("UpdateNilAssistant", func(t *testing.T) {
|
||||
var nilAssistant *Assistant
|
||||
var nilAssistant *assistant.Assistant
|
||||
err := nilAssistant.Update(map[string]interface{}{"name": "test"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
|
@ -423,7 +431,7 @@ func TestMap(t *testing.T) {
|
|||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
assistant, err := LoadPath("/assistants/tests/fullfields")
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
m := assistant.Map()
|
||||
|
|
@ -451,16 +459,103 @@ func TestMap(t *testing.T) {
|
|||
assert.Equal(t, assistant.Source, m["source"])
|
||||
}
|
||||
|
||||
// TestLoadSystemAgents tests loading system agents from bindata
|
||||
func TestLoadSystemAgents(t *testing.T) {
|
||||
prepareAgent(t)
|
||||
defer test.Clean()
|
||||
|
||||
// Clear cache first
|
||||
assistant.ClearCache()
|
||||
assistant.SetCache(200)
|
||||
|
||||
t.Run("LoadSystemAgents", func(t *testing.T) {
|
||||
err := assistant.LoadSystemAgents()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check __yao.keyword
|
||||
keywordAst, keywordExists := assistant.GetCache().Get("__yao.keyword")
|
||||
require.True(t, keywordExists, "__yao.keyword should be loaded")
|
||||
assert.Equal(t, "__yao.keyword", keywordAst.ID)
|
||||
assert.Equal(t, "Keyword Extraction", keywordAst.Name)
|
||||
assert.True(t, keywordAst.Readonly)
|
||||
assert.True(t, keywordAst.BuiltIn)
|
||||
assert.Contains(t, keywordAst.Tags, "system")
|
||||
assert.NotNil(t, keywordAst.Prompts)
|
||||
assert.Greater(t, len(keywordAst.Prompts), 0)
|
||||
|
||||
// Check __yao.querydsl
|
||||
querydslAst, querydslExists := assistant.GetCache().Get("__yao.querydsl")
|
||||
require.True(t, querydslExists, "__yao.querydsl should be loaded")
|
||||
assert.Equal(t, "__yao.querydsl", querydslAst.ID)
|
||||
assert.Equal(t, "QueryDSL Generator", querydslAst.Name)
|
||||
assert.True(t, querydslAst.Readonly)
|
||||
assert.True(t, querydslAst.BuiltIn)
|
||||
assert.Contains(t, querydslAst.Tags, "system")
|
||||
assert.NotNil(t, querydslAst.Prompts)
|
||||
assert.Greater(t, len(querydslAst.Prompts), 0)
|
||||
|
||||
// Check __yao.title
|
||||
titleAst, titleExists := assistant.GetCache().Get("__yao.title")
|
||||
require.True(t, titleExists, "__yao.title should be loaded")
|
||||
assert.Equal(t, "__yao.title", titleAst.ID)
|
||||
assert.Equal(t, "Title Generator", titleAst.Name)
|
||||
assert.True(t, titleAst.Readonly)
|
||||
assert.True(t, titleAst.BuiltIn)
|
||||
|
||||
// Check __yao.prompt
|
||||
promptAst, promptExists := assistant.GetCache().Get("__yao.prompt")
|
||||
require.True(t, promptExists, "__yao.prompt should be loaded")
|
||||
assert.Equal(t, "__yao.prompt", promptAst.ID)
|
||||
assert.Equal(t, "Prompt Optimizer", promptAst.Name)
|
||||
assert.True(t, promptAst.Readonly)
|
||||
assert.True(t, promptAst.BuiltIn)
|
||||
|
||||
// Check __yao.needsearch
|
||||
needsearchAst, needsearchExists := assistant.GetCache().Get("__yao.needsearch")
|
||||
require.True(t, needsearchExists, "__yao.needsearch should be loaded")
|
||||
assert.Equal(t, "__yao.needsearch", needsearchAst.ID)
|
||||
assert.Equal(t, "Need Search", needsearchAst.Name)
|
||||
assert.True(t, needsearchAst.Readonly)
|
||||
assert.True(t, needsearchAst.BuiltIn)
|
||||
})
|
||||
|
||||
t.Run("SystemAgentsSavedToStorage", func(t *testing.T) {
|
||||
// System agents should be saved to storage
|
||||
require.NotNil(t, assistant.GetStore(), "storage should be initialized")
|
||||
|
||||
// Check __yao.keyword in storage
|
||||
builtIn := true
|
||||
tags := []string{"system"}
|
||||
res, err := assistant.GetStore().GetAssistants(store.AssistantFilter{
|
||||
BuiltIn: &builtIn,
|
||||
Tags: tags,
|
||||
Select: []string{"assistant_id", "name"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(res.Data), 0, "System agents should be in storage")
|
||||
|
||||
// Verify at least one system agent exists
|
||||
found := false
|
||||
for _, ast := range res.Data {
|
||||
if ast.ID == "__yao.keyword" || ast.ID == "__yao.querydsl" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "System agents should be found in storage")
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidate tests the assistant Validate method
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ast *Assistant
|
||||
ast *assistant.Assistant
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ValidAssistant",
|
||||
ast: &Assistant{
|
||||
ast: &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: "test-id",
|
||||
Name: "Test Assistant",
|
||||
|
|
@ -471,7 +566,7 @@ func TestValidate(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "MissingID",
|
||||
ast: &Assistant{
|
||||
ast: &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
Name: "Test Assistant",
|
||||
Connector: "gpt-4o",
|
||||
|
|
@ -481,7 +576,7 @@ func TestValidate(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "MissingName",
|
||||
ast: &Assistant{
|
||||
ast: &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: "test-id",
|
||||
Connector: "gpt-4o",
|
||||
|
|
|
|||
|
|
@ -234,7 +234,25 @@ func initAssistant() error {
|
|||
assistant.SetGlobalSearchConfig(agentDSL.Search)
|
||||
}
|
||||
|
||||
// Load Built-in Assistants
|
||||
// Set system agents configuration
|
||||
if agentDSL.System != nil {
|
||||
assistant.SetSystemConfig(&assistant.SystemConfig{
|
||||
Default: agentDSL.System.Default,
|
||||
Keyword: agentDSL.System.Keyword,
|
||||
QueryDSL: agentDSL.System.QueryDSL,
|
||||
Title: agentDSL.System.Title,
|
||||
Prompt: agentDSL.System.Prompt,
|
||||
NeedSearch: agentDSL.System.NeedSearch,
|
||||
Entity: agentDSL.System.Entity,
|
||||
})
|
||||
}
|
||||
|
||||
// Load System Agents (from bindata: __yao.keyword, __yao.querydsl, etc.)
|
||||
if err := assistant.LoadSystemAgents(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load Built-in Assistants (from application /assistants directory)
|
||||
err := assistant.LoadBuiltIn()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ type DSL struct {
|
|||
StoreSetting store.Setting `json:"store" yaml:"store"` // The store setting of the assistant
|
||||
Cache string `json:"cache" yaml:"cache"` // The cache store of the assistant, if not set, default is "__yao.agent.cache"
|
||||
|
||||
// System Agents Connector Settings
|
||||
// ===============================
|
||||
// System configures connectors for system agents (__yao.keyword, __yao.querydsl, __yao.title, __yao.prompt)
|
||||
// Each agent can have its own connector, or use the default
|
||||
// If not set, fallback to the first connector that supports the required capabilities
|
||||
System *System `json:"system,omitempty" yaml:"system,omitempty"`
|
||||
|
||||
// Global External Settings - model capabilities, tools, etc.
|
||||
// ===============================
|
||||
Models map[string]openai.Capabilities `json:"models,omitempty" yaml:"models,omitempty"` // The model capabilities configuration
|
||||
|
|
@ -48,6 +55,18 @@ type Uses struct {
|
|||
Rerank string `json:"rerank,omitempty" yaml:"rerank,omitempty"` // Result reranking: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
}
|
||||
|
||||
// System configures connectors for system agents
|
||||
// ===============================
|
||||
type System struct {
|
||||
Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents
|
||||
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent
|
||||
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent
|
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent
|
||||
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // Connector for __yao.prompt agent
|
||||
NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent
|
||||
Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent
|
||||
}
|
||||
|
||||
// Mention Structure
|
||||
// ===============================
|
||||
type Mention struct {
|
||||
|
|
|
|||
576
data/bindata.go
576
data/bindata.go
File diff suppressed because it is too large
Load diff
10
yao/assistants/entity/package.yao
Normal file
10
yao/assistants/entity/package.yao
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "Entity Extraction",
|
||||
"description": "Extract entities and relationships for knowledge graph",
|
||||
"type": "worker",
|
||||
"options": {
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.2
|
||||
}
|
||||
}
|
||||
|
||||
28
yao/assistants/entity/prompts.yml
Normal file
28
yao/assistants/entity/prompts.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
- role: system
|
||||
content: |
|
||||
Extract entities and relationships from text for knowledge graph construction.
|
||||
|
||||
## Task
|
||||
1. Identify named entities (Person, Organization, Location, Product, Event, Concept, etc.)
|
||||
2. Extract relationships between entities
|
||||
3. Return structured JSON
|
||||
|
||||
## Response Format (JSON only)
|
||||
```json
|
||||
{
|
||||
"entities": [
|
||||
{"id": "e1", "name": "Entity Name", "type": "Person|Org|Location|Product|Event|Concept", "properties": {}}
|
||||
],
|
||||
"relationships": [
|
||||
{"source": "e1", "target": "e2", "type": "relationship_type", "properties": {}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
- Use consistent entity IDs (e1, e2, ...)
|
||||
- Normalize entity names (remove titles, standardize format)
|
||||
- Common relationship types: WORKS_FOR, LOCATED_IN, OWNS, CREATED, RELATED_TO
|
||||
- Keep properties minimal and relevant
|
||||
- Same language as input for entity names
|
||||
|
||||
9
yao/assistants/keyword/package.yao
Normal file
9
yao/assistants/keyword/package.yao
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "Keyword Extraction",
|
||||
"description": "Extract keywords from text content",
|
||||
"type": "worker",
|
||||
"options": {
|
||||
"max_tokens": 500,
|
||||
"temperature": 0.3
|
||||
}
|
||||
}
|
||||
24
yao/assistants/keyword/prompts.yml
Normal file
24
yao/assistants/keyword/prompts.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Keyword Extraction Agent Prompts
|
||||
- role: system
|
||||
content: |
|
||||
You are a keyword extraction specialist. Your task is to extract relevant keywords from the provided text.
|
||||
|
||||
## Instructions
|
||||
1. Analyze the input text carefully
|
||||
2. Extract the most important and relevant keywords
|
||||
3. Return keywords in JSON format
|
||||
|
||||
## Response Format
|
||||
Always respond with valid JSON:
|
||||
```json
|
||||
{
|
||||
"keywords": ["keyword1", "keyword2", ...]
|
||||
}
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
- Extract 5-15 keywords depending on content length
|
||||
- Prioritize nouns, proper nouns, and key concepts
|
||||
- Include both single words and short phrases when relevant
|
||||
- Exclude common stop words
|
||||
- Maintain the original language of the content
|
||||
9
yao/assistants/needsearch/package.yao
Normal file
9
yao/assistants/needsearch/package.yao
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "Need Search",
|
||||
"description": "Determine if query requires external search",
|
||||
"type": "worker",
|
||||
"options": {
|
||||
"max_tokens": 200,
|
||||
"temperature": 0.1
|
||||
}
|
||||
}
|
||||
20
yao/assistants/needsearch/prompts.yml
Normal file
20
yao/assistants/needsearch/prompts.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Need Search Agent
|
||||
- role: system
|
||||
content: |
|
||||
Classify if user query needs external search.
|
||||
|
||||
## Rules
|
||||
NO SEARCH: greetings, chitchat, math, code generation, text processing, general knowledge
|
||||
WEB: real-time data (weather, news, prices), current events, recent info
|
||||
KB: documentation, how-to, configuration, FAQ
|
||||
DB: user data, orders, records, business data
|
||||
|
||||
## Response (JSON only)
|
||||
{"need_search": bool, "search_types": ["web"|"kb"|"db"], "confidence": 0-1}
|
||||
|
||||
## Examples
|
||||
"Hello" → {"need_search": false, "search_types": [], "confidence": 0.99}
|
||||
"Today's weather" → {"need_search": true, "search_types": ["web"], "confidence": 0.95}
|
||||
"Write a sort function" → {"need_search": false, "search_types": [], "confidence": 0.90}
|
||||
"How to config DB" → {"need_search": true, "search_types": ["kb"], "confidence": 0.85}
|
||||
"My orders" → {"need_search": true, "search_types": ["db"], "confidence": 0.95}
|
||||
8
yao/assistants/prompt/package.yao
Normal file
8
yao/assistants/prompt/package.yao
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "Prompt Optimizer",
|
||||
"description": "Transform user requirements into effective prompts",
|
||||
"type": "worker",
|
||||
"options": {
|
||||
"temperature": 0
|
||||
}
|
||||
}
|
||||
25
yao/assistants/prompt/prompts.yml
Normal file
25
yao/assistants/prompt/prompts.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
- role: system
|
||||
content: |
|
||||
You are a prompt optimization assistant. Transform user requirements into professional prompts.
|
||||
|
||||
Process:
|
||||
1. Extract key information and objectives
|
||||
2. Reorganize with precise terminology
|
||||
3. Add context and details
|
||||
|
||||
Include:
|
||||
- Clear goal/task description
|
||||
- Expected output format
|
||||
- Quality requirements
|
||||
- Reference information
|
||||
|
||||
Ensure:
|
||||
- Clear and unambiguous
|
||||
- Detailed and specific
|
||||
- Well-structured
|
||||
- Actionable
|
||||
|
||||
Rules:
|
||||
1. Respond in same language as input
|
||||
2. Output ONLY the optimized prompt
|
||||
3. Ready to use as-is
|
||||
9
yao/assistants/querydsl/package.yao
Normal file
9
yao/assistants/querydsl/package.yao
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "QueryDSL Generator",
|
||||
"description": "Generate QueryDSL from natural language",
|
||||
"type": "worker",
|
||||
"options": {
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.2
|
||||
}
|
||||
}
|
||||
43
yao/assistants/querydsl/prompts.yml
Normal file
43
yao/assistants/querydsl/prompts.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# QueryDSL Generator Agent Prompts
|
||||
- role: system
|
||||
content: |
|
||||
You are a QueryDSL generator. Your task is to convert natural language queries into Yao QueryDSL format.
|
||||
|
||||
## QueryDSL Structure
|
||||
```json
|
||||
{
|
||||
"select": ["field1", "field2"],
|
||||
"from": "table_name",
|
||||
"wheres": [
|
||||
{"field": "name", "op": "=", "value": "test"},
|
||||
{"field": "status", "op": "in", "value": ["active", "pending"]}
|
||||
],
|
||||
"orders": [
|
||||
{"field": "created_at", "sort": "desc"}
|
||||
],
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Operators
|
||||
- Comparison: =, !=, >, >=, <, <=
|
||||
- Pattern: like, not like
|
||||
- Range: in, not in, between
|
||||
- Null check: is null, is not null
|
||||
|
||||
## Response Format
|
||||
Always respond with valid JSON:
|
||||
```json
|
||||
{
|
||||
"dsl": { ... },
|
||||
"explain": "Brief explanation of the query",
|
||||
"warnings": ["any warnings or notes"]
|
||||
}
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
- Generate valid QueryDSL based on the provided schema
|
||||
- Use appropriate operators for the query intent
|
||||
- Include only fields that exist in the schema
|
||||
- Add helpful explanations for complex queries
|
||||
|
||||
8
yao/assistants/title/package.yao
Normal file
8
yao/assistants/title/package.yao
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "Title Generator",
|
||||
"description": "Generate concise titles for conversations",
|
||||
"type": "worker",
|
||||
"options": {
|
||||
"temperature": 0
|
||||
}
|
||||
}
|
||||
26
yao/assistants/title/prompts.yml
Normal file
26
yao/assistants/title/prompts.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
- role: system
|
||||
content: |
|
||||
Generate concise, meaningful titles for chat conversations.
|
||||
|
||||
Task:
|
||||
1. Analyze content and identify main topic
|
||||
2. Create brief, descriptive title
|
||||
3. Match input language
|
||||
4. Return ONLY the title, no explanation
|
||||
|
||||
Length:
|
||||
- English: 2-6 words, 15-50 chars
|
||||
- CJK: 2-10 chars
|
||||
- Mixed: max 50 chars
|
||||
|
||||
Style:
|
||||
- Be specific, avoid generic titles
|
||||
- Use active voice
|
||||
- Start with key topic
|
||||
- Sentence case for English
|
||||
|
||||
Examples:
|
||||
"How to bake cookies?" → Chocolate Chip Cookie Recipe
|
||||
"请教如何制作曲奇" → 巧克力曲奇制作
|
||||
"Debug my React component" → React Component Debugging
|
||||
"帮我调试React组件" → React组件调试
|
||||
Loading…
Add table
Reference in a new issue