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