init: 云枢·Agent 初始提交
This commit is contained in:
311
src/catalog.go
Normal file
311
src/catalog.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// YAML 目录结构
|
||||
// ============================================================
|
||||
|
||||
type ToolsCatalog struct {
|
||||
Version string `yaml:"version"`
|
||||
Auto CatalogSection `yaml:"auto"`
|
||||
Manual CatalogSection `yaml:"manual,omitempty"`
|
||||
}
|
||||
|
||||
type CatalogSection struct {
|
||||
Tools []CatalogTool `yaml:"tools"`
|
||||
Skills []CatalogSkill `yaml:"skills"`
|
||||
Agents []CatalogAgent `yaml:"agents"`
|
||||
}
|
||||
|
||||
type CatalogTool struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
Status string `yaml:"status"`
|
||||
Description string `yaml:"description"`
|
||||
Parameters map[string]ParameterField `yaml:"parameters"`
|
||||
Returns string `yaml:"returns"`
|
||||
Source string `yaml:"source"`
|
||||
}
|
||||
|
||||
type ParameterField struct {
|
||||
Type string `yaml:"type"`
|
||||
Required bool `yaml:"required"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
type CatalogSkill struct {
|
||||
Name string `yaml:"name"`
|
||||
Path string `yaml:"path"`
|
||||
Description string `yaml:"description"`
|
||||
Status string `yaml:"status"`
|
||||
}
|
||||
|
||||
type CatalogAgent struct {
|
||||
Name string `yaml:"name"`
|
||||
Path string `yaml:"path"`
|
||||
Description string `yaml:"description"`
|
||||
Tools []string `yaml:"tools"`
|
||||
Status string `yaml:"status"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 构建目录
|
||||
// ============================================================
|
||||
|
||||
func BuildCatalog() *ToolsCatalog {
|
||||
return &ToolsCatalog{
|
||||
Version: "1.0",
|
||||
Auto: CatalogSection{
|
||||
Tools: buildToolList(),
|
||||
Skills: scanSkills(),
|
||||
Agents: scanAgents(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildToolList() []CatalogTool {
|
||||
tools := ListRegisteredTools()
|
||||
list := make([]CatalogTool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
ct := CatalogTool{
|
||||
Name: t.Name,
|
||||
Type: "builtin",
|
||||
Status: "active",
|
||||
Description: t.Description,
|
||||
Source: "src/tool.go",
|
||||
}
|
||||
|
||||
// 从 JSON Schema 提取参数
|
||||
ct.Parameters = make(map[string]ParameterField)
|
||||
for name, prop := range t.Parameters.Properties {
|
||||
required := false
|
||||
for _, r := range t.Parameters.Required {
|
||||
if r == name {
|
||||
required = true
|
||||
break
|
||||
}
|
||||
}
|
||||
ct.Parameters[name] = ParameterField{
|
||||
Type: prop.Type,
|
||||
Required: required,
|
||||
Description: prop.Description,
|
||||
}
|
||||
}
|
||||
list = append(list, ct)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func scanSkills() []CatalogSkill {
|
||||
// 搜索路径:项目目录 → 用户配置目录
|
||||
dirs := []string{
|
||||
"skills",
|
||||
filepath.Join(ConfigDir(), "skills"),
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var list []CatalogSkill
|
||||
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
seen[e.Name()] = true
|
||||
|
||||
// 尝试读取 SKILL.md 获取描述
|
||||
skillPath := filepath.Join(dir, e.Name(), "SKILL.md")
|
||||
desc := fmt.Sprintf("skill: %s", e.Name())
|
||||
|
||||
if data, err := os.ReadFile(skillPath); err == nil {
|
||||
content := string(data)
|
||||
if fm, _, err := parseFrontmatterSimple(content); err == nil {
|
||||
if d, ok := fm["description"]; ok {
|
||||
desc = fmt.Sprintf("%v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list = append(list, CatalogSkill{
|
||||
Name: e.Name(),
|
||||
Path: fmt.Sprintf("skills/%s/SKILL.md", e.Name()),
|
||||
Description: desc,
|
||||
Status: "active",
|
||||
})
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func scanAgents() []CatalogAgent {
|
||||
dirs := []string{
|
||||
"agents",
|
||||
filepath.Join(ConfigDir(), "agents"),
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var list []CatalogAgent
|
||||
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
seen[e.Name()] = true
|
||||
|
||||
agentPath := filepath.Join(dir, e.Name())
|
||||
data, err := os.ReadFile(agentPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fm, _, err := parseFrontmatterSimple(string(data))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
cat := CatalogAgent{
|
||||
Name: fmt.Sprintf("%v", fm["name"]),
|
||||
Path: fmt.Sprintf("agents/%s", e.Name()),
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if d, ok := fm["description"]; ok {
|
||||
cat.Description = fmt.Sprintf("%v", d)
|
||||
}
|
||||
if tools, ok := fm["tools"]; ok {
|
||||
if list, ok := tools.([]interface{}); ok {
|
||||
for _, t := range list {
|
||||
cat.Tools = append(cat.Tools, fmt.Sprintf("%v", t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list = append(list, cat)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 写入 tools.yml
|
||||
// ============================================================
|
||||
|
||||
func GenerateToolsYAML() {
|
||||
catalog := BuildCatalog()
|
||||
|
||||
// 尝试读取已有的 manual 节,保留用户编辑
|
||||
existingPath := filepath.Join(ConfigDir(), "tools.yml")
|
||||
if data, err := os.ReadFile(existingPath); err == nil {
|
||||
var existing ToolsCatalog
|
||||
if err := yaml.Unmarshal(data, &existing); err == nil {
|
||||
catalog.Manual = existing.Manual
|
||||
}
|
||||
}
|
||||
|
||||
dir := ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 用 2 空格缩进编码
|
||||
var buf bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buf)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(catalog); err != nil {
|
||||
return
|
||||
}
|
||||
encoder.Close()
|
||||
|
||||
// 在文件头加注释
|
||||
header := "# ⚠️ auto 节由云枢 Agent 自动生成,每次启动覆写;manual 节保留用户自定义\n"
|
||||
yamlPath := filepath.Join(dir, "tools.yml")
|
||||
os.WriteFile(yamlPath, []byte(header+buf.String()), 0644)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 注入目录到 system prompt
|
||||
// ============================================================
|
||||
|
||||
// BuildInjectPrompt 生成能力边界目录,追加到 system prompt 末尾
|
||||
func BuildInjectPrompt(toolNames []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n## 能力边界\n\n")
|
||||
|
||||
// 工具
|
||||
allTools := ListRegisteredTools()
|
||||
b.WriteString("### 可用工具\n")
|
||||
for _, t := range allTools {
|
||||
available := false
|
||||
for _, name := range toolNames {
|
||||
if t.Name == name {
|
||||
available = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if available {
|
||||
b.WriteString(fmt.Sprintf("- %s: %s\n", t.Name, t.Description))
|
||||
}
|
||||
}
|
||||
|
||||
// skill
|
||||
skills := scanSkills()
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n### 可用技能\n")
|
||||
for _, s := range skills {
|
||||
b.WriteString(fmt.Sprintf("- %s: %s\n", s.Name, s.Description))
|
||||
}
|
||||
}
|
||||
|
||||
// 数据文件
|
||||
b.WriteString("\n### 数据文件\n")
|
||||
b.WriteString("- data/tools.yml: 完整工具/技能/Agent 目录(可读此文件获取详细参数)\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助:简易 frontmatter 解析(只读 YAML map)
|
||||
// ============================================================
|
||||
|
||||
func parseFrontmatterSimple(content string) (map[string]interface{}, string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return nil, content, nil
|
||||
}
|
||||
endIdx := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "---" {
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if endIdx == -1 {
|
||||
return nil, content, nil
|
||||
}
|
||||
frontmatter := strings.Join(lines[1:endIdx], "\n")
|
||||
body := strings.Join(lines[endIdx+1:], "\n")
|
||||
|
||||
var fm map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), &fm); err != nil {
|
||||
return nil, body, nil
|
||||
}
|
||||
return fm, body, nil
|
||||
}
|
||||
157
src/config.go
Normal file
157
src/config.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config 存储云枢 Agent 的所有配置
|
||||
type Config struct {
|
||||
LLM LLMConfig `yaml:"llm"`
|
||||
}
|
||||
|
||||
// LLMConfig 大模型连接配置
|
||||
type LLMConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Model string `yaml:"model"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
// ConfigDir 返回全局配置目录路径 ~/.config/yunshu/
|
||||
func ConfigDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "./.config/yunshu"
|
||||
}
|
||||
return filepath.Join(home, ".config", "yunshu")
|
||||
}
|
||||
|
||||
// oldConfigDir 旧版本配置目录,用于迁移
|
||||
func oldConfigDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "weather-cli")
|
||||
}
|
||||
|
||||
// migrateOldConfig 从旧目录 weather-cli 迁移配置到 yunshu
|
||||
func migrateOldConfig() {
|
||||
old := oldConfigDir()
|
||||
if old == "" {
|
||||
return
|
||||
}
|
||||
// 旧目录不存在,无需迁移
|
||||
if _, err := os.Stat(old); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
// 新目录已存在,说明已迁移过
|
||||
new := ConfigDir()
|
||||
if _, err := os.Stat(new); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 复制旧目录到新目录
|
||||
copyDirRecursive(old, new)
|
||||
os.RemoveAll(old)
|
||||
}
|
||||
|
||||
// LoadConfig 从 ~/.config/yunshu/config.yaml 读取配置
|
||||
// 如果新目录不存在,自动从旧 weather-cli 目录迁移
|
||||
func LoadConfig() (*Config, error) {
|
||||
// 尝试自动迁移旧配置
|
||||
migrateOldConfig()
|
||||
|
||||
path := filepath.Join(ConfigDir(), "config.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig 将配置写入 ~/.config/yunshu/config.yaml
|
||||
func SaveConfig(cfg *Config) error {
|
||||
dir := ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 0600 - 仅所有者可读写,保护 API Key
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// SearchFile 在项目目录和全局配置目录中搜索文件
|
||||
// 项目目录优先,全局配置目录作为后备
|
||||
func SearchFile(relativePath string) string {
|
||||
paths := []string{
|
||||
relativePath, // 项目目录
|
||||
filepath.Join(ConfigDir(), relativePath), // 全局配置目录
|
||||
}
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// CopyDefaultDir 将项目目录下的默认文件复制到全局配置目录
|
||||
func CopyDefaultDir(srcDir, dstDir string) {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dstBase := filepath.Join(ConfigDir(), dstDir)
|
||||
for _, e := range entries {
|
||||
srcPath := filepath.Join(srcDir, e.Name())
|
||||
dstPath := filepath.Join(dstBase, e.Name())
|
||||
|
||||
if e.IsDir() {
|
||||
os.MkdirAll(dstPath, 0755)
|
||||
copyDirRecursive(srcPath, dstPath)
|
||||
} else {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(dstPath), 0755)
|
||||
os.WriteFile(dstPath, data, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyDirRecursive(src, dst string) {
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.MkdirAll(dst, 0755)
|
||||
for _, e := range entries {
|
||||
srcPath := filepath.Join(src, e.Name())
|
||||
dstPath := filepath.Join(dst, e.Name())
|
||||
if e.IsDir() {
|
||||
copyDirRecursive(srcPath, dstPath)
|
||||
} else {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
os.WriteFile(dstPath, data, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/llm.go
Normal file
130
src/llm.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
llmHost = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
llmModel = "doubao-seed-2-0-pro-260215"
|
||||
llmKey = ""
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 1. 从配置文件加载
|
||||
cfg, err := LoadConfig()
|
||||
if err == nil {
|
||||
if cfg.LLM.Host != "" {
|
||||
llmHost = cfg.LLM.Host
|
||||
}
|
||||
if cfg.LLM.Model != "" {
|
||||
llmModel = cfg.LLM.Model
|
||||
}
|
||||
if cfg.LLM.Key != "" {
|
||||
llmKey = cfg.LLM.Key
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 环境变量覆盖配置文件(优先级最高)
|
||||
if v := os.Getenv("LLM_ENDPOINT"); v != "" {
|
||||
llmHost = v
|
||||
}
|
||||
if v := os.Getenv("LLM_MODEL"); v != "" {
|
||||
llmModel = v
|
||||
}
|
||||
if v := os.Getenv("LLM_API_KEY"); v != "" {
|
||||
llmKey = v
|
||||
}
|
||||
// 兼容旧环境变量名
|
||||
if v := os.Getenv("OPENAI_API_KEY"); v != "" && llmKey == "" {
|
||||
llmKey = v
|
||||
}
|
||||
}
|
||||
|
||||
// GetLLMKey 获取 API Key,优先使用已加载的密钥
|
||||
func GetLLMKey() (string, error) {
|
||||
if llmKey == "" {
|
||||
return "", fmt.Errorf("未配置 API Key。请运行 'weather-cia onboard' 初始化,或设置 LLM_API_KEY 环境变量")
|
||||
}
|
||||
return llmKey, nil
|
||||
}
|
||||
|
||||
// CallLLM 调用大模型 API(兼容 OpenAI Chat Completion 格式)
|
||||
func CallLLM(messages []Message, toolDefs []ToolDef) (*OpenAIResponse, error) {
|
||||
apiKey, err := GetLLMKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"model": llmModel,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
// 注册工具定义
|
||||
if len(toolDefs) > 0 {
|
||||
tools := make([]OpenAITool, 0, len(toolDefs))
|
||||
for _, td := range toolDefs {
|
||||
tools = append(tools, OpenAITool{
|
||||
Type: "function",
|
||||
Function: OpenAIToolFunc{
|
||||
Name: td.Name,
|
||||
Description: td.Description,
|
||||
Parameters: td.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
reqBody["tools"] = tools
|
||||
reqBody["tool_choice"] = "auto"
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", llmHost, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求 LLM 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
var errResp OpenAIErrorResponse
|
||||
if json.Unmarshal(respData, &errResp) == nil && errResp.Error.Message != "" {
|
||||
return nil, fmt.Errorf("LLM API 错误 [%s]: %s", errResp.Error.Type, errResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("LLM API 返回 HTTP %d: %s", resp.StatusCode, string(respData))
|
||||
}
|
||||
|
||||
var result OpenAIResponse
|
||||
if err := json.Unmarshal(respData, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 {
|
||||
return nil, fmt.Errorf("LLM 返回空结果")
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
76
src/loader.go
Normal file
76
src/loader.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadAgent 从 .md 文件加载 agent 定义
|
||||
// 搜索结果:项目目录 → 全局配置目录
|
||||
func LoadAgent(name string) (*AgentDef, error) {
|
||||
path := SearchFile(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 agent 文件失败 (搜索路径: %s): %w", name, err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
frontmatter, body, err := parseFrontmatter(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
def := &AgentDef{SystemPrompt: strings.TrimSpace(body)}
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), def); err != nil {
|
||||
return nil, fmt.Errorf("解析 frontmatter 失败: %w", err)
|
||||
}
|
||||
|
||||
if def.Name == "" {
|
||||
return nil, fmt.Errorf("agent 定义缺少 name 字段")
|
||||
}
|
||||
|
||||
// 注入能力边界目录到 system prompt
|
||||
def.SystemPrompt += BuildInjectPrompt(def.Tools)
|
||||
|
||||
return def, nil
|
||||
}
|
||||
|
||||
func parseFrontmatter(content string) (string, string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return "", content, nil
|
||||
}
|
||||
|
||||
endIdx := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "---" {
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if endIdx == -1 {
|
||||
return "", content, nil
|
||||
}
|
||||
|
||||
frontmatter := strings.Join(lines[1:endIdx], "\n")
|
||||
body := strings.Join(lines[endIdx+1:], "\n")
|
||||
return frontmatter, body, nil
|
||||
}
|
||||
|
||||
// LoadSkill 按名称加载 skill 知识内容
|
||||
// 搜索结果:skills/<name>/SKILL.md → ~/.config/weather-cli/skills/<name>/SKILL.md
|
||||
func LoadSkill(name string) (string, error) {
|
||||
relativePath := fmt.Sprintf("skills/%s/SKILL.md", name)
|
||||
path := SearchFile(relativePath)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("skill %q 未找到", name)
|
||||
}
|
||||
|
||||
_, body, _ := parseFrontmatter(string(data))
|
||||
return strings.TrimSpace(body), nil
|
||||
}
|
||||
85
src/main.go
Normal file
85
src/main.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Windows 控制台 UTF-8 编码
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
setConsoleCP := kernel32.NewProc("SetConsoleOutputCP")
|
||||
setConsoleCP.Call(65001)
|
||||
}
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
// onboard 子命令:交互式初始化
|
||||
if len(args) > 0 && args[0] == "onboard" {
|
||||
runOnboard()
|
||||
return
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ 未找到配置文件。请先运行:\n")
|
||||
fmt.Fprintf(os.Stderr, " yunshu onboard\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 配置已通过 init() 加载到 llmHost/llmModel/llmKey 中
|
||||
_ = cfg
|
||||
|
||||
// 生成工具目录(启动时覆写 auto 节,保留 manual 节)
|
||||
GenerateToolsYAML()
|
||||
|
||||
// 查找并加载 agent 定义
|
||||
agentPath := SearchFile("agents/weather-agent.md")
|
||||
def, err := LoadAgent(agentPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "加载 agent 失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 单次查询模式
|
||||
if len(args) > 0 {
|
||||
ClearSession()
|
||||
query := strings.Join(args, " ")
|
||||
if err := RunAgent(def, query); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 交互模式
|
||||
fmt.Println("☁️ 云枢 Agent — 天气情报官,输入 exit 退出")
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
ClearSession()
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for {
|
||||
fmt.Print("> ")
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
input := strings.TrimSpace(scanner.Text())
|
||||
if input == "" {
|
||||
continue
|
||||
}
|
||||
if input == "exit" || input == "quit" {
|
||||
fmt.Println("再见!")
|
||||
break
|
||||
}
|
||||
|
||||
if err := RunAgent(def, input); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
|
||||
}
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
}
|
||||
}
|
||||
69
src/onboard.go
Normal file
69
src/onboard.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runOnboard 交互式初始化向导
|
||||
func runOnboard() {
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
fmt.Println(" ☁️ 云枢 Agent 初始化配置")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
// 读取 LLM 地址
|
||||
defaultHost := "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
fmt.Printf("\nLLM 接口地址\n 默认: %s\n > ", defaultHost)
|
||||
host, _ := reader.ReadString('\n')
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
host = defaultHost
|
||||
}
|
||||
|
||||
// 读取 API Key
|
||||
fmt.Print("API Key\n > ")
|
||||
key, _ := reader.ReadString('\n')
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
// 读取模型名称
|
||||
defaultModel := "doubao-seed-2-0-pro-260215"
|
||||
fmt.Printf("模型名称\n 默认: %s\n > ", defaultModel)
|
||||
model, _ := reader.ReadString('\n')
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
model = defaultModel
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
cfg := &Config{
|
||||
LLM: LLMConfig{
|
||||
Host: host,
|
||||
Model: model,
|
||||
Key: key,
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveConfig(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "保存配置失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 复制默认 agents / skills / data 到全局配置目录
|
||||
CopyDefaultDir("agents", "agents")
|
||||
CopyDefaultDir("skills", "skills")
|
||||
CopyDefaultDir("data", "data")
|
||||
|
||||
configPath := filepath.Join(ConfigDir(), "config.yaml")
|
||||
fmt.Printf("\n✅ 配置完成!\n")
|
||||
fmt.Printf(" 配置文件: %s\n", configPath)
|
||||
fmt.Printf(" Agent 目录: %s\n", filepath.Join(ConfigDir(), "agents"))
|
||||
fmt.Println()
|
||||
fmt.Println("运行示例:")
|
||||
fmt.Println(" yunshu \"北京今天天气\"")
|
||||
fmt.Println(" yunshu")
|
||||
}
|
||||
101
src/runtime.go
Normal file
101
src/runtime.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func sessionPath() string {
|
||||
return filepath.Join(ConfigDir(), "session.json")
|
||||
}
|
||||
|
||||
func ClearSession() {
|
||||
os.Remove(sessionPath())
|
||||
}
|
||||
|
||||
func LoadSession() []Message {
|
||||
data, err := os.ReadFile(sessionPath())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []Message
|
||||
if err := json.Unmarshal(data, &messages); err != nil {
|
||||
return nil
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func AppendToSession(msg Message) {
|
||||
|
||||
messages := LoadSession()
|
||||
messages = append(messages, msg)
|
||||
|
||||
data, err := json.MarshalIndent(messages, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.WriteFile(sessionPath(), data, 0644)
|
||||
}
|
||||
|
||||
func RunAgent(def *AgentDef, userInput string) error {
|
||||
messages := LoadSession()
|
||||
|
||||
fullMessages := []Message{
|
||||
{Role: RoleSystem, Content: def.SystemPrompt},
|
||||
}
|
||||
fullMessages = append(fullMessages, messages...)
|
||||
fullMessages = append(fullMessages, Message{Role: RoleUser, Content: userInput})
|
||||
|
||||
AppendToSession(Message{Role: RoleUser, Content: userInput})
|
||||
|
||||
toolDefs := GetToolDefs(def.Tools)
|
||||
|
||||
for {
|
||||
resp, err := CallLLM(fullMessages, toolDefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
assistantMsg := Message{
|
||||
Role: RoleAssistant,
|
||||
ToolCalls: choice.Message.ToolCalls,
|
||||
}
|
||||
fullMessages = append(fullMessages, assistantMsg)
|
||||
AppendToSession(assistantMsg)
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
result, err := ExecuteTool(tc)
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("工具执行错误: %v", err)
|
||||
}
|
||||
toolMsg := Message{
|
||||
Role: RoleTool,
|
||||
Content: result,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
fullMessages = append(fullMessages, toolMsg)
|
||||
AppendToSession(toolMsg)
|
||||
}
|
||||
} else {
|
||||
content := ""
|
||||
if choice.Message.Content != nil {
|
||||
content = *choice.Message.Content
|
||||
}
|
||||
assistantMsg := Message{
|
||||
Role: RoleAssistant,
|
||||
Content: content,
|
||||
}
|
||||
fullMessages = append(fullMessages, assistantMsg)
|
||||
AppendToSession(assistantMsg)
|
||||
|
||||
fmt.Println(content)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
224
src/tool.go
Normal file
224
src/tool.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var registeredTools = make(map[string]*ToolDef)
|
||||
|
||||
func RegisterTool(td *ToolDef) {
|
||||
registeredTools[td.Name] = td
|
||||
}
|
||||
|
||||
func ListRegisteredTools() []*ToolDef {
|
||||
list := make([]*ToolDef, 0, len(registeredTools))
|
||||
for _, td := range registeredTools {
|
||||
list = append(list, td)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func GetToolDefs(names []string) []ToolDef {
|
||||
defs := make([]ToolDef, 0, len(names))
|
||||
for _, name := range names {
|
||||
if td, ok := registeredTools[name]; ok {
|
||||
defs = append(defs, *td)
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func ExecuteTool(tc ToolCall) (string, error) {
|
||||
td, ok := registeredTools[tc.Function.Name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("未知工具: %s", tc.Function.Name)
|
||||
}
|
||||
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
return "", fmt.Errorf("解析工具参数失败: %w", err)
|
||||
}
|
||||
|
||||
return td.Execute(args)
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "http-get",
|
||||
Description: "发送 HTTP GET 请求获取数据",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"url": {Type: "string", Description: "请求的完整 URL 地址"},
|
||||
"headers": {Type: "string", Description: "请求头,JSON 格式的键值对字符串,如 {\"User-Agent\": \"...\"}"},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
url, _ := args["url"].(string)
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("缺少 url 参数")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
if headersStr, ok := args["headers"].(string); ok && headersStr != "" {
|
||||
var headers map[string]string
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err == nil {
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), nil
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
},
|
||||
})
|
||||
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "skill",
|
||||
Description: "加载指定名称的 Skill 知识内容到当前上下文,获取专业知识",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"name": {Type: "string", Description: "Skill 名称,如 msn-weather-api"},
|
||||
},
|
||||
Required: []string{"name"},
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("缺少 name 参数")
|
||||
}
|
||||
return LoadSkill(name)
|
||||
},
|
||||
})
|
||||
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "read-file",
|
||||
Description: "读取本地文件内容",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"path": {Type: "string", Description: "文件路径,相对于项目根目录"},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("缺少 path 参数")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
},
|
||||
})
|
||||
|
||||
RegisterTool(&ToolDef{
|
||||
Name: "geocode",
|
||||
Description: "查询城市或地点的经纬度坐标,返回 lat/lon/name/country。支持中文城市名(如 北京、上海、成都)和英文名",
|
||||
Parameters: ToolParameter{
|
||||
Type: "object",
|
||||
Properties: map[string]ToolProperty{
|
||||
"city": {Type: "string", Description: "城市名称,支持中文(如 北京)或英文(如 Beijing)"},
|
||||
},
|
||||
Required: []string{"city"},
|
||||
},
|
||||
Execute: func(args map[string]interface{}) (string, error) {
|
||||
city, _ := args["city"].(string)
|
||||
if city == "" {
|
||||
return "", fmt.Errorf("缺少 city 参数")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://wttr.in/%s?format=j1", city)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求 wttr.in 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("wttr.in 返回 HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
NearestArea []struct {
|
||||
AreaName []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"areaName"`
|
||||
Country []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"country"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
Population string `json:"population"`
|
||||
} `json:"nearest_area"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析 wttr.in 响应失败: %w", err)
|
||||
}
|
||||
|
||||
if len(result.NearestArea) == 0 {
|
||||
return "", fmt.Errorf("未找到城市: %s", city)
|
||||
}
|
||||
|
||||
area := result.NearestArea[0]
|
||||
name := ""
|
||||
if len(area.AreaName) > 0 {
|
||||
name = area.AreaName[0].Value
|
||||
}
|
||||
country := ""
|
||||
if len(area.Country) > 0 {
|
||||
country = area.Country[0].Value
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"lat": area.Latitude,
|
||||
"lon": area.Longitude,
|
||||
"name": name,
|
||||
"country": country,
|
||||
})
|
||||
return string(out), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
101
src/types.go
Normal file
101
src/types.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
type MessageRole string
|
||||
|
||||
const (
|
||||
RoleSystem MessageRole = "system"
|
||||
RoleUser MessageRole = "user"
|
||||
RoleAssistant MessageRole = "assistant"
|
||||
RoleTool MessageRole = "tool"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role MessageRole `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function ToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
type ToolCallFunction struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type AgentDef struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tools []string `yaml:"tools"`
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
type ToolParameter struct {
|
||||
Type string `json:"type"`
|
||||
Properties map[string]ToolProperty `json:"properties"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
type ToolProperty struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ToolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters ToolParameter
|
||||
Execute func(args map[string]interface{}) (string, error)
|
||||
}
|
||||
|
||||
type OpenAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content *string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message OpenAIMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type OpenAIUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type OpenAIResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIChoice `json:"choices"`
|
||||
Usage OpenAIUsage `json:"usage"`
|
||||
}
|
||||
|
||||
type OpenAIErrorResponse struct {
|
||||
Error OpenAIError `json:"error"`
|
||||
}
|
||||
|
||||
type OpenAIError struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type OpenAITool struct {
|
||||
Type string `json:"type"`
|
||||
Function OpenAIToolFunc `json:"function"`
|
||||
}
|
||||
|
||||
type OpenAIToolFunc struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters interface{} `json:"parameters"`
|
||||
}
|
||||
Reference in New Issue
Block a user