commit a6025c699fc8b22ead4ddd9fbed4a2ff5942de3e Author: titor Date: Fri May 8 10:12:31 2026 +0800 init: 云枢·Agent 初始提交 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5c0f1a8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "markdown.validate.enabled": true +} \ No newline at end of file diff --git a/agents/weather-agent.md b/agents/weather-agent.md new file mode 100644 index 0000000..57ce99a --- /dev/null +++ b/agents/weather-agent.md @@ -0,0 +1,34 @@ +--- +name: weather-agent +description: 天气情报官 - 查询实时天气和未来预报 +tools: + - http-get + - geocode + - skill + - read-file +--- + +# 天气情报官 + +你是专业的天气情报官,职责是回答用户关于天气的所有问题。 + +## 工作流程 + +1. **识别城市** — 从用户输入中提取城市名 +2. **获取坐标** — 调用 `geocode` 工具获取城市经纬度 +3. **加载 API 知识** — 调用 `skill("msn-weather-api")` 获取 MSN 天气 API 的请求参数 +4. **请求数据** — 用获取到的坐标和 API 参数,通过 `http-get` 请求天气数据 +5. **分析回答** — 解析 JSON 并给出清晰、有用的回答 + +## 追问处理 + +- 如果用户追问(如"适合穿什么?""风大不大?"),优先基于已有数据回答,无需重复 API 调用 +- 如果用户问另一个城市,重新执行完整流程 +- 如果数据明显过时(超过 2 小时),重新请求 + +## 输出规范 + +回答要清晰友好,包含关键信息: +- 当前温度、体感温度、天气状况 +- 湿度、风速、空气质量 +- 根据天气给出实用建议(如"建议带伞""适合户外"等) diff --git a/data/cities.json b/data/cities.json new file mode 100644 index 0000000..aacc561 --- /dev/null +++ b/data/cities.json @@ -0,0 +1,43 @@ +{ + "北京": {"lat": 39.9042, "lon": 116.4074}, + "上海": {"lat": 31.2304, "lon": 121.4737}, + "天津": {"lat": 39.3434, "lon": 117.3616}, + "重庆": {"lat": 29.4316, "lon": 106.9123}, + "广州": {"lat": 23.1291, "lon": 113.2644}, + "深圳": {"lat": 22.5431, "lon": 114.0579}, + "成都": {"lat": 30.5728, "lon": 104.0668}, + "杭州": {"lat": 30.2741, "lon": 120.1551}, + "武汉": {"lat": 30.5928, "lon": 114.3055}, + "南京": {"lat": 32.0603, "lon": 118.7969}, + "西安": {"lat": 34.3416, "lon": 108.9398}, + "长沙": {"lat": 28.2282, "lon": 112.9388}, + "郑州": {"lat": 34.7466, "lon": 113.6253}, + "沈阳": {"lat": 41.8057, "lon": 123.4315}, + "青岛": {"lat": 36.0671, "lon": 120.3826}, + "大连": {"lat": 38.9140, "lon": 121.6147}, + "厦门": {"lat": 24.4798, "lon": 118.0894}, + "苏州": {"lat": 31.2990, "lon": 120.5853}, + "宁波": {"lat": 29.8683, "lon": 121.5440}, + "合肥": {"lat": 31.8206, "lon": 117.2272}, + "福州": {"lat": 26.0745, "lon": 119.2965}, + "济南": {"lat": 36.6512, "lon": 116.9972}, + "昆明": {"lat": 25.0389, "lon": 102.7183}, + "贵阳": {"lat": 26.6470, "lon": 106.6302}, + "南宁": {"lat": 22.8170, "lon": 108.3665}, + "海口": {"lat": 20.0440, "lon": 110.1999}, + "三亚": {"lat": 18.2528, "lon": 109.5120}, + "拉萨": {"lat": 29.6500, "lon": 91.1000}, + "乌鲁木齐": {"lat": 43.8256, "lon": 87.6168}, + "兰州": {"lat": 36.0611, "lon": 103.8343}, + "西宁": {"lat": 36.6232, "lon": 101.7622}, + "银川": {"lat": 38.4872, "lon": 106.2309}, + "呼和浩特": {"lat": 40.8422, "lon": 111.7498}, + "太原": {"lat": 37.8706, "lon": 112.5489}, + "石家庄": {"lat": 38.0428, "lon": 114.5149}, + "哈尔滨": {"lat": 45.8038, "lon": 126.5350}, + "长春": {"lat": 43.8171, "lon": 125.3235}, + "南昌": {"lat": 28.6829, "lon": 115.8582}, + "珠海": {"lat": 22.2710, "lon": 113.5767}, + "东莞": {"lat": 23.0208, "lon": 113.7518}, + "佛山": {"lat": 23.0219, "lon": 113.1214} +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..64b2a0e --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,117 @@ +# 编码规范 + +## 通用规范 + +- 全程使用中文书写注释、文档和沟通 +- 所有代码必须包含详细的中文注释,说明函数功能、参数含义、关键逻辑 +- Markdown 文件使用 `#` 标题层级,保持结构清晰 +- 变量命名采用驼峰式,类型和函数首字母大写导出 +- 同一个问题连续工作 3 次没有结论,立即退出并询问用户接下来怎么做 + +## Go 代码规范 + +- 使用 `package main` 扁平结构(MVP 阶段),后续可拆分子包 +- 错误处理:所有可能失败的操作必须检查 error +- 错误信息使用中文描述 +- 导出函数(首字母大写)供包外调用,非导出函数(首字母小写)为内部实现 +- HTTP 客户端设置超时(默认 15s),避免资源泄漏 +- JSON 序列化/反序列化使用 `encoding/json` 标准库 + +## Agent 定义规范(.md 文件) + +- 必须包含 YAML frontmatter(以 `---` 包裹) +- frontmatter 必需字段:`name`, `description`, `tools` +- tools 为数组,声明 agent 需要的工具名(在 tool.go 中注册) +- body 为 system prompt,**只定义行为逻辑**(角色、工作流程、输出规范) +- **关键技术细节(URL、apiKey、请求头、JSON 路径等)不要 inline 在 agent skill 中**,改为: + - 放到 `skills/*/SKILL.md` 中,由 agent 调用 `skill("name")` 按需加载 + - 或注册为 tool(确定性操作),由 agent 声明 tools 即可调用 +- session 文件存在 `~/.config/weather-cli/session.json`,不污染项目目录 + +### 示例 + +```markdown +--- +name: weather-agent +description: 天气情报官 +tools: + - http-get + - geocode + - skill +--- + +# 天气情报官 +你是专业的天气情报官。 + +## 工作流程 +1. 识别城市 → 调用 geocode 获取坐标 +2. 调用 skill("msn-weather-api") 获取 API 参数 +3. 调用 http-get 请求天气数据 +4. 分析并输出 +``` + +## Session 规范 + +- 文件路径:`~/.config/yunshu/session.json` +- 格式:JSON 数组,元素为 Message 对象(兼容 OpenAI Chat Completion messages 格式) +- 角色类型:`system`, `user`, `assistant`, `tool` +- 启动时清空,每轮对话追加 +- 消息顺序即对话顺序 +- 放在用户配置目录而非项目目录,确保不同目录下运行时上下文连贯 + +## 工具注册规范 + +- 工具在 `tool.go` 的 `init()` 中通过 `RegisterTool()` 注册 +- 每个工具定义:Name, Description, Parameters(JSON Schema), Execute 函数 +- 工具名与 `.md` 文件中声明的 tools 列表对应 +- Execute 函数接收 `map[string]interface{}` 参数,返回 string 和 error + +## 环境变量 + +| 变量名 | 必需 | 说明 | +|--------|------|------| +| `LLM_API_KEY` | 否* | API Key,覆盖配置文件 | +| `LLM_ENDPOINT` | 否 | API 端点,覆盖配置文件 | +| `LLM_MODEL` | 否 | 模型名,覆盖配置文件 | +| `OPENAI_API_KEY` | 否 | 兼容旧名,当 `LLM_API_KEY` 未设置时生效 | + +> *注:可在 `~/.config/yunshu/config.yaml` 中配置,无需环境变量。 +> 首次使用请运行 `yunshu onboard` 交互式初始化。 + +--- + +## 【认知修正】 + +> 本字段存放开发过程中验证后的知识点、踩坑记录。以陈述句形式记录。 + +### 2026-05-07 + +1. **MSN 天气 API 属于非公开内部接口**,apiKey 固定为 `j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo`,修改任意字符即 401。必须携带 `User-Agent` 和 `Referer` 请求头,否则返回 401。响应数据在 `value[0].responses[0].weather[0]` 路径下。 + +2. **Go 的 `syscall` 包是标准库,无需额外依赖**。在 Windows 上可通过 `kernel32.SetConsoleOutputCP(65001)` 设置控制台 UTF-8 编码,但 PowerShell 5.1 有独立的 `[Console]::OutputEncoding` 覆盖此设置,需要额外 `[Console]::OutputEncoding = [Text.Encoding]::UTF8`。 + +3. **豆包(火山引擎)API 兼容 OpenAI Chat Completion 格式**,包括 function calling(tool_calls)。修改 `endpoint` 和 `model` 即可切换,无需改动代码逻辑。实测 `doubao-seed-2-0-pro-260215` 支持工具调用正常。 + +4. **非流式调用更简单可靠**。对于 CLI 工具,等待完整响应再输出比流式逐 token 输出实现更简单,且用户能一次获取完整信息。 + +5. **Session 文件的关键设计**:session 存储的是完整的对话消息列表(不含 system prompt),格式与 OpenAI Chat Completion API 的 messages 数组一致。这意味着 runtime 不需要做任何格式转换,读 session → 直接 POST 给 LLM → 拿到回复 → 追加到 session。 + +6. **Go 的 `gopkg.in/yaml.v3` 依赖可能遇到 GOSUMDB 问题**。在中国网络环境下,需要设置 `GONOSUMCHECK='*'` 和 `GONOSUMDB='*'` 环境变量来绕过 checksum 数据库验证。 + +7. **工具定义要提供清晰的 JSON Schema 参数描述**。LLM 通过参数描述来理解如何调用工具。描述越清晰,LLM 生成正确参数的概率越高。`http-get` 工具的 `headers` 参数设计为 JSON 字符串格式,比结构化对象更灵活。 + +8. **Go 中处理 OpenAI 响应的 Content 字段要使用指针类型**。当 LLM 返回 tool_calls 时,content 字段为 null(JSON 中的 null),而非空字符串。使用 `*string` 才能区分"内容为空"和"无内容"两种情况。 + +9. **配置文件放在 `~/.config/yunshu/config.yaml` 而非 .env/.secret**。YAML 格式与 agent 定义风格一致,统一管理。API Key 用 `0600` 权限保护。优先顺序:环境变量 > 配置文件 > 默认值。`onboard` 子命令提供交互式初始化体验。 + +10. **双路径搜索机制**:项目目录优先,`~/.config/yunshu/` 后备。这使得开发时用项目本地文件,部署后自动切换到全局配置。`SearchFile()` 和 `LoadAgent()/LoadSkill()` 都遵循此规则。 + +11. **用户配置目录固定为 `~/.config/yunshu/`**,所有系统统一。存放 config.yaml、session.json、以及用户自定义的 agents/skills/data。不能改到其他路径。 + +12. **Agent skill、普通 skill、tool 必须严格分离,不能混淆**。Agent skill(`agents/*.md`)只放行为逻辑(角色、工作流程、输出风格),不 inline 任何技术细节。技术细节(URL、apiKey、请求头、JSON 解析路径)放在 `skills/*/SKILL.md` 作为纯知识,由 LLM 按需调用 `skill("name")` 加载。确定性操作(如 geocode)注册为 tool,保证 100% 可靠执行。这解决了 picoclaw 单 agent 架构下 skill 污染上下文的问题。 + +13. **wttr.in `?format=j1` 返回的 JSON 包含地理编码信息**,`nearest_area[0]` 中有 `latitude`、`longitude`、`areaName`、`country`、`population` 字段。可作为免费的地理编码服务使用,无需 API Key。 + +14. **geocode 工具用 Go 代码实现比让 LLM 自己调 http-get 解析 JSON 更可靠**。LLM 在构造 URL 和解析嵌套 JSON 时容易出错(尤其是中文编码问题)。注册为 tool 后,LLM 只需提供城市名参数,Go 代码处理所有细节。 + +15. **项目正式命名为云枢·Agent(YunShu / yunshu)**,配置目录从 `~/.config/weather-cli/` 迁移到 `~/.config/yunshu/`。旧目录在首次运行时会自动迁移并删除。二进制名称改为 `yunshu`。如果迁移失败,用户可手动复制旧目录内容后重新运行。 diff --git a/docs/MSN天气API探索报告.md b/docs/MSN天气API探索报告.md new file mode 100644 index 0000000..088e120 --- /dev/null +++ b/docs/MSN天气API探索报告.md @@ -0,0 +1,297 @@ +# MSN 天气 API 探索报告 + +**生成时间**: 2026-05-03 +**探索目标**: 确认 MSN 天气是否有免费可用的 API 接口 + +--- + +## 一、核心结论 + +**MSN 没有官方公开的免费 REST API**,但存在**微软官方内部 API**(通过浏览器抓包获得),在国内访问速度快,可直接调用。 + +- ❌ **不是**官方对外公开的 API(无文档、无 SLA) +- ✅ **是**微软官方的后台接口(`assets.msn.cn` / `api.msn.cn` 均为微软域名) +- ⚠️ 属于**非公开内部 API**,随时可能变更或失效 + +--- + +## 二、可用接口汇总 + +| 接口 | URL | 功能 | 稳定性 | +|------|-----|------|--------| +| **当前天气** | `https://assets.msn.cn/service/weather/current` | 获取实时天气 | ✅ 稳定可用 | +| **每日预报** | `https://assets.msn.cn/service/weather/dailyforecast` | 未来10天预报 | ✅ 稳定可用 | +| **天气趋势** | `https://assets.msn.cn/service/weather/weathertrends` | 历史+趋势+日历 | ✅ 可用(参数复杂) | +| api.msn.cn 当前 | `https://api.msn.cn/weather/current` | 用城市名获取 | ✅ 可用(但城市名不准) | +| api.msn.cn 预报 | `https://api.msn.cn/weather/forecast` | 预报 | ❌ 500错误 | + +**推荐**:只用 `assets.msn.cn` 的两个接口即可满足大部分需求。 + +--- + +## 三、关键参数说明 + +### 必须参数 +- **`apiKey`**: `j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo` + - 固定值,修改任意字符即返回 401 Unauthorized + - 从微软 MSN 天气前端代码中提取 +- **`lat`** / **`lon`**: 经纬度(WGS84 坐标系) +- **`locale`**: 语言区域,如 `zh-cn`、`en-us` + +### 可选参数 +- **`units`**: 温度单位,`C`(摄氏)或 `F`(华氏) +- **`days`**: 预报天数(dailyforecast 接口,最大10天) + +### 不需要的参数 +- `user`: 测试发现不带也能正常工作 +- `cm`、`ocid`、`fdhead` 等: weathertrends 专用,dailyforecast 不需要 + +### 必须请求头 +```http +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) +Referer: https://www.msn.com/zh-cn/weather +``` +不带这些请求头会返回 `401 Authorization Required`。 + +--- + +## 四、调用示例 + +### PowerShell 示例 + +```powershell +$apiKey = "j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo" +$headers = @{ + "User-Agent" = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + "Referer" = "https://www.msn.com/zh-cn/weather" +} + +# 获取当前天气(北京) +$uri_current = "https://assets.msn.cn/service/weather/current?apiKey=$apiKey&lat=39.904172&lon=116.407417&units=C&locale=zh-cn" +$currentResp = Invoke-RestMethod -Uri $uri_current -Headers $headers +$current = $currentResp.value[0].responses[0].weather[0].current + +Write-Host "温度: $($current.temp)C" +Write-Host "天气: $($current.cap)" +Write-Host "体感: $($current.feels)C" +Write-Host "湿度: $($current.rh)%" +Write-Host "风速: $($current.windSpd)km/h" +Write-Host "气压: $($current.baro)hPa" +Write-Host "紫外线: $($current.uv) ($($current.uvDesc))" +Write-Host "AQI: $($current.aqi) ($($current.aqiSeverity))" + +# 获取未来7天预报 +$uri_forecast = "https://assets.msn.cn/service/weather/dailyforecast?apiKey=$apiKey&lat=39.904172&lon=116.407417&units=C&locale=zh-cn&days=7" +$forecastResp = Invoke-RestMethod -Uri $uri_forecast -Headers $headers +$days = $forecastResp.value[0].responses[0].weather[0].days + +Write-Host "`n未来7天预报:" +foreach ($day in $days) { + $d = $day.daily + Write-Host " $($d.valid.ToString().Substring(0,10)): $($d.tempLo)-$($d.tempHi)C, 降水$($d.precip)%, 风速$($d.windMax)km/h" +} +``` + +### curl 示例 + +```bash +# 当前天气 +curl -H "User-Agent: Mozilla/5.0" \ + -H "Referer: https://www.msn.com/zh-cn/weather" \ + "https://assets.msn.cn/service/weather/current?apiKey=j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo&lat=39.904172&lon=116.407417&units=C&locale=zh-cn" + +# 7天预报 +curl -H "User-Agent: Mozilla/5.0" \ + -H "Referer: https://www.msn.com/zh-cn/weather" \ + "https://assets.msn.cn/service/weather/dailyforecast?apiKey=j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo&lat=39.904172&lon=116.407417&units=C&locale=zh-cn&days=7" +``` + +--- + +## 五、返回数据结构 + +### current 接口响应 + +```json +{ + "@odata.context": "api.msn.com/weather/$metadata#current", + "value": [{ + "responses": [{ + "weather": [{ + "current": { + "temp": 20.0, // 当前温度 + "cap": "晴", // 天气描述 + "capAbbr": "晴", // 简短描述 + "feels": 20.0, // 体感温度 + "rh": 14.0, // 相对湿度 % + "baro": 1006.0, // 气压 hPa + "windSpd": 25.0, // 风速 km/h + "windDir": 360, // 风向(度) + "windGust": 43.0, // 阵风速度 + "uv": 5.0, // 紫外线指数 + "uvDesc": "中等", // 紫外线描述 + "vis": 30.0, // 能见度 km + "dewPt": -8.0, // 露点温度 + "aqi": 22.0, // AQI指数 + "aqiSeverity": "优", // AQI等级 + "cloudCover": 15.0, // 云量 % + "created": "2026-05-03T14:29:06+08:00" + } + }], + "source": { + "location": "北京, 北京市, 中国", + "coordinates": {"lat": 39.904172, "lon": 116.407417} + } + }] + }] +} +``` + +### dailyforecast 接口响应 + +```json +{ + "@odata.context": "api.msn.com/weather/$metadata#dailyforecast", + "value": [{ + "responses": [{ + "weather": [{ + "days": [ + { + "daily": { + "valid": "2026-05-03T00:00:00", // 日期 + "tempLo": 8, // 最低温 + "tempHi": 21, // 最高温 + "precip": 5.0, // 降水概率 % + "windMax": 10.0, // 最大风速 + "windMaxDir": 286, // 风向 + "rhHi": 35.85, // 最高湿度 + "rhLo": 14.0, // 最低湿度 + "icon": 1, // 图标代码 + "symbol": "d000", // 天气符号 + "uv": 5.0, // 紫外线指数 + "uvDesc": "中等" + } + } + // ... 更多天 + ] + }] + }] + }] +} +``` + +--- + +## 六、多城市验证结果 + +| 城市 | 经纬度 | 状态 | 示例数据 | +|------|--------|------|----------| +| 北京 | 39.904172, 116.407417 | ✅ 正常 | 20°C, 晴, 湿度14% | +| 上海 | 31.2304, 121.4737 | ✅ 正常 | 21°C, 多云, 湿度54% | +| 广州 | 23.1291, 113.2644 | ✅ 正常 | 24°C, 多云, 湿度79% | +| 成都 | 30.5728, 104.0668 | ✅ 正常 | 23°C, 局部多云, 湿度42% | + +--- + +## 七、限制与注意事项 + +### 1. apiKey 固定 +- 当前 key 硬编码在微软前端代码中 +- 修改任意字符即失效(返回 401) +- **长期有效性未知**,微软可能随时更换 + +### 2. 非公开接口 +- 无官方文档 +- 无 SLA(服务等级协议)保证 +- 数据结构可能随时变更 + +### 3. 需要特定请求头 +必须携带 `User-Agent` 和 `Referer`,否则返回 401。 + +### 4. 限流未知 +未测试请求频率限制,建议生产环境加入适当的请求间隔。 + +### 5. 城市名接口不可靠 +`api.msn.cn` 使用城市名参数可能返回错误城市(测试"北京"返回了也门首都萨那)。 + +### 6. 法律合规 +- 这是非公开接口,用于个人项目/内部工具问题不大 +- **不建议用于商业产品**(随时可能失效,且无使用授权) + +--- + +## 八、推荐方案 + +### 最简调用方案 + +```powershell +function Get-MSNWeather { + param( + [double]$Lat, + [double]$Lon, + [string]$Locale = "zh-cn" + ) + + $apiKey = "j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo" + $headers = @{ + "User-Agent" = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + "Referer" = "https://www.msn.com/zh-cn/weather" + } + + # 当前天气 + $currentUri = "https://assets.msn.cn/service/weather/current?apiKey=$apiKey&lat=$Lat&lon=$Lon&units=C&locale=$Locale" + $current = Invoke-RestMethod -Uri $currentUri -Headers $headers + + # 7天预报 + $forecastUri = "https://assets.msn.cn/service/weather/dailyforecast?apiKey=$apiKey&lat=$Lat&lon=$Lon&units=C&locale=$Locale&days=7" + $forecast = Invoke-RestMethod -Uri $forecastUri -Headers $headers + + return @{ + Current = $current.value[0].responses[0].weather[0].current + Forecast = $forecast.value[0].responses[0].weather[0].days + } +} + +# 使用示例 +$weather = Get-MSNWeather -Lat 39.904172 -Lon 116.407417 +$weather.Current.temp # 当前温度 +``` + +--- + +## 九、补充:天气图标对照 + +接口返回的 `icon` 或 `symbol` 字段对应天气图标: +- `0`: 晴天 +- `1`: 大部晴朗 +- `2`: 局部多云 +- `3`: 多云 +- `4`: 阴天 +- `5-6`: 有雾 +- `7-8`: 阴沉 +- `9-12`: 阵雨 +- `13-18`: 雷雨 +- `19-22`: 雨夹雪 +- `23-26`: 小雪 +- `27-30`: 中到大雪 + +图标完整 URL: +``` +http://img-s-msn-com.akamaized.net/tenant/amp/entityid/AAehR3S.img +``` +(其中 `AAehR3S` 是从 `urlIcon` 字段获取) + +--- + +## 十、总结 + +| 项目 | 结论 | +|------|------| +| 是否有免费 API | ✅ 有(非公开内部接口) | +| 国内速度 | ✅ 快(msn.cn 国内节点) | +| 稳定性 | ⚠️ 未知(非官方,随时可能变) | +| 数据完整性 | ✅ 完整(当前+预报+AQI+紫外线等) | +| 推荐用途 | 个人项目、内部工具、原型开发 | +| 不推荐用途 | 商业产品、长期运行服务 | + +**建议**:如果用于生产环境,推荐同时准备备用方案(如和风天气、OpenWeatherMap 等)。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fa0f082 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,70 @@ +# 云枢·Agent 架构参考 + +> 详细架构白皮书见 `~/Desktop/yunshu-architecture.md` +> +> 本文档为项目内部精简参考 + +## 项目命名 + +- **中文名**:云枢·Agent(坐看云卷云舒,静听花开花落) +- **英文名**:YunShu / yunshu +- **配置目录**:`~/.config/yunshu/` + +## 三层分离架构 + +``` +Agent Skill (agents/*.md) → 纯行为(~40行,全程在 system prompt) +普通 Skill (skills/*/SKILL.md) → 纯知识(按需加载,用完即走) +Tool (src/tool.go 注册) → 确定性执行(Go 代码,仅返回结果) +``` + +## 四种能力对比 + +| 维度 | Agent Skill | 普通 Skill | Tool | MCP | +|------|------------|-----------|------|-----| +| 本质 | 角色定义("我是谁") | 知识手册("怎么用") | 确定性执行("帮我做") | 外部服务("远程调用") | +| 加载方式 | 启动即加载 | `skill("name")` | 声明即注册 | 外部进程协议 | +| 上下文影响 | 全程 | 仅该轮 | 仅结果文本 | 同 tool | +| 实现形式 | .md frontmatter+body | .md body | Go 函数 | 外部 server | + +## 判断准则 + +``` +"做什么" → Agent Skill +"怎么做" → 继续问 + "知识" → 普通 Skill + "操作" → 继续问 + "本地操作" → Tool + "远程服务" → MCP +``` + +## 和 picoclaw 的关键区别 + +| | picoclaw | 云枢·Agent | +|---|---|---| +| 上下文 | 行为+知识+工具全堆在一起 | 三层分离,各司其职 | +| 角色 | 一个 prompt 塞 N 个角色 | 一个 agent = 一个角色 | +| 知识加载 | 预置或直接塞入 | 按需加载,仅该轮存在 | +| 工具执行 | 依赖 LLM 构造 URL 解析 JSON | Tool 用 Go 代码,100% 可靠 | + +## 当前 tools + +| 工具名 | 作用 | 实现 | +|--------|------|------| +| http-get | HTTP GET 请求 | Go | +| skill | 按需加载知识 | Go | +| geocode | 城市名 → 坐标 | Go(调 wttr.in) | +| read-file | 读取文件 | Go | + +## 后续演进 + +``` +云枢·Agent (三层分离+单agent) + ↓ +河虾 claw (三层分离+主-从) + ├─ master: 意图识别+任务分发 + ├─ weather-subagent + ├─ tts-subagent + ├─ asr-subagent + └─ ...更多 subagent +``` diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..9309297 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,56 @@ +# 云枢·Agent 版本变更日志 + +> 坐看云卷云舒,静听花开花落 + +## [1.0.0] - 2026-05-07 + +### 重大变更 +- **项目更名**:weather-cia → **云枢·Agent**(英文名 YunShu / yunshu) +- **配置目录迁移**:`~/.config/weather-cli/` → `~/.config/yunshu/`(自动迁移) +- 二进制名称改为 `yunshu` + +## [0.3.0] - 2026-05-07 + +### 新增 +- `geocode` 工具:通过 wttr.in 查询城市坐标,支持中文和英文城市名 +- `skills/geocoding/SKILL.md`:地理编码验证规则(同名城市检测、国家核对) +- 架构分离:agent skill 只放行为,普通 skill 只放知识,tool 负责确定性执行 + +### 变更 +- `agents/weather-agent.md` 精简为纯行为定义(去掉所有 MSN API 内联细节,改为按需加载 skill) +- 城市定位方式:从静态 cities.json 查表 → 调用 `geocode` 工具实时查询 +- `agents/weather-agent.md` tools 新增 `geocode` +- session 文件从项目目录移至 `~/.config/weather-cli/session.json` + +## [0.2.0] - 2026-05-07 + +### 新增 +- `onboard` 子命令:交互式初始化向导,引导用户配置 LLM 连接信息 +- 全局配置文件 `~/.config/weather-cli/config.yaml`,存储 LLM host/model/key +- 双路径搜索机制:项目目录优先,`~/.config/weather-cli/` 后备 +- 首次运行检测:未配置时提示用户运行 `weather-cia onboard` + +### 变更 +- 项目重命名为 `weather-cia` +- 配置加载改为:配置文件 → 环境变量(环境变量优先级更高) +- Agent/skill 搜索路径扩展:项目目录 → 全局配置目录 +- `onboard` 自动复制默认 agents/skills/data 到全局配置目录 + +## [0.1.0] - 2026-05-07 + +### 新增 +- 项目初始化,基于 Go 实现的轻量级 agent 框架 +- 核心架构:.md 文件定义 agent 行为,代码只负责加载和执行 +- 工具系统:声明式注册(http-get, skill, read-file) +- Session 会话管理:session.json 记录对话历史,支持上下文追问 +- 天气情报官 agent(weather-agent.md):通过 MSN 天气 API 查询实时天气和预报 +- MSN 天气 API Skill(msn-weather-api/SKILL.md):API 知识按需加载 +- 内置 42 个中国城市经纬度数据库(data/cities.json) +- 支持单次查询和交互模式两种运行方式 +- 默认集成豆包(火山引擎)LLM,通过环境变量可切换 + +### 技术细节 +- 语言:Go 1.21 +- 依赖:仅 gopkg.in/yaml.v3(用于解析 frontmatter) +- API 兼容 OpenAI Chat Completion 格式 +- 环境变量:`OPENAI_API_KEY`(必填)、`LLM_ENDPOINT`、`LLM_MODEL` diff --git a/docs/taolun.md b/docs/taolun.md new file mode 100644 index 0000000..44d4c9c --- /dev/null +++ b/docs/taolun.md @@ -0,0 +1,144 @@ +# 讨论历史 + +## 2026-05-07 项目启动与架构设计 + +### 背景 +用户有一个 `MSN天气API探索报告.md` 文档,记录了通过抓包发现的微软 MSN 天气内部 API(`assets.msn.cn`),该 API 国内访问速度快,数据完整(温度、湿度、风速、AQI、紫外线等),但属于非公开接口,无 SLA 保证。 + +### 目标演变 +1. **最初目标**:做一个"天气情报官" agent,后期结合 TTS 和 ASR 实现语音查询播报 +2. **深化**:用户想从 0 实现一个类似 opencode 主-从架构的个人 AI 助理,解决现有单 agent 框架(zeroclaw/picoclaw)的痛点:上下文污染、工具执行懒惰、skill 效果差 +3. **当前范围**:先做一个最小化的 CLI 天气查询工具,验证 .md 外挂 agent 定义 + session 会话管理 + 工具注册表机制 + +### 架构决策 + +#### 为什么不用现有框架(LangChain 等) +- 核心创新是 .md 文件即 agent 定义,与任何框架都耦合不上 +- 自实现核心 ~500 行,无外部依赖包袱 + +#### Agent 定义格式(仿 opencode) +- YAML frontmatter + Markdown body +- frontmatter 字段:name, description, type, tools, permission +- body 即 system prompt,定义角色行为 + +#### Session 会话机制 +- `session.json` 文件存对话历史,格式兼容 OpenAI Chat Completion API messages 数组 +- 每次启动清空,每轮对话追加 +- 追问时 LLM 自动判断是否需要重新调 API(数据过期/不同城市) +- 通用设计,后续 master-subagent 架构也可复用 + +#### LLM 提供商 +- 用户提供豆包(火山引擎)API:`https://ark.cn-beijing.volces.com/api/v3` +- 模型:`doubao-seed-2-0-pro-260215` +- 环境变量可配置:`LLM_ENDPOINT`, `LLM_MODEL`, `LLM_API_KEY` + +### 工具系统 +- 声明式注册:`tool.go` 注册工具,`.md` 文件声明即可用 +- 内置工具:`http-get`, `skill`, `read-file` +- skill 工具按需加载,不预置到 system prompt + +### Windows 编码问题 +- PowerShell 输出编码为 GB2312,Go 输出 UTF-8 导致中文乱码 +- 通过 `kernel32.SetConsoleOutputCP(65001)` 设置控制台 CP 为 UTF-8 +- 在 PowerShell 中需额外执行 `[Console]::OutputEncoding = [Text.Encoding]::UTF8` + +### 项目结构(最终) +``` +weather/ +├── main.go # CLI 入口 +├── types.go # 核心类型 +├── loader.go # .md 解析 + skill 加载 +├── llm.go # LLM API 封装(默认豆包) +├── tool.go # 工具注册表 +├── runtime.go # agent 循环 + session +├── agents/ +│ └── weather-agent.md # 天气情报官定义 +├── skills/ +│ └── msn-weather-api/SKILL.md +├── data/ +│ └── cities.json # 42 个中国城市 +├── taolun.md # 本文件 +├── changelog.md # 版本变更 +└── agents.md # 编码规范 +``` + +### 验证结果 +- 单次查询:`.\weather-agent.exe "北京今天天气"` → 成功返回温度、湿度、AQI 等 +- 交互模式:启动后连续追问 → `session.json` 记录历史,LLM 基于上下文回答"适合穿什么" +- 豆包 API 工具调用正常:自动读取 cities.json → 调 MSN API → 分析输出 + +--- + +## 2026-05-07 项目重命名与配置体系 + +### 变更 +1. **项目重命名**:`weather-agent` → `weather-cia`(CIA = 天气情报官) +2. **配置体系**:`~/.config/weather-cli/config.yaml` 统一管理 LLM 配置 +3. **初始化方式**:`weather-cia onboard` 交互式向导,替代手动写配置文件 +4. **双路径搜索**:项目目录优先 + `~/.config/weather-cli/` 后备 + +### 关键决策 +- **用 config.yaml 而非 .env/.secret**:YAML 风格与 agent 定义一致,API Key 用 0600 权限保护 +- **配置优先级**:环境变量 > 配置文件 > 默认值(`init()` 中依次加载) +- **`onboard` 子命令**:交互式 TTY 输入,自动复制默认 agents/skills/data 到全局目录 +- **搜索路径**:`SearchFile()` 统一管理,开发者用项目文件,用户用全局配置 + +### 验证 +- `weather-cia onboard` 成功创建 `~/.config/weather-cli/config.yaml` +- `weather-cia "北京今天天气"` 无需环境变量,直接读取配置文件中的豆包 key 并成功返回天气数据 +- 全局配置目录自动包含 agents/、skills/、data/ 的完整副本 + +--- + +## 2026-05-07 架构分离:Agent Skill vs 普通 Skill vs Tool + +### 背景 +参考了 picoclaw 的 weather skill 设计,对比发现: +- picoclaw 的 skill 写得很完整(含验证规则、边界情况) +- 但我们的 `weather-agent.md` 之前 inline 了大量 API 细节 → 和 picoclaw 一样污染上下文 + +### 决策:三层分离 + +| 层 | 文件位置 | 加载时机 | 上下文影响 | +|---|---------|---------|-----------| +| **Agent skill** | `agents/weather-agent.md` | 启动即加载为 system prompt | **全程** | +| **普通 skill** | `skills/*/SKILL.md` | LLM 调用 `skill("name")` 时 | **仅该轮对话** | +| **Tool** | `src/tool.go` 注册 | 预声明,LLM 调用时执行 | **仅返回结果文本** | + +### 具体改造 + +1. **新增 `geocode` tool**(Go 代码): + - 输入城市名,调 wttr.in `?format=j1` 解析 JSON + - 返回 `{lat, lon, name, country}` 结构化数据 + - 确定性执行,比 LLM 自己构造 URL 解析 JSON 更可靠 + +2. **新建 `skills/geocoding/SKILL.md`**: + - 纯知识:wttr.in 查询格式、JSON 解析路径 + - 验证规则:同名城市检测、country 核对、population 排序 + +3. **精简 `agents/weather-agent.md`**: + - 去掉所有 MSN API URL、apiKey、请求头、JSON 路径等内联知识 + - 改为行为描述:识别城市 → geocode → skill("msn-weather-api") → http-get → 分析 + - 从 65 行缩减为 40 行,只留行为逻辑 + +4. **session 移至 `~/.config/weather-cli/session.json`** + +### 结果 +- Agent skill 保持瘦身,system prompt 不膨胀 +- 知识按需加载,用完即走,不残留上下文 +- Tool 执行可靠,不依赖 LLM 的 JSON 解析能力 +- 三种内容互不干扰,为后续主-从架构打下基础 + +--- + +## 2026-05-07 项目更名:云枢·Agent + +### 变更 +1. **正式命名**:云枢·Agent(YunShu / yunshu) + - 坐看云卷云舒,静听花开花落 +2. **配置目录迁移**:`~/.config/weather-cli/` → `~/.config/yunshu/`(自动迁移) +3. **二进制名称**:`yunshu` +4. **架构白皮书**:`~/Desktop/yunshu-architecture.md` + +### 设计理念 +"云枢"二字呼应了项目作为 AI 助理"中枢调度"的定位——云是分布式的、流转的,枢是枢纽、核心。后续主-从架构中,master 负责调度、subagent 各司其职,恰如云卷云舒。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57b3da9 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module yunshu + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/skills/geocoding/SKILL.md b/skills/geocoding/SKILL.md new file mode 100644 index 0000000..d655fd0 --- /dev/null +++ b/skills/geocoding/SKILL.md @@ -0,0 +1,33 @@ +--- +name: geocoding +description: 通过 wttr.in 查询城市坐标的知识和验证规则 +--- + +# Geocoding(地理编码)参考 + +## wttr.in 坐标查询 + +获取城市经纬度: +``` +https://wttr.in/{城市名}?format=j1 +``` + +- 城市名支持中文(如 `北京`、`成都`)和英文(如 `Beijing`、`Chengdu`) +- 返回 JSON 格式的完整天气数据,坐标在 `nearest_area[0]` 中 +- 坐标字段:`latitude`、`longitude` +- 额外信息:`areaName`、`country`、`population` + +## 验证规则 + +获取到坐标后必须验证以下信息,避免张冠李戴: + +1. **确认国家/地区正确** — 检查 `country[0].value`,如"成都"的 country 应为 "China" +2. **同名城市判断** — 如果 `nearest_area` 有多条,按 population 大小取最可能的目标 +3. **中文输入优先用中文查** — wttr.in 原生支持中文名解析,比先转英文更准确 +4. **结果存疑时用 skill("msn-weather-api") 多看细节** + +## 已知边界情况 + +- 小城市名可能匹配到其他国家的同名地点(如"长春"→ 印度尼西亚也有长春),需要核对 country +- 一些城市名可能有多个行政区同名(如"济南"确保是中国山东济南) +- wttr.in 是一个天气网站,它的地理编码数据偏向人口多的城市 diff --git a/skills/msn-weather-api/SKILL.md b/skills/msn-weather-api/SKILL.md new file mode 100644 index 0000000..cb4a321 --- /dev/null +++ b/skills/msn-weather-api/SKILL.md @@ -0,0 +1,56 @@ +--- +name: msn-weather-api +description: MSN 天气 API 详细知识 +--- + +# MSN 天气 API 参考 + +## 接口地址 + +| 接口 | URL | +|------|-----| +| 当前天气 | `https://assets.msn.cn/service/weather/current` | +| 每日预报 | `https://assets.msn.cn/service/weather/dailyforecast` | + +## 必须参数 + +- `apiKey`: `j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo`(固定值,不可修改) +- `lat` / `lon`: 经纬度(WGS84) +- `locale`: `zh-cn` +- `units`: `C`(摄氏) + +## 可选参数 + +- `days`: 预报天数(最大 10) + +## 必须请求头 + +``` +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) +Referer: https://www.msn.com/zh-cn/weather +``` + +## 响应数据结构 + +### current 接口 +``` +value[].responses[].weather[].current.{ + temp, cap, feels, rh, baro, windSpd, windDir, + windGust, uv, uvDesc, vis, dewPt, aqi, + aqiSeverity, cloudCover, created +} +``` + +### dailyforecast 接口 +``` +value[].responses[].weather[].days[].daily.{ + valid, tempLo, tempHi, precip, windMax, + windMaxDir, rhHi, rhLo, icon, symbol, uv, uvDesc +} +``` + +## 注意事项 +- 数据源为微软 MSN 天气后台接口 +- 国内访问速度快(msn.cn 国内节点) +- apiKey 可能随时更换 +- 不可用于商业产品 diff --git a/src/catalog.go b/src/catalog.go new file mode 100644 index 0000000..708f939 --- /dev/null +++ b/src/catalog.go @@ -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 +} diff --git a/src/config.go b/src/config.go new file mode 100644 index 0000000..39338cd --- /dev/null +++ b/src/config.go @@ -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) + } + } +} diff --git a/src/llm.go b/src/llm.go new file mode 100644 index 0000000..d231628 --- /dev/null +++ b/src/llm.go @@ -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 +} diff --git a/src/loader.go b/src/loader.go new file mode 100644 index 0000000..5b76771 --- /dev/null +++ b/src/loader.go @@ -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//SKILL.md → ~/.config/weather-cli/skills//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 +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..412583e --- /dev/null +++ b/src/main.go @@ -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)) + } +} diff --git a/src/onboard.go b/src/onboard.go new file mode 100644 index 0000000..f1cb0e3 --- /dev/null +++ b/src/onboard.go @@ -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") +} diff --git a/src/runtime.go b/src/runtime.go new file mode 100644 index 0000000..47f8c73 --- /dev/null +++ b/src/runtime.go @@ -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 + } + } +} diff --git a/src/tool.go b/src/tool.go new file mode 100644 index 0000000..25691ca --- /dev/null +++ b/src/tool.go @@ -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 + }, + }) +} diff --git a/src/types.go b/src/types.go new file mode 100644 index 0000000..23f805f --- /dev/null +++ b/src/types.go @@ -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"` +} diff --git a/yunshu.exe b/yunshu.exe new file mode 100644 index 0000000..101dab3 Binary files /dev/null and b/yunshu.exe differ