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