init: 云枢·Agent 初始提交

This commit is contained in:
titor
2026-05-08 10:12:31 +08:00
commit a6025c699f
22 changed files with 2116 additions and 0 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"markdown.validate.enabled": true
}

34
agents/weather-agent.md Normal file
View File

@@ -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 小时),重新请求
## 输出规范
回答要清晰友好,包含关键信息:
- 当前温度、体感温度、天气状况
- 湿度、风速、空气质量
- 根据天气给出实用建议(如"建议带伞""适合户外"等)

43
data/cities.json Normal file
View File

@@ -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}
}

117
docs/AGENTS.md Normal file
View File

@@ -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, ParametersJSON 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 callingtool_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 字段为 nullJSON 中的 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. **项目正式命名为云枢·AgentYunShu / yunshu**,配置目录从 `~/.config/weather-cli/` 迁移到 `~/.config/yunshu/`。旧目录在首次运行时会自动迁移并删除。二进制名称改为 `yunshu`。如果迁移失败,用户可手动复制旧目录内容后重新运行。

View File

@@ -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 等)。

70
docs/architecture.md Normal file
View File

@@ -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
```

56
docs/changelog.md Normal file
View File

@@ -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 记录对话历史,支持上下文追问
- 天气情报官 agentweather-agent.md通过 MSN 天气 API 查询实时天气和预报
- MSN 天气 API Skillmsn-weather-api/SKILL.mdAPI 知识按需加载
- 内置 42 个中国城市经纬度数据库data/cities.json
- 支持单次查询和交互模式两种运行方式
- 默认集成豆包火山引擎LLM通过环境变量可切换
### 技术细节
- 语言Go 1.21
- 依赖:仅 gopkg.in/yaml.v3用于解析 frontmatter
- API 兼容 OpenAI Chat Completion 格式
- 环境变量:`OPENAI_API_KEY`(必填)、`LLM_ENDPOINT``LLM_MODEL`

144
docs/taolun.md Normal file
View File

@@ -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 输出编码为 GB2312Go 输出 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. **正式命名**云枢·AgentYunShu / yunshu
- 坐看云卷云舒,静听花开花落
2. **配置目录迁移**`~/.config/weather-cli/``~/.config/yunshu/`(自动迁移)
3. **二进制名称**`yunshu`
4. **架构白皮书**`~/Desktop/yunshu-architecture.md`
### 设计理念
"云枢"二字呼应了项目作为 AI 助理"中枢调度"的定位——云是分布式的、流转的,枢是枢纽、核心。后续主-从架构中master 负责调度、subagent 各司其职,恰如云卷云舒。

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module yunshu
go 1.21
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -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=

33
skills/geocoding/SKILL.md Normal file
View File

@@ -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 是一个天气网站,它的地理编码数据偏向人口多的城市

View File

@@ -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 可能随时更换
- 不可用于商业产品

311
src/catalog.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"`
}

BIN
yunshu.exe Normal file

Binary file not shown.