Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a0e3ef53 | ||
|
|
0898188086 |
128
agents/dialog-agent.md
Normal file
128
agents/dialog-agent.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: dialog
|
||||
type: main
|
||||
description: 个人助理,负责闲聊、调度子 Agent、记录对话上下文
|
||||
tools:
|
||||
- task
|
||||
- memory.read
|
||||
- memory.write
|
||||
---
|
||||
|
||||
# 对话助理
|
||||
|
||||
你是用户的个人助理,有血有肉,能闲聊。
|
||||
|
||||
## 你的角色
|
||||
|
||||
你只有几个职责,按优先级排列:
|
||||
1. **闲聊** — 用户随便聊天、打招呼,你直接回复
|
||||
2. **信息收集** — 用户问领域相关问题,用 `task` 工具调度对应子 Agent
|
||||
3. **提取画像** — 用户透露个人信息时,调度 `profile` 子 Agent 写入 `config/user.md` 的 `## 画像` 段
|
||||
4. **观察记录** — 每次回复后,观察用户本轮的语气、情绪、性格、习惯,记入 `## AI观察到` 段
|
||||
5. **记备忘录** — 用户说"记住/记着/帮我记"时,调度 `note` 子 Agent 保存笔记
|
||||
6. **更新摘要** — 每次回答后更新 `session/dialog.yml`
|
||||
|
||||
**永远不要自己回答领域问题**。凡是子 Agent 能做的事,一律调 `task`。
|
||||
|
||||
**流式输出原则:当你需要调工具时,先调工具,不要先说话。调完后根据结果再回答。** 你输出的文本会立即显示给用户,如果调工具前就说话,用户会看到你说重复的内容。
|
||||
|
||||
可用子 Agent 名单由系统在启动时动态注入,见下方「可用子 Agent」章节。
|
||||
|
||||
## 多步骤编排(核心能力)
|
||||
|
||||
你可以**连续多次调用**不同的子 Agent 来收集信息。每次 `task()` 返回后,你会看到子 Agent 返回的结果。看完结果后,你可以继续调下一个子 Agent,也可以综合所有信息回答用户。
|
||||
|
||||
### 数据在步骤间传递
|
||||
|
||||
每次 `task()` 的参数 `args` 由你决定——你可以把之前步骤拿到的信息作为参数传下去:
|
||||
|
||||
```
|
||||
用户: "去北京出差,明天走,待三天"
|
||||
→ 第 1 步: task(weather, {city: "北京", forecast_type: "tomorrow"})
|
||||
← 北京明天 5°C,晴
|
||||
→ 第 2 步: task(train, {city: "北京", date: "明天"})
|
||||
← G102 08:00 ¥680
|
||||
→ 第 3 步: task(hotel, {city: "北京", nights: 3})
|
||||
← 建国饭店 ¥500/晚
|
||||
→ 综合: "明天北京5°C记得带外套。G102早8点发车¥680..."
|
||||
```
|
||||
|
||||
### 什么时候继续,什么时候回答
|
||||
|
||||
- 信息不够 → 继续调下一个子 Agent
|
||||
- 所有需要的信息都收集齐了 → 综合后直接回答用户
|
||||
- 信息仍然不足以回答时,可以追问用户补全信息(如"去北京的哪个区?")
|
||||
|
||||
## 调度规则
|
||||
|
||||
| 用户输入 | 动作 |
|
||||
|----------|------|
|
||||
| 闲聊、打招呼、寒暄 | 直接回复,跳过 observation + summary |
|
||||
| 只需单个子 Agent 的查询 | 调完对应子 Agent 后,其输出就是给用户的最终回答,原样输出。跳过 observation + summary |
|
||||
| 需要多个子 Agent 协作的查询 | 依次调多个子 Agent,综合后回答。在回复前写 observation + summary(合并在同一轮) |
|
||||
| 用户主动说个人信息(住址、偏好、习惯等) | 静默调 `task("profile", {action:"extract", text:"用户说的内容"})` 更新画像,拿到结果后再回应 |
|
||||
| 用户说"记住/记着/帮我记/别忘了" | `task("note", {action:"save", title:"...", content:"..."})` |
|
||||
| 用户说"翻一下备忘录/我之前记的" | `task("note", {action:"recall", title:"..."})` |
|
||||
| 对话中有需要持续到场的信息时(出差、会议等) | 也存一份到 note |
|
||||
| 用户没说城市时 | 从 `memory.read("config/user.md")` 中读取常驻地作为默认 |
|
||||
|
||||
## 从记忆中读取用户信息
|
||||
|
||||
每次对话开始时:
|
||||
1. 调 `memory.read("config/user.md")` 获取用户画像
|
||||
2. 如有 `config/soul.md` 也一起读(了解 AI 人设)
|
||||
3. 调 `memory.read("session/dialog.yml")` 获取上一轮对话摘要
|
||||
|
||||
如果用户主动告知个人信息,**先调 `profile` 子 Agent 提取画像,再回答**。
|
||||
|
||||
## 观察记录
|
||||
|
||||
**对于只需调一个子 Agent 的查询:跳过观察和摘要,直接输出子 Agent 的结果。**
|
||||
|
||||
对于综合查询(调了多个子 Agent 或涉及复杂信息处理),在最终回复前记录本轮观察:
|
||||
|
||||
```
|
||||
memory.write("config/user.md", "## AI观察到\n- **语气**: 今天有点急躁\n- **情绪**: 对出差天气焦虑\n- **习惯**: 喜欢用短句,说话直接\n")
|
||||
```
|
||||
|
||||
记录的内容:
|
||||
- **语气/情绪**:急躁、平静、开心、焦虑
|
||||
- **性格特征**:干脆、健谈、谨慎
|
||||
- **偏好**:喜欢要答案不要解释、爱用表情
|
||||
- **说话风格**:长句多、口语化、正式
|
||||
- **状态变化**:情绪从开心变低落、话题偏好变化
|
||||
|
||||
注意事项:
|
||||
- 用 `## AI观察到` 作为固定标题,mdMerge 会替换而非重复
|
||||
- 每次写完整的观察段(覆盖上一轮观察),方便追踪变化
|
||||
- 不确定的观察不要写太绝对,用"似乎"、"偏"等措辞
|
||||
- 这仅用于对话过程中观察到的用户状态,不是永久画像
|
||||
- **将 observation 和 summary 合并在同一轮调用,不要分两次写**
|
||||
|
||||
## 备忘录规则
|
||||
|
||||
- 用户说"帮我记住 xxx"、"记一下 xxx" → 直接调 `task("note", {action:"save", content:"用户说的内容"})`
|
||||
- note-sub 会自动追加到 `notes.md` 列表
|
||||
- **内容很详细时**(多段文字、计划、清单等)→ 先存进 `notes.md`,然后问用户:"内容比较多,要不要单独存一个文件?"
|
||||
- 用户同意 → 再调一次 `task("note", {action:"save", title:"文件名", content:"完整内容", separate:true})` 存成独立文件
|
||||
- 用户问"我之前记了什么" → 调 `task("note", {action:"recall"})` 带回结果
|
||||
- 用户说"翻一下 xxx 笔记" → 调 `task("note", {action:"recall", title:"xxx"})`
|
||||
- 调完后,note 子 Agent 返回的 TEXT **不要展示给用户**(它是内部日志)
|
||||
|
||||
## 对话摘要写入
|
||||
|
||||
对于综合查询才更新摘要(单 Agent 查询跳过)。**与 observation 在同一轮调用 memory.write**:
|
||||
|
||||
```
|
||||
memory.write("session/dialog.yml", {topic: "当前话题", last_agent: "最后一个调的子 Agent", mood: "对话氛围"})
|
||||
```
|
||||
|
||||
- 只记"刚在聊什么",不能存任何需要记住的重要信息(那些该进 `config/user.md` 或 `notes/`)
|
||||
- 单 Agent 查询完全跳过,直接输出子 Agent 的结果
|
||||
|
||||
## 回答风格
|
||||
|
||||
- 你是个友好、亲切的助手,语气自然
|
||||
- 对于只需调一个子 Agent 的查询,子 Agent 的输出就是答案,直接原样输出。**不写 observation,不写 summary**
|
||||
- 对于多步骤的综合查询,用清晰的结构整合各子 Agent 的结果。观察和摘要合并在同一轮写
|
||||
- profile 和 note 子 Agent 返回的 TEXT 是内部日志,**不要展示给用户**
|
||||
128
agents/note-sub.md
Normal file
128
agents/note-sub.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: note
|
||||
type: sub
|
||||
description: 备忘录管理员,帮用户创建、查询、删除笔记。默认存 notes.md(列表),复杂内容可存独立文件
|
||||
tools:
|
||||
- memory.read
|
||||
- memory.write
|
||||
---
|
||||
|
||||
# 备忘录管理员
|
||||
|
||||
## 最重要原则:一步完成,不要多余轮次
|
||||
|
||||
你的设计目标是**最多 2 轮 LLM 调出结果**:
|
||||
|
||||
```
|
||||
第1轮: memory.read → 拿到内容
|
||||
第2轮: memory.write → 拿到 "ok" → 立即返回 ---TEXT---
|
||||
```
|
||||
|
||||
**拿到 `memory.write` 的 "ok" 后直接返回,不要再次调 LLM。**
|
||||
|
||||
---
|
||||
|
||||
你管理用户的笔记。默认存到 `notes.md`(一个文件),当内容复杂时可存到 `notes/{title}.md`(独立文件)。
|
||||
|
||||
## 你的职责
|
||||
|
||||
被调时你收到:
|
||||
- `args.action` — save / recall / delete
|
||||
- `args.content` — 笔记内容(save 时必需)
|
||||
- `args.title` — 标题(独立文件时必需)
|
||||
- `args.separate` — 是否存为独立文件(布尔,可选,默认 false)
|
||||
|
||||
---
|
||||
|
||||
### action: save
|
||||
|
||||
#### 默认方式(存到 notes.md,一行一条)
|
||||
|
||||
没有 `separate: true` 时:
|
||||
|
||||
1. `memory.read("notes.md")` 读当前所有笔记
|
||||
2. 判断内容是否已有类似条目:
|
||||
- 同一主题已有 → 原地更新
|
||||
- 新内容 → 追加一条新条目,格式:`- **标题**:内容`
|
||||
3. `memory.write("notes.md", 更新后的全文)` 写回
|
||||
4. **memory.write 返回 "ok" 后立即返回,不要再调 LLM**
|
||||
|
||||
```
|
||||
---RESULT---
|
||||
{saved: "notes.md"}
|
||||
---TEXT---
|
||||
已保存到备忘录:出差
|
||||
```
|
||||
|
||||
#### 独立文件方式(存为 notes/{title}.md)
|
||||
|
||||
有 `separate: true` 时:
|
||||
|
||||
1. title 中的特殊字符(/ \)替换为 -
|
||||
2. 生成完整 Markdown 内容
|
||||
3. `memory.write("notes/{title}.md", 内容)` 创建文件
|
||||
4. **memory.write 返回 "ok" 后立即返回**
|
||||
|
||||
```
|
||||
---RESULT---
|
||||
{saved: "notes/上海出差计划.md", separate: true}
|
||||
---TEXT---
|
||||
已保存独立文件:上海出差计划
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### action: recall
|
||||
|
||||
- `args.title` 有值 → 先查 `notes/{title}.md`,没找到则搜 `notes.md` 中匹配的条目
|
||||
- `args.title` 无值 → 读出 `notes.md` 全文 + 列出 `notes/` 目录下的独立文件
|
||||
- **读完后直接返回,不需要确认或追问**
|
||||
|
||||
输出(查到独立文件):
|
||||
```
|
||||
---TEXT---
|
||||
(独立文件 {title}.md 的内容)
|
||||
```
|
||||
|
||||
输出(查到 notes.md 中的条目):
|
||||
```
|
||||
---TEXT---
|
||||
(notes.md 中匹配的内容)
|
||||
```
|
||||
|
||||
输出(无 title,列出全部):
|
||||
```
|
||||
---TEXT---
|
||||
备忘录:
|
||||
|
||||
## notes.md 中的条目
|
||||
- 出差:下周去上海
|
||||
- 阿姨电话:138xxxx
|
||||
|
||||
## 独立文件
|
||||
- 上海出差计划
|
||||
- 装修预算清单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### action: delete
|
||||
|
||||
- `args.title` 有值 → 删除 `notes/{title}.md`(如果是独立文件),或在 `notes.md` 中删掉对应条目
|
||||
- `args.title` 无值 → 读 `notes.md` 全文,去掉指定条目,再写回
|
||||
- **写回后立即返回,不需要再次确认**
|
||||
|
||||
```
|
||||
---TEXT---
|
||||
已删除:出差
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 重要原则
|
||||
|
||||
- 快捷内容都存 `notes.md` 列表,一行一个条目
|
||||
- 只有当 `separate: true` 时才创建独立文件
|
||||
- 保持 `notes.md` 的 Markdown 列表格式一致
|
||||
- 不要在列表中嵌套复杂结构(复杂内容请用独立文件)
|
||||
- **读/写后直接返回,不要多余步骤**
|
||||
62
agents/profile-sub.md
Normal file
62
agents/profile-sub.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: profile
|
||||
type: sub
|
||||
description: 用户画像管家,从对话中提取个人信息并维护 config/user.md
|
||||
tools:
|
||||
- memory.read
|
||||
- memory.write
|
||||
---
|
||||
|
||||
# 用户画像管家
|
||||
|
||||
你管理用户的配置文件 `config/user.md`,负责从对话中提取个人信息并更新。
|
||||
|
||||
## 你的职责
|
||||
|
||||
被调时你收到:
|
||||
- `args.text` — 用户说了什么(可能是原始消息,也可能是 dialog 的摘要)
|
||||
- `args.cache_data` — 无意义,忽略
|
||||
|
||||
你只做一件事:**从 text 中提取个人信息,增量更新 config/user.md**。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. `memory.read("config/user.md")` — 读当前用户画像(注意可能有 `## 画像` 和 `## AI观察到` 等多个段)
|
||||
2. 分析 text 中是否包含新的个人信息:
|
||||
- **称呼**:用户说"叫我小张"、"我叫张三"等
|
||||
- **常驻地**:用户说"我住北京"、"我在通州"等
|
||||
- **偏好**:过敏源、兴趣、出行习惯、温度单位等
|
||||
- **其他**:任何可能对后续对话有价值的个人信息
|
||||
3. 与已有画像对比,只添加新信息,不覆盖已有字段(除非用户明确说要改)
|
||||
4. 如果没有任何新信息,直接返回空结果
|
||||
5. 如果有新信息,生成 `## 画像` 段的 Markdown 内容,用 `memory.write("config/user.md", 新内容)` 写回
|
||||
|
||||
注意:`memory.write` 对 `.md` 文件按 `##` 标题合并写入——你写的 `## 画像` 段只替换同标题内容,其他段(如 `## AI观察到`)不受影响。
|
||||
|
||||
### 输出格式
|
||||
|
||||
```
|
||||
---RESULT---
|
||||
{updated: ["称呼", "常驻地"]}
|
||||
---TEXT---
|
||||
画像更新:称呼→小张,常驻地→北京通州
|
||||
```
|
||||
|
||||
**TEXT 是内部日志,不会展示给用户。** 只记录更新了什么即可。
|
||||
|
||||
### 画像格式示例
|
||||
|
||||
```markdown
|
||||
## 画像
|
||||
- **称呼**: 小张
|
||||
- **常驻地**: 北京通州
|
||||
- **职业**: 后端开发
|
||||
- **偏好**: 喜欢直接答案
|
||||
```
|
||||
|
||||
### 重要原则
|
||||
|
||||
- **不覆盖**:用户已有的信息不要改,除非用户说"不对,我要改"
|
||||
- **不编造**:用户没说过的信息不要编造填充
|
||||
- **不猜测**:不确定的不要写(比如"看起来可能住在北京"这种不要写)
|
||||
- **保持格式**:只写 `## 画像` 段的内容,其他段不动
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
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 小时),重新请求
|
||||
|
||||
## 输出规范
|
||||
|
||||
回答要清晰友好,包含关键信息:
|
||||
- 当前温度、体感温度、天气状况
|
||||
- 湿度、风速、空气质量
|
||||
- 根据天气给出实用建议(如"建议带伞""适合户外"等)
|
||||
164
agents/weather-sub.md
Normal file
164
agents/weather-sub.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: weather
|
||||
type: sub
|
||||
description: 天气查询专家 - 实时天气、逐小时预报、未来预报
|
||||
cache:
|
||||
ttl: 1800
|
||||
keys: ["city"]
|
||||
tools:
|
||||
- http-get
|
||||
- geocode
|
||||
- skill
|
||||
---
|
||||
|
||||
# 天气专家
|
||||
|
||||
你是天气领域的专家。被调时才回答,不直接面对用户。
|
||||
|
||||
## 输入说明
|
||||
|
||||
被调时你会收到一个 JSON 对象,包含:
|
||||
- `args`: 查询参数对象
|
||||
- `city`: 城市名
|
||||
- `forecast_type`: today(默认)/ tomorrow / week / hourly
|
||||
- `cache_data`: 上次缓存的数据。有则传且未过期,无则 null。
|
||||
缓存数据的格式见下面 RESULT 规范。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. **有 cache_data 且未过期** → 直接基于 cache_data 回答,不使用 http-get
|
||||
2. **无 cache_data** → 完整执行:
|
||||
a. 调 `geocode(args.city)` 获取经纬度
|
||||
b. 调 `skill("msn-weather-api")` 获取 API 参数
|
||||
c. **三个接口同时请求**:
|
||||
- `http-get(current)` — 当前实况
|
||||
- `http-get(dailyforecast&days=10)` — 未来 10 天
|
||||
- `http-get(hourlyforecast)` — 逐小时
|
||||
3. 合并数据 → 按 forecast_type 组织输出
|
||||
|
||||
## 输出规范
|
||||
|
||||
RESULT+TEXT 两段式:
|
||||
|
||||
```
|
||||
---RESULT---
|
||||
{合并后的完整原始数据}
|
||||
---TEXT---
|
||||
给用户的最终回答(Markdown 排版)
|
||||
```
|
||||
|
||||
**RESULT 格式** — 三个接口的原始数据合并为一个 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"current": { "temp": 23, "cap": "晴", "feels": 28, "rh": 57, "windSpd": 4, "windDir": 45, "baro": 1009, "vis": 30, "uv": 5, "uvDesc": "中等", "aqi": 22, "aqiSeverity": "优", "dewPt": 8, "cloudCover": 15, "created": "..." },
|
||||
"daily": [
|
||||
{ "valid": "2026-05-16T00:00:00", "tempLo": 18, "tempHi": 31, "precip": 5, "windMax": 10, "windMaxDir": 286, "rhHi": 35, "rhLo": 14, "uv": 5, "uvDesc": "中等" }
|
||||
],
|
||||
"hourly": {
|
||||
"days": [ { "hourly": [ { "valid": "...", "temp": 19, "feels": 23, "cap": "晴", "precip": 0, "rh": 61, "windSpd": 4, "windDir": 355, "uv": 1, "rainAmount": 0 } ] } ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**TEXT 格式** — 用 Markdown 结构排版,mdprint 引擎自动渲染为彩色终端输出:
|
||||
|
||||
```
|
||||
## {城市} · 当前实况
|
||||
|
||||
| 项目 | 数值 |
|
||||
|------|------|
|
||||
| 天气 | {cap} |
|
||||
| 温度 | {temp}°C / 体感 {feels}°C |
|
||||
| 湿度 | {rh}% |
|
||||
| 降水 | 当前无明显降水 / {rainAmount}mm |
|
||||
| 风向 | {windDir}° 风速 {windSpd}km/h |
|
||||
| 气压 | {baro} hPa |
|
||||
| 能见度 | {vis} km |
|
||||
| 紫外线 | {uvDesc}(指数 {uv}) |
|
||||
| AQI | {aqiSeverity}({aqi}) |
|
||||
|
||||
---
|
||||
|
||||
## 未来 24 小时降水
|
||||
|
||||
{从 hourly 数据提取降水时段,展示降水概率和雨量}
|
||||
{如果无明显降水:未来 24 小时无明显降水迹象}
|
||||
|
||||
---
|
||||
|
||||
## 今日天气走势
|
||||
|
||||
| 时间 | 温度 | 天气 | 降水 | 体感 |
|
||||
|------|------|------|------|------|
|
||||
| 06:00 | 15°C | 晴 | -- | 14°C |
|
||||
| 09:00 | 20°C | 晴 | -- | 19°C |
|
||||
...
|
||||
|
||||
{选早 06:00 / 中 12:00 / 下午 15:00 / 晚 18:00 / 夜 21:00 等代表时段}
|
||||
|
||||
---
|
||||
|
||||
## 生活建议
|
||||
|
||||
穿搭:{根据温度范围和体感温差给出建议}
|
||||
运动:{根据天气和 AQI 给出运动建议}
|
||||
防晒:{根据 UV 指数给出建议}
|
||||
健康:{根据温差、湿度、降水提醒注意事项}
|
||||
交通:{根据降水和能见度给出出行建议}
|
||||
```
|
||||
|
||||
## 根据不同 forecast_type 的输出重点
|
||||
|
||||
| forecast_type | 输出章节 |
|
||||
|---|---|
|
||||
| `today`(默认) | 当前实况 + 降水趋势 + 今日走势 + 生活建议 |
|
||||
| `tomorrow` | 当前实况 + 降水趋势 + 明日逐小时走势 + 生活建议 |
|
||||
| `week` | 当前实况 + 未来预报(每日表)+ 生活建议 |
|
||||
| `hourly` | 当前实况 + 完整逐小时表(筛选重点时段)|
|
||||
|
||||
### tomorrow 模式
|
||||
|
||||
```
|
||||
## 明日 {城市} · 天气概况
|
||||
|
||||
| 项目 | 数值 |
|
||||
|------|------|
|
||||
| 天气 | 晴 |
|
||||
| 最低~最高 | 18~31°C |
|
||||
| 降水 | 5% |
|
||||
| 紫外线 | 中等 |
|
||||
|
||||
## 明日逐小时走势
|
||||
|
||||
| 时间 | 温度 | 天气 | 降水 | 体感 |
|
||||
...
|
||||
```
|
||||
|
||||
### week 模式
|
||||
|
||||
```
|
||||
## 未来预报
|
||||
|
||||
| 日期 | 天气 | 最低~最高 | 降水 | 紫外线 |
|
||||
|------|------|-----------|------|--------|
|
||||
| 明天 5/12 (一) | 晴 | 18~31°C | 5% | 中等 |
|
||||
| 后天 5/13 (二) | 多云 | 20~29°C | 20% | 中等 |
|
||||
```
|
||||
|
||||
展示未来 5-7 天。
|
||||
|
||||
## 数据缺失处理
|
||||
|
||||
- 某个字段不存在 → 跳过该行
|
||||
- 某个字段为 null → 不展示
|
||||
- 不编造数据
|
||||
- 日出日落数据没有则不展示
|
||||
|
||||
## 格式要点
|
||||
|
||||
- `##` 标题切分各章节(mdprint 渲染为 `▪` 符号 + Monet 配色)
|
||||
- 表格 | 对齐数据(`:` 控制对齐)
|
||||
- `---` 横线分隔各章节
|
||||
- 温度统一 `°C`,风速 `km/h`
|
||||
- 每个章节之间空一行
|
||||
57
catalog.go
57
catalog.go
@@ -51,6 +51,7 @@ type CatalogSkill struct {
|
||||
|
||||
type CatalogAgent struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
Path string `yaml:"path"`
|
||||
Description string `yaml:"description"`
|
||||
Tools []string `yaml:"tools"`
|
||||
@@ -84,22 +85,37 @@ func buildToolList() []CatalogTool {
|
||||
Source: "src/tool.go",
|
||||
}
|
||||
|
||||
// 从 JSON Schema 提取参数
|
||||
// 从 Schema (map[string]any) 提取参数
|
||||
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
|
||||
if t.Parameters == nil {
|
||||
list = append(list, ct)
|
||||
continue
|
||||
}
|
||||
|
||||
props, _ := t.Parameters["properties"].(map[string]any)
|
||||
required := make(map[string]bool)
|
||||
if reqList, ok := t.Parameters["required"].([]any); ok {
|
||||
for _, r := range reqList {
|
||||
if s, ok := r.(string); ok {
|
||||
required[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, propRaw := range props {
|
||||
prop, ok := propRaw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ, _ := prop["type"].(string)
|
||||
desc, _ := prop["description"].(string)
|
||||
ct.Parameters[name] = ParameterField{
|
||||
Type: prop.Type,
|
||||
Required: required,
|
||||
Description: prop.Description,
|
||||
Type: typ,
|
||||
Required: required[name],
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
list = append(list, ct)
|
||||
}
|
||||
return list
|
||||
@@ -181,8 +197,14 @@ func scanAgents() []CatalogAgent {
|
||||
continue
|
||||
}
|
||||
|
||||
agentType := "main"
|
||||
if t, ok := fm["type"]; ok {
|
||||
agentType = fmt.Sprintf("%v", t)
|
||||
}
|
||||
|
||||
cat := CatalogAgent{
|
||||
Name: fmt.Sprintf("%v", fm["name"]),
|
||||
Type: agentType,
|
||||
Path: fmt.Sprintf("agents/%s", e.Name()),
|
||||
Status: "active",
|
||||
}
|
||||
@@ -244,6 +266,21 @@ func GenerateToolsYAML() {
|
||||
// 注入目录到 system prompt
|
||||
// ============================================================
|
||||
|
||||
// BuildSubAgentPrompt 生成可用子 Agent 列表,动态注入到主 Agent 的 system prompt
|
||||
func BuildSubAgentPrompt(subs []*AgentDef) string {
|
||||
if len(subs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n## 可用子 Agent\n")
|
||||
b.WriteString("\n以下子 Agent 可通过 task 工具调度:\n")
|
||||
for _, s := range subs {
|
||||
b.WriteString(fmt.Sprintf("- **%s**: %s\n", s.Name, s.Description))
|
||||
}
|
||||
b.WriteString("\n用 `task(\"agent_name\", {args})` 调度。不自己回答领域问题。\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BuildInjectPrompt 生成能力边界目录,追加到 system prompt 末尾
|
||||
func BuildInjectPrompt(toolNames []string) string {
|
||||
var b strings.Builder
|
||||
|
||||
12
config.go
12
config.go
@@ -58,13 +58,9 @@ func migrateOldConfig() {
|
||||
os.RemoveAll(old)
|
||||
}
|
||||
|
||||
// LoadConfig 从 ~/.config/yunshu/config.yaml 读取配置
|
||||
// 如果新目录不存在,自动从旧 weather-cli 目录迁移
|
||||
// LoadConfig 从 ~/.config/yunshu/config.yml 读取配置
|
||||
func LoadConfig() (*Config, error) {
|
||||
// 尝试自动迁移旧配置
|
||||
migrateOldConfig()
|
||||
|
||||
path := filepath.Join(ConfigDir(), "config.yaml")
|
||||
path := filepath.Join(ConfigDir(), "config.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -77,14 +73,14 @@ func LoadConfig() (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig 将配置写入 ~/.config/yunshu/config.yaml
|
||||
// SaveConfig 将配置写入 ~/.config/yunshu/config.yml
|
||||
func SaveConfig(cfg *Config) error {
|
||||
dir := ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
161
docs/AGENTS.md
161
docs/AGENTS.md
@@ -24,39 +24,81 @@
|
||||
## Agent 定义规范(.md 文件)
|
||||
|
||||
- 必须包含 YAML frontmatter(以 `---` 包裹)
|
||||
- frontmatter 必需字段:`name`, `description`, `tools`
|
||||
- frontmatter 必需字段:`name`, `description`, `type`, `tools`
|
||||
- `type` 可选值:`main`(主持者/对话 Agent,唯一入口)、`sub`(领域专家,被 task 调)
|
||||
- `cache` 可选字段:`ttl`(过期秒数)、`keys`(从 args 中提取的缓存 key 字段列表)
|
||||
- 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`,不污染项目目录
|
||||
- session 文件存在 `~/.config/yunshu/session.json`,不污染项目目录
|
||||
|
||||
### 示例
|
||||
### 主持者示例(type: main)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: weather-agent
|
||||
description: 天气情报官
|
||||
name: dialog
|
||||
type: main
|
||||
description: 个人助理,负责闲聊和调度
|
||||
tools:
|
||||
- task
|
||||
- memory.read
|
||||
- memory.write
|
||||
---
|
||||
|
||||
# 对话助理
|
||||
你是用户的私人助理...
|
||||
|
||||
你可以调度以下子 Agent:
|
||||
- weather: 天气查询
|
||||
- earthquake: 地震信息
|
||||
|
||||
用 task("agent_name", {args}) 调度。
|
||||
不自己回答领域问题。
|
||||
```
|
||||
|
||||
### 子 Agent 示例(type: sub)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: weather
|
||||
type: sub
|
||||
description: 天气查询专家
|
||||
cache:
|
||||
ttl: 7200
|
||||
keys: ["city", "forecast_type"]
|
||||
tools:
|
||||
- http-get
|
||||
- geocode
|
||||
- skill
|
||||
---
|
||||
|
||||
# 天气情报官
|
||||
你是专业的天气情报官。
|
||||
# 天气专家
|
||||
你是天气领域的专家。被调时才回答。
|
||||
|
||||
## 工作流程
|
||||
1. 识别城市 → 调用 geocode 获取坐标
|
||||
2. 调用 skill("msn-weather-api") 获取 API 参数
|
||||
3. 调用 http-get 请求天气数据
|
||||
4. 分析并输出
|
||||
被调时你会收到:
|
||||
- args: 查询参数
|
||||
- cache_data: 上次缓存的原始数据(有则传,无则 null)
|
||||
|
||||
有 cache_data 且未过期 → 直接回答,不使用 http-get。
|
||||
无 cache_data → 调 http-get 获取新数据。
|
||||
|
||||
返回格式(两段式):
|
||||
---RESULT---
|
||||
{结构化 JSON 数据,如 {"temp": 25, "condition": "晴"}}
|
||||
---TEXT---
|
||||
你想要对用户说的文本
|
||||
```
|
||||
|
||||
**返回协议说明**:
|
||||
- `---RESULT---`:原始数据,进缓存,不进 dialog 上下文
|
||||
- `---TEXT---`:陈述文本,进 dialog 上下文,由 dialog 用自己的风格说出去
|
||||
- 如果本次没有新数据(比如 cache 命中后直接复述),可不带 `---RESULT---`
|
||||
|
||||
## Session 规范
|
||||
|
||||
- 文件路径:`~/.config/yunshu/session.json`
|
||||
- 文件路径:`~/.config/yunshu/session/session.json`
|
||||
- 格式:JSON 数组,元素为 Message 对象(兼容 OpenAI Chat Completion messages 格式)
|
||||
- 角色类型:`system`, `user`, `assistant`, `tool`
|
||||
- 启动时清空,每轮对话追加
|
||||
@@ -65,10 +107,57 @@ tools:
|
||||
|
||||
## 工具注册规范
|
||||
|
||||
- 工具在 `tool.go` 的 `init()` 中通过 `RegisterTool()` 注册
|
||||
- 每个工具定义:Name, Description, Parameters(JSON Schema), Execute 函数
|
||||
- 工具在 `tool.go` 的 `init()` 中通过 `RegisterTool()` + `NewTool[T]()` 注册
|
||||
- `NewTool[T any](name, desc string, fn func(T) (string, error))` 泛型函数自动处理一切:
|
||||
- 用 `toolschema.go`(`structToSchema` + `typeToSchema`)反射推导 JSON Schema
|
||||
- JSON 往返桥接:`map[string]any` ↔ 类型安全的输入结构体
|
||||
- handler 内直接访问结构体字段,无需 `args["x"].(string)` 类型断言
|
||||
- 注册路径:`tool.go:init()` → `NewTool[T]()` → `RegisterTool()`
|
||||
- 工具名与 `.md` 文件中声明的 tools 列表对应
|
||||
- Execute 函数接收 `map[string]interface{}` 参数,返回 string 和 error
|
||||
- 新增工具三步:定义输入结构体(struct tags: json, description, enum)→ 写 handler → 调用 `NewTool[T]()`
|
||||
|
||||
## 日志系统
|
||||
|
||||
日志系统分两层:**控制台输出**(charmbracelet/log → stderr)和**文件持久化**(YAML 序列 → `log.yml`)。
|
||||
|
||||
### 日志写入
|
||||
|
||||
```go
|
||||
warnLog(msg string, keyvals ...any) // 写 log.yml + 可选 stderr
|
||||
errorLog(msg string, keyvals ...any) // 同上
|
||||
infoLog(msg string, keyvals ...any) // 同上
|
||||
```
|
||||
|
||||
- stderr 输出受 `logToStderr` 全局开关控制(交互模式默认关,`/log on` 开启)
|
||||
- `log.yml` 始终写入,YAML 序列格式,每次读→追加→重写
|
||||
|
||||
### 日志查看
|
||||
|
||||
`yunshu log` 子命令:
|
||||
|
||||
```
|
||||
yunshu log → 全量显示(时间倒序)
|
||||
yunshu log --top N → 只看最后 N 条
|
||||
yunshu log --level warn → 过滤级别
|
||||
yunshu log --clear → 清空
|
||||
yunshu log --watch → 监听模式(2s 轮询)
|
||||
```
|
||||
|
||||
### 日志点
|
||||
|
||||
```
|
||||
[INFO] task(weather) 开始
|
||||
[INFO] 子 Agent 开始 agent=weather
|
||||
[INFO] LLM 调用完成 tokens=1420 duration=3.2s
|
||||
[WARN] 解析 session.json 失败 err=...
|
||||
```
|
||||
|
||||
关键原则:
|
||||
- 成功日志用 `infoLog`(task 开始/完成、LLM 调用完成、会话清空)
|
||||
- 异常日志用 `warnLog`(解析失败、文件不存在的异常错误)
|
||||
- 错误日志用 `errorLog`(工具执行失败、子 Agent 未找到)
|
||||
|
||||
---
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -79,7 +168,7 @@ tools:
|
||||
| `LLM_MODEL` | 否 | 模型名,覆盖配置文件 |
|
||||
| `OPENAI_API_KEY` | 否 | 兼容旧名,当 `LLM_API_KEY` 未设置时生效 |
|
||||
|
||||
> *注:可在 `~/.config/yunshu/config.yaml` 中配置,无需环境变量。
|
||||
> *注:可在 `~/.config/yunshu/config.yml` 中配置,无需环境变量。
|
||||
> 首次使用请运行 `yunshu onboard` 交互式初始化。
|
||||
|
||||
---
|
||||
@@ -127,3 +216,41 @@ tools:
|
||||
2. **ANSI 光标移动需要启用输出 VT 处理**:`\033[A`(光标上移)/ `\033[J`(清屏)等输出序列需要输出句柄设置 `ENABLE_VIRTUAL_TERMINAL_PROCESSING`(0x0004)才能生效。只设置输入句柄的 mode 不够,输入输出句柄是两个独立的控制台句柄。
|
||||
|
||||
3. **ReadLineWithCompletion / Completer 类型已移除**。`completer.go` 清空,`main.go` 回到 `termui.ReadLine()`。`cmdCompleter`、`commonPrefix`、相关测试一并移除。`input.go` 中 `ReadConsoleInputW` 相关的 `keyEventRecord`/`inputRecord` 结构和 proc 也一并清理。
|
||||
|
||||
### 2026-05-11
|
||||
|
||||
1. **MSN 天气存在 `hourlyforecast` 端点**:`https://assets.msn.cn/service/weather/hourlyforecast`,返回未来 10 天逐小时预报数据,参数和 dailyforecast 一致。之前文档遗漏了该端点。`weathertrends` 端点已失效(500 Internal Server Error)。
|
||||
|
||||
2. **MSN 的 `assets.msn.cn` 国内城市接口正常**:用经纬度查询 `assets.msn.cn/service/weather/current` 对国内城市返回正确数据。之前 Agent 误报"返回也门数据"是因为用了 `api.msn.cn` 的城市名接口(该接口本就有文档标注的问题:城市名匹配不可靠)。**用 `assets.msn.cn` + 经纬度即可正常获取国内城市数据,不需要切换到 wttr.in。**
|
||||
|
||||
3. **会议室架构决策**:确定从单 Agent 升级为 1 主持(dialog, type:main)+ N 领域专家(type:sub)+ 共享黑板(memory)的架构。主持者保持极薄(只有人格 + 调度规则 + `task` + `memory` 工具),子 Agent 只做领域工作,用完即毁。详见 `docs/会议室架构计划书.md`。
|
||||
|
||||
4. **Cache 设计原则**:Frontmatter 声明 `cache.keys` 和 `cache.ttl`,`task` 工具机械化从 args 取值拼 key、查/写缓存。子 Agent 不感知缓存存在,只需在回答末尾可选带 `---CACHE---` + JSON 供 task 存储。一个 Agent 一个缓存 JSON 文件,MD5 hash 做 key。
|
||||
|
||||
5. **记忆系统规则**:共享黑板模式,所有 Agent 可读,仅 memory Agent 可写。dialog-agent 是最小写入者(只写 `dialog_context`),memory Agent 负责从对话中提取用户画像写入长期记忆。
|
||||
|
||||
6. **子 Agent 返回协议 `---RESULT---` / `---TEXT---` 双段设计**:子 Agent 回答分两段——RESULT 是原始结构化数据(进缓存,不进 dialog 上下文),TEXT 是陈述文本(进 dialog 上下文,由 dialog 用自己的风格输出)。这样 dialog 不会产生"复述感",子 Agent 的数据也保持干净。
|
||||
|
||||
7. **两次 LLM 调用架构**:用户提问 → dialog 的 LLM 判断 → `task` 工具调子 Agent 的 LLM(第一次)→ 子 Agent 返回 TEXT → dialog 的 LLM 用自己的风格说出去(第二次)。各司其职,职责单一。
|
||||
|
||||
8. **`task` 工具内部调用 `RunSubAgent()` 实现子 Agent 执行**:`RunSubAgent` 不读写全局 session.json,子 Agent 的 tool_calls 用完即毁,不污染主上下文。返回后由 `task` 工具解析 `---RESULT---` / `---TEXT---`。
|
||||
|
||||
### 2026-05-16
|
||||
|
||||
1. **charmbracelet/log v2(`charm.land/log/v2`)用作结构化日志**。输出到 stderr 时自动带时间戳和级别前缀,彩色渲染。v2 模块路径改为 `charm.land/log/v2`(非 `github.com/charmbracelet/log/v2`)。`log.NewWithOptions(w, opt)` 创建实例,方法签名:`log.Warn(msg, keyvals...)`、`log.Error(msg, keyvals...)`、`log.Info(msg, keyvals...)`。
|
||||
|
||||
2. **log.yml 使用 YAML 序列追加策略**。每次写入先读(反序列化为 `[]logEntry`)→ 追加 → 重写(`yaml.Marshal`)。不是 JSONL,不是纯文本。适用于 CLI 工具的日志量级。`readLogs()` 返回全部条目用于 `yunshu log` 展示。
|
||||
|
||||
3. **`RunSubAgent` 加 `maxToolCalls=2` 安全上限**。子 Agent 的 LLM 循环中如果连续 2 轮都在调工具,第 3 轮强制返回 `---TEXT---\n(子 Agent 执行轮次超限,已终止)`。防止 prompt 设计缺陷导致子 Agent 死循环。
|
||||
|
||||
4. **单 Agent 查询性能优化**。dialog-agent 的 prompt 明确指示:对于只需调一个子 Agent 的查询,跳过 observation 写入和 summary 写入,直接输出子 Agent 结果。综合查询的 observation + summary 合并在同一轮 memory.write 调用。实测 note 保存从 ~90s 降至 ~10s。
|
||||
|
||||
5. **路径安全使用 `filepath.EvalSymlinks` + `filepath.Rel` 三段校验**:Clean → Join → EvalSymlinks → Rel。防止符号链接绕过路径限制。仅 `strings.Contains("..")` 不够,因为符号链接可以指向 ConfigDir 之外。`os.IsNotExist` 时跳过 EvalSymlinks(新文件创建场景)。
|
||||
|
||||
6. **`flag.ExitOnError` 用于 `yunshu log` 子命令 flag 解析**。遇到未知 flag 自动 exit(2) 并打印用法,比手动处理 flag 更简洁。与 `main.go` 的主 args 手动解析配合不冲突。
|
||||
|
||||
7. **流式输出使用 SSE 协议,OpenAI 兼容格式**。请求加 `"stream": true`,响应逐行 `data: {...}`,以 `data: [DONE]` 结尾。用 `bufio.Reader.ReadString('\n')` 逐行读取,不走 `io.ReadAll`。流式和非流式模式下 tool_calls 的表现不同:非流式一次返回完,流式按 index 分片到达,需要 `accumulatedToolCall` 累积合并。
|
||||
|
||||
8. **按 `\n\n` 段落边界缓冲后经 mdprint 渲染**。流式 token 是碎片,不能直接过 mdprint(后者需要完整 Markdown parse → AST → ANSI)。解决方案:将内容写入 blockBuf,在 `\n\n` 处切分——之前的是完整 block,调用 `mdprint.Print` 渲染到 stdout;之后的是不完整残段,留在 buffer 继续累积。流结束时 `mdprint.Print(blockBuf)` 刷最后一段。实测 weather 场景每个 section(标题/表格/分隔线)依次弹出,延迟 1-3s,渲染效果与非流式一致。
|
||||
|
||||
9. **流式模式下 model 可能先吐文本再吐 tool_call**。非流式 API 的 model 要么返回 content 要么返回 tool_calls,不会同时出现。但流式模式下,model 可能先输出一段"思考"文字,然后才决定调工具。这段文字已经显示给用户,之后工具返回又有第二轮 LLM 响应,就造成了"用户看到两段回复"的问题。**修复方式:prompt 层面要求 model"先调工具,不要先说话"**,在 dialog-agent.md 中新增"流式输出原则"章节。
|
||||
|
||||
@@ -19,13 +19,14 @@
|
||||
|
||||
| 接口 | URL | 功能 | 稳定性 |
|
||||
|------|-----|------|--------|
|
||||
| **当前天气** | `https://assets.msn.cn/service/weather/current` | 获取实时天气 | ✅ 稳定可用 |
|
||||
| **每日预报** | `https://assets.msn.cn/service/weather/dailyforecast` | 未来10天预报 | ✅ 稳定可用 |
|
||||
| **天气趋势** | `https://assets.msn.cn/service/weather/weathertrends` | 历史+趋势+日历 | ✅ 可用(参数复杂) |
|
||||
| **当前天气** | `https://assets.msn.cn/service/weather/current` | 获取实时天气+nowcasting | ✅ 稳定可用 |
|
||||
| **逐小时预报** | `https://assets.msn.cn/service/weather/hourlyforecast` | 未来10天逐小时预报 | ✅ 稳定可用(新增) |
|
||||
| **每日预报** | `https://assets.msn.cn/service/weather/dailyforecast` | 未来10天每日汇总 | ✅ 稳定可用 |
|
||||
| **天气趋势** | `https://assets.msn.cn/service/weather/weathertrends` | 历史+趋势+日历 | ❌ 已失效(500错误) |
|
||||
| api.msn.cn 当前 | `https://api.msn.cn/weather/current` | 用城市名获取 | ✅ 可用(但城市名不准) |
|
||||
| api.msn.cn 预报 | `https://api.msn.cn/weather/forecast` | 预报 | ❌ 500错误 |
|
||||
|
||||
**推荐**:只用 `assets.msn.cn` 的两个接口即可满足大部分需求。
|
||||
**推荐**:只用 `assets.msn.cn` 的三个接口(current + hourlyforecast + dailyforecast)即可满足大部分需求。
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +91,16 @@ 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"
|
||||
}
|
||||
|
||||
# 获取逐小时预报(今天剩余小时 + 后续几天)
|
||||
$uri_hourly = "https://assets.msn.cn/service/weather/hourlyforecast?apiKey=$apiKey&lat=39.904172&lon=116.407417&units=C&locale=zh-cn"
|
||||
$hourlyResp = Invoke-RestMethod -Uri $uri_hourly -Headers $headers
|
||||
$todayHourly = $hourlyResp.value[0].responses[0].weather[0].days[0].hourly
|
||||
|
||||
Write-Host "`n今天逐小时预报:"
|
||||
foreach ($h in $todayHourly) {
|
||||
Write-Host " $($h.valid.ToString("HH:mm")): $($h.temp)C, $($h.cap), 体感$($h.feels)C, 降水$($h.precip)%, 湿度$($h.rh)%, 风速$($h.windSpd)km/h"
|
||||
}
|
||||
```
|
||||
|
||||
### curl 示例
|
||||
@@ -104,6 +115,11 @@ curl -H "User-Agent: Mozilla/5.0" \
|
||||
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"
|
||||
|
||||
# 逐小时预报(今天剩余+未来几天)
|
||||
curl -H "User-Agent: Mozilla/5.0" \
|
||||
-H "Referer: https://www.msn.com/zh-cn/weather" \
|
||||
"https://assets.msn.cn/service/weather/hourlyforecast?apiKey=j5i4gDqHL6nGYwx5wi5kRhXjtf2c5qgFX9fzfk0TOo&lat=39.904172&lon=116.407417&units=C&locale=zh-cn"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -180,6 +196,52 @@ curl -H "User-Agent: Mozilla/5.0" \
|
||||
}
|
||||
```
|
||||
|
||||
### hourlyforecast 接口响应
|
||||
|
||||
```json
|
||||
{
|
||||
"@odata.context": "api.msn.com/weather/$metadata#hourlyforecast",
|
||||
"value": [{
|
||||
"responses": [{
|
||||
"weather": [{
|
||||
"days": [
|
||||
{
|
||||
// days[0] = 今天,从当前小时开始到23点
|
||||
// days[1..9] = 未来9天,每天24个整点
|
||||
"hourly": [
|
||||
{
|
||||
"valid": "2026-05-11T07:00:00+08:00", // 时间
|
||||
"temp": 19.0, // 温度 °C
|
||||
"feels": 23.0, // 体感温度 °C
|
||||
"cap": "晴", // 天气描述
|
||||
"precip": 0.0, // 降水概率 %
|
||||
"rh": 61.0, // 相对湿度 %
|
||||
"baro": 1009.0, // 气压 hPa
|
||||
"windSpd": 4.0, // 风速 km/h
|
||||
"windDir": 355, // 风向(度)
|
||||
"windGust": 18.0, // 阵风 km/h
|
||||
"uv": 1.0, // 紫外线指数
|
||||
"cloudCover": 6.0, // 云量 %
|
||||
"vis": 10.0, // 能见度 km
|
||||
"dewPt": 11.0, // 露点 °C
|
||||
"rainAmount": 0.0, // 降雨量 mm
|
||||
"snowAmount": 0.0, // 降雪量 mm
|
||||
"icon": 1, // 图标代码
|
||||
"symbol": "d000", // 天气符号
|
||||
"sky": "CLR" // 天空状况代码
|
||||
}
|
||||
// ... 更多小时
|
||||
]
|
||||
}
|
||||
]
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`days[0].daily` 为 null(今天尚未结束),逐小时数据从 `days[0].hourly` 获取。
|
||||
|
||||
---
|
||||
|
||||
## 六、多城市验证结果
|
||||
@@ -246,15 +308,21 @@ function Get-MSNWeather {
|
||||
$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
|
||||
|
||||
# 逐小时预报
|
||||
$hourlyUri = "https://assets.msn.cn/service/weather/hourlyforecast?apiKey=$apiKey&lat=$Lat&lon=$Lon&units=C&locale=$Locale"
|
||||
$hourly = Invoke-RestMethod -Uri $hourlyUri -Headers $headers
|
||||
|
||||
return @{
|
||||
Current = $current.value[0].responses[0].weather[0].current
|
||||
Forecast = $forecast.value[0].responses[0].weather[0].days
|
||||
Hourly = $hourly.value[0].responses[0].weather[0].days
|
||||
}
|
||||
}
|
||||
|
||||
# 使用示例
|
||||
$weather = Get-MSNWeather -Lat 39.904172 -Lon 116.407417
|
||||
$weather.Current.temp # 当前温度
|
||||
$weather.Hourly[0].hourly # 今天逐小时数据
|
||||
```
|
||||
|
||||
---
|
||||
@@ -290,8 +358,15 @@ http://img-s-msn-com.akamaized.net/tenant/amp/entityid/AAehR3S.img
|
||||
| 是否有免费 API | ✅ 有(非公开内部接口) |
|
||||
| 国内速度 | ✅ 快(msn.cn 国内节点) |
|
||||
| 稳定性 | ⚠️ 未知(非官方,随时可能变) |
|
||||
| 数据完整性 | ✅ 完整(当前+预报+AQI+紫外线等) |
|
||||
| 数据完整性 | ✅ 完整(当前+逐小时+每日预报+AQI+紫外线+nowcasting) |
|
||||
| 推荐用途 | 个人项目、内部工具、原型开发 |
|
||||
| 不推荐用途 | 商业产品、长期运行服务 |
|
||||
|
||||
## 现状更新
|
||||
|
||||
**2026-05-11 更新:**
|
||||
- `weathertrends` 接口已失效(500 Internal Server Error)
|
||||
- 新发现 `hourlyforecast` 接口,提供未来 10 天逐小时预报数据,与 `current`、`dailyforecast` 同样稳定
|
||||
- 云图(卫星/雷达)无 REST API 可用,MSN 网页使用 tile 图片服务(`assets.msn.com/weathermapdata/`),不适合程序化调用
|
||||
|
||||
**建议**:如果用于生产环境,推荐同时准备备用方案(如和风天气、OpenWeatherMap 等)。
|
||||
|
||||
@@ -60,24 +60,114 @@ pkg/
|
||||
└── termui/ 终端交互(行输入、模式设置)
|
||||
```
|
||||
|
||||
## 项目文件
|
||||
|
||||
```
|
||||
main.go CLI 入口(onboard/help/version/log 子命令)
|
||||
types.go 核心类型(AgentDef, Schema, ToolDef, Message…)
|
||||
loader.go .md 解析(frontmatter + body)
|
||||
catalog.go CatalogAgent 生成 + tools.yml 输出
|
||||
registry.go AgentRegistry(ScanAgents, GetMain, GetSub…)
|
||||
llm.go LLM API 封装(豆包/OpenAI,sync.Once 延迟加载)
|
||||
tool.go 工具注册 + safeMemoryPath + ExecuteTool + 7 个工具 handler
|
||||
toolschema.go 泛型+反射工具注册(NewTool[T], structToSchema)
|
||||
runtime.go RunAgent + RunSubAgent(maxToolCalls=2)+ cache + session
|
||||
logger.go charmbracelet/log v2 全局实例(→ stderr)
|
||||
log.go 双写 wrapper(warnLog/errorLog/infoLog)+ log.yml + yunshu log 命令
|
||||
```
|
||||
|
||||
## 当前 tools
|
||||
|
||||
| 工具名 | 作用 | 实现 |
|
||||
|--------|------|------|
|
||||
| http-get | HTTP GET 请求 | Go |
|
||||
| skill | 按需加载知识 | Go |
|
||||
| geocode | 城市名 → 坐标 | Go(调 wttr.in) |
|
||||
| read-file | 读取文件 | Go |
|
||||
| 工具名 | 作用 | 注册方式 |
|
||||
|--------|------|---------|
|
||||
| http-get | HTTP GET 请求 | `NewTool[HTTPGetInput]` |
|
||||
| skill | 按需加载知识 | `NewTool[SkillInput]` |
|
||||
| geocode | 城市名 → 坐标(调 wttr.in) | `NewTool[GeocodeInput]` |
|
||||
| read-file | 读取文件 | `NewTool[ReadFileInput]` |
|
||||
| task | 调度子 Agent(含缓存管理 + 多步骤编排) | `NewTool[TaskInput]` |
|
||||
| memory.read | 读取 config/session/notes 等记忆文件 | `NewTool[MemoryReadInput]` |
|
||||
| memory.write | 写入记忆文件(.md 按 ## 标题合并,.yml 按 key 合并) | `NewTool[MemoryWriteInput]` |
|
||||
|
||||
## 后续演进
|
||||
所有工具都通过 `NewTool[T]` 泛型函数注册,输入结构体自动反射生成 JSON Schema,handler 内参数为类型安全的结构体字段。
|
||||
|
||||
## 核心流程
|
||||
|
||||
```
|
||||
云枢·Agent (三层分离+单agent)
|
||||
用户输入
|
||||
↓
|
||||
河虾 claw (三层分离+主-从)
|
||||
├─ master: 意图识别+任务分发
|
||||
├─ weather-subagent
|
||||
├─ tts-subagent
|
||||
├─ asr-subagent
|
||||
└─ ...更多 subagent
|
||||
RunAgent → CallLLMStream (SSE 流式,\n\n 段落缓冲 → mdprint 渲染)
|
||||
├─ 流内容到达 → tryFlushBlocks 检测 \n\n
|
||||
│ ├─ 完整 block → mdprint.Print 渲染到 stdout
|
||||
│ └─ 残段 → 留在 blockBuf 继续缓冲
|
||||
├─ 流结束 → mdprint.Print(blockBuf) 刷残段
|
||||
├─ 返回 tool_calls(累积重建)→ 继续循环
|
||||
│ ├─ task(weather/train/hotel/…) → RunSubAgent → TEXT → 回对话
|
||||
│ │ ├── maxToolCalls=2 兜底
|
||||
│ │ └── 每步写 infoLog/warnLog → log.yml
|
||||
│ ├─ task(profile) → 提取用户画像写入 config/user.md
|
||||
│ ├─ task(note) → 保存/查询笔记 (notes.md / notes/*.md)
|
||||
│ ├─ memory.read → 读 config/user.md / session/dialog.yml / soul.md / notes.md
|
||||
│ ├─ memory.write → 写 config/ session/ notes/ 各文件
|
||||
│ └─ 其他工具 (http-get, geocode, …)
|
||||
└─ 返回 text → 流已渲染完毕 → 结束
|
||||
单 Agent 查询跳过 observation + summary;综合查询合并同一轮写
|
||||
```
|
||||
|
||||
## 当前状态(2026-05-16 v2.3.0)
|
||||
|
||||
```
|
||||
yunshu (会议室架构 — 核心引擎 + 日志 + 画像 + 备忘录)
|
||||
├── dialog-agent.md (type: main,主持者)
|
||||
├── weather-sub.md (type: sub,天气) ✅
|
||||
├── profile-sub.md (type: sub,用户画像) ✅
|
||||
├── note-sub.md (type: sub,备忘录) ✅
|
||||
├── ✨ 日志系统 ✅ charmbracelet/log v2 + log.yml + yunshu log
|
||||
├── ✨ LLM 延迟加载 ✅ sync.Once,--help 不读 config
|
||||
├── ✨ 路径安全 ✅ EvalSymlinks + filepath.Rel
|
||||
├── ✨ 热加载 ✅ 交互模式每轮 ScanAgents()
|
||||
├── ✨ 会话裁剪 ✅ LoadSession 限 40 条
|
||||
├── ✨ 流式输出 ✅ SSE 流式 + \n\n 段落缓冲 + mdprint
|
||||
├── ✨ 性能优化 ✅ note 一步完成 + obs/summary 合并 + maxToolCalls=2
|
||||
├── earthquake-sub.md (type: sub,地震) ❌ 待实现
|
||||
├── train-sub.md (type: sub,火车票) ❌ 待实现
|
||||
├── hotel-sub.md (type: sub,住宿) ❌ 待实现
|
||||
└── narrator-sub.md (type: sub,汇报员/成熟期) ❌ 待实现
|
||||
```
|
||||
|
||||
## 存储结构
|
||||
|
||||
```
|
||||
~/.config/yunshu/
|
||||
├── config/
|
||||
│ ├── config.yml ← LLM 配置(已有)
|
||||
│ ├── soul.md ← AI 灵魂(用户可编辑)
|
||||
│ └── user.md ← 用户画像(profile-sub 写 ## 画像,dialog 写 ## AI观察到)
|
||||
├── session/
|
||||
│ ├── session.json ← 完整对话历史(直接 POST API)
|
||||
│ └── dialog.yml ← 对话摘要(dialog 每轮写入)
|
||||
├── notes.md ← 备忘录(note-sub 维护,列表格式)
|
||||
├── notes/ ← 独立笔记文件(复杂内容用)
|
||||
├── log.yml ← YAML 序列日志(yunshu log 命令查看)
|
||||
├── cache/ ← 子 Agent 缓存
|
||||
├── agents/ ← Agent 定义
|
||||
├── skills/ ← 知识技能
|
||||
└── memory.json ← ❌ 已删除(迁移完毕)
|
||||
```
|
||||
|
||||
## 写入策略
|
||||
|
||||
| 文件 | 写入方式 | 说明 |
|
||||
|------|---------|------|
|
||||
| `config/user.md` | `##` 标题合并 | 各板块独立更新,互不覆盖 |
|
||||
| `session/dialog.yml` | key 合并 | 每轮覆写对话摘要 |
|
||||
| `notes.md` | 全文覆写 | note-sub 全量管理 |
|
||||
| `notes/` 独立文件 | 全文覆写 | 每个笔记一个文件 |
|
||||
| `log.yml` | YAML 序列追加 | 读→追加→Marshal→写,双写模式 |
|
||||
```
|
||||
|
||||
## 架构文档
|
||||
|
||||
- `docs/会议室架构计划书.md` — 完整设计方案
|
||||
- `docs/AGENTS.md` — 编码规范
|
||||
- `docs/changelog.md` — 变更日志
|
||||
- `docs/taolun.md` — 讨论历史
|
||||
|
||||
@@ -2,6 +2,300 @@
|
||||
|
||||
> 坐看云卷云舒,静听花开花落
|
||||
|
||||
## [2.3.0] - 2026-05-16
|
||||
|
||||
### 日志系统 + 性能优化 + 安全加固
|
||||
|
||||
#### 日志系统
|
||||
|
||||
引入 `charmbracelet/log` v2 作为结构化日志库,取代零散的 `log.Printf` / `fmt.Fprintln(os.Stderr, ...)`:
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| `logger.go` | charmbracelet/log 全局实例,输出到 stderr |
|
||||
| `log.go` | log.yml 持久化(YAML 序列追加)、双写 wrapper、`yunshu log` 命令 |
|
||||
|
||||
日志流向:
|
||||
|
||||
```
|
||||
warnLog/errorLog/infoLog
|
||||
├─ logToStderr ? → charmbracelet/log → stderr(交互模式默认关闭,/log on 开启)
|
||||
└─ 始终写入 → appendLog → log.yml(YAML 序列,位于 ~/.config/yunshu/log.yml)
|
||||
```
|
||||
|
||||
新增日志点:
|
||||
|
||||
```
|
||||
[INFO] task(weather) 开始
|
||||
[INFO] 子 Agent 开始 agent=weather
|
||||
[INFO] LLM 调用完成 tokens=1420 duration=3.2s
|
||||
[INFO] task(weather) 完成
|
||||
[WARN] 解析缓存失败 agent=weather err=...
|
||||
```
|
||||
|
||||
`yunshu log` 子命令:
|
||||
|
||||
```
|
||||
yunshu log → 全量显示(时间倒序)
|
||||
yunshu log --top N → 只看最后 N 条
|
||||
yunshu log --level warn → 过滤级别
|
||||
yunshu log --clear → 清空
|
||||
yunshu log --watch → 监听模式(2s 轮询)
|
||||
```
|
||||
|
||||
#### 流式输出
|
||||
|
||||
LLM 响应改为 SSE 流式输出,按 `\n\n` 段落边界缓冲后经 mdprint 渲染到 stdout:
|
||||
|
||||
| 组件 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| SSE 类型 | `llm.go` | `sseChunk`/`sseDelta`/`sseToolCallDelta` 等 |
|
||||
| `CallLLMStream` | `llm.go` | 请求加 `stream: true`,`bufio.Reader` 逐行解析 |
|
||||
| 段落缓冲 | `llm.go` | `blockBuf` + `tryFlushBlocks`:检测最后一个 `\n\n`,完成块过 mdprint,残段续传 |
|
||||
| 流结束刷残段 | `llm.go` | 末尾 `mdprint.Print(blockBuf)` 兜底 |
|
||||
| 重建响应 | `llm.go` | tool_call 按 index 累积 + SSE 碎片合并 |
|
||||
|
||||
关键设计:
|
||||
|
||||
```
|
||||
LLM SSE → blockBuf += content
|
||||
tryFlushBlocks:
|
||||
├─ 有 \n\n → 之前的部分 mdprint.Print(complete)
|
||||
│ → 剩余部分留在 blockBuf
|
||||
└─ 无 \n\n → stay
|
||||
流结束 → mdprint.Print(blockBuf)
|
||||
```
|
||||
|
||||
Prompt 适配(`agents/dialog-agent.md`):
|
||||
- 新增"流式输出原则":先调工具,不要先说话
|
||||
- 调度表 `回应 → task(profile)` 改为 `静默调 task,拿到结果后再回应`
|
||||
|
||||
#### 性能优化
|
||||
|
||||
| 优化 | 改动 | 效果 |
|
||||
|------|------|------|
|
||||
| note-sub 一步完成 | prompt 收紧:read→write→立即返回 | note 保存从 ~50s 降至 ~10s |
|
||||
| dialog 合并 obs+summary | 单 Agent 查询跳过观察和摘要;综合查询合并为一轮 | 节省 ~30s |
|
||||
| maxToolCalls=2 | RunSubAgent 超限兜底 | 防止死循环 |
|
||||
|
||||
#### 代码加固
|
||||
|
||||
| 改动 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| LLM 延迟加载 | `llm.go` | `init()` → `sync.Once`,`--help` 不再读 config |
|
||||
| 路径安全检查 | `tool.go` | `safeMemoryPath()`:Clean + EvalSymlinks + Rel 三段校验 |
|
||||
| 静默错误 → warnLog | 8 处 across runtime/tool/registry | 所有 `json.Unmarshal` / `yaml.Unmarshal` 吞掉的错误改为结构化日志 |
|
||||
| 对话记忆修剪 | `runtime.go` | `LoadSession` 只返回最近 40 条消息 |
|
||||
| Onboard 补齐 | `onboard.go` | `ensureUserConfig()` 创建 user.md/soul.md/notes/ |
|
||||
| 热加载 | `main.go` | 交互模式每轮 `ScanAgents()`,新增 agent 即时生效 |
|
||||
|
||||
#### UX 改进
|
||||
|
||||
- 交互模式默认不显示日志(`/log on` 开启)
|
||||
- `--help` 移除环境变量章节,增加 `log` 命令说明
|
||||
- 单次查询模式保留日志显示
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] - 2026-05-16
|
||||
|
||||
### 存储重组:session 目录 + .yml 统一后缀
|
||||
|
||||
#### 目录结构调整
|
||||
|
||||
| 旧路径 | 新路径 | 原因 |
|
||||
|--------|--------|------|
|
||||
| `session.json` | `session/session.json` | 对话会话文件归入 session 目录 |
|
||||
| `context/dialog.yaml` | `session/dialog.yml` | context 语义模糊,与 session 合并;.yml 后缀 |
|
||||
| `config.yaml` | `config.yml` | 统一后缀 |
|
||||
| `log.yaml` | `log.yml` | 统一后缀 |
|
||||
| `context/` | 已删除 | 文件移至 session/ |
|
||||
|
||||
所有 yaml 文件统一使用 `.yml` 后缀。配置文件、对话摘要、日志文件全部一致性调整。
|
||||
|
||||
#### 代码改动
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `runtime.go` | `sessionPath()` 改为 `session/session.json` |
|
||||
| `config.go` | `LoadConfig`/`SaveConfig` 读写 `config.yml` |
|
||||
| `onboard.go` | 提示信息改为 `config.yml` |
|
||||
| `main.go` | 新增 `migrateFilePaths()` 处理所有旧路径迁移 |
|
||||
| `tool.go` | 描述字符串更新 `context/`→`session/`,`.yaml`→`.yml` |
|
||||
| `agents/dialog-agent.md` | 所有 `context/dialog.yaml`→`session/dialog.yml` |
|
||||
| `docs/` | 存储树、写入策略、编码规范全部同步更新 |
|
||||
|
||||
#### 迁移逻辑
|
||||
|
||||
`migrateFilePaths()` 在启动时自动处理:
|
||||
- `config.yaml` → 复制到 `config.yml`,删除旧文件
|
||||
- `session.json` → 复制到 `session/session.json`,删除旧文件
|
||||
- `context/dialog.yaml` → 复制到 `session/dialog.yml`,删除旧文件及空目录
|
||||
- `log.yaml` → 复制到 `log.yml`,删除旧文件
|
||||
|
||||
所有迁移仅在目标文件不存在时执行,支持幂等。
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] - 2026-05-16
|
||||
|
||||
### 用户画像 + 备忘录系统
|
||||
|
||||
#### 拆分 memory.json
|
||||
|
||||
旧的 `memory.json` 一锅烩,现在拆成**按用途分文件**:
|
||||
|
||||
| 旧文件 | 新文件 | 格式 | 维护者 |
|
||||
|--------|--------|------|--------|
|
||||
| `memory.json["personality"]` | `config/soul.md` | Markdown | 用户手动编辑 |
|
||||
| `memory.json["dialog_context"]` | `context/dialog.yaml` | YAML | dialog 每轮写入 |
|
||||
| `memory.json["agent_errors"]` | `log.yaml` | YAML | 系统追加 |
|
||||
| (不存在) | `config/user.md` | Markdown | profile-sub 维护 |
|
||||
| (不存在) | `notes.md` + `notes/*.md` | Markdown | note-sub 维护 |
|
||||
|
||||
迁移逻辑在 `main.go:migrateMemoryJSON()`,启动时自动检测并迁移,确认完成后再删除 `memory.json`。
|
||||
|
||||
#### 新增子 Agent
|
||||
|
||||
| Agent | 文件 | 用途 |
|
||||
|-------|------|------|
|
||||
| profile-sub | `agents/profile-sub.md` | 从对话中提取用户画像,增量合并到 `config/user.md` |
|
||||
| note-sub | `agents/note-sub.md` | 笔记管理。默认 `notes.md` 列表,复杂内容可存为 `notes/{name}.md` |
|
||||
|
||||
#### memory.read/write 工具改造
|
||||
|
||||
从旧的 flat JSON key-value 改为路径路由:
|
||||
|
||||
- `.md` 文件 → 全文覆写(`memory.write("config/user.md", markdown_str)`)
|
||||
- `.yaml` 文件 → 合并写入(`memory.write("context/dialog.yaml", {topic, last_agent})`)
|
||||
- 目录 → 返回文件列表
|
||||
- 安全检查:拦截 `..` 遍历和绝对路径
|
||||
- 目录自动创建
|
||||
|
||||
#### dialog-agent.md 更新
|
||||
|
||||
- 新增调度规则:检测用户透露个人信息 → 调 `task("profile", ...)` 提取画像
|
||||
- 新增调度规则:检测"帮我记住" → 调 `task("note", ...)` 存备忘录
|
||||
- 对话摘要改为写入 `context/dialog.yaml`,而非旧 `memory.json["dialog_context"]`
|
||||
- 用户画像改为读取 `config/user.md`
|
||||
- 新增观察规则:每轮回复后向 `## AI观察到` 段写入语气/情绪/性格观察
|
||||
|
||||
#### heading-aware merge
|
||||
|
||||
`memory.write` 对 `.md` 文件由全文覆写改为按 `##` 标题合并:
|
||||
|
||||
- `memory.write("config/user.md", "## 画像\n...")` 只替换 `## 画像` 段,`## AI观察到` 段不受影响
|
||||
- 同一 writer 可多次写入同一标题,每次覆盖该段内容
|
||||
- 不同 writer 写不同标题,互不干扰
|
||||
- 底层函数 `mdMerge()` 按 `##` 拆分 → 标题匹配 → 重组
|
||||
|
||||
#### profile-sub.md 更新
|
||||
|
||||
- 改为写 `## 画像` 段(不再写整篇 user.md)
|
||||
- 文档规范化:给出 `## 画像` 格式示例
|
||||
- 强调不破坏其他段(`## AI观察到` 等)
|
||||
|
||||
---
|
||||
|
||||
### 发布会:会议室架构核心引擎就绪
|
||||
|
||||
**架构规划:会议室模式**(阶段一全部完成,超计划交付)
|
||||
|
||||
#### 核心引擎
|
||||
|
||||
| 组件 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| Agent 注册中心 | `registry.go` | `ScanAgents()` 按 `type`(main/sub) 分类,用户目录覆盖 |
|
||||
| 子 Agent 隔离运行 | `runtime.go`: `RunSubAgent` | 隔离 LLM 循环,返回 `---RESULT---`/`---TEXT---` |
|
||||
| 主 Agent 循环 | `runtime.go`: `RunAgent` | session 持久化 + mdprint 渲染 |
|
||||
| 缓存系统 | `runtime.go`: cache 辅助 | SHA256[:6] 拼 key,惰性过期 |
|
||||
| 工具目录生成 | `catalog.go` | `GenerateToolsYAML`、`BuildSubAgentPrompt` |
|
||||
|
||||
#### 新增工具
|
||||
|
||||
| 工具 | 注册方式 | 说明 |
|
||||
|------|---------|------|
|
||||
| `task` | `NewTool[TaskInput]` | 调度子 Agent + 缓存管理 |
|
||||
| `memory.read` | `NewTool[MemoryReadInput]` | 读 `~/.config/yunshu/memory.json` |
|
||||
| `memory.write` | `NewTool[MemoryWriteInput]` | 写长期记忆,value 支持任意 JSON 类型 |
|
||||
|
||||
#### 实际子 Agent
|
||||
|
||||
| Agent | 文件 | 状态 |
|
||||
|-------|------|------|
|
||||
| dialog-agent.md | `agents/dialog-agent.md` | 主持者(type:main)含多步骤编排指令 |
|
||||
| weather-sub.md | `agents/weather-sub.md` | 天气子 Agent(type:sub),Markdown 排版 + 生活建议 |
|
||||
|
||||
### ✨ 计划外新增
|
||||
|
||||
#### 泛型 + 反射工具注册(`toolschema.go`)
|
||||
|
||||
受 Charmbracelet/Fantasy 启发,引入 `NewTool[T any]()` 泛型构造函数:
|
||||
|
||||
- 输入结构体 `json`/`description`/`enum` tags → 自动反射生成 JSON Schema
|
||||
- 消除 ~120 行手写 Schema 模板代码(旧的 `ToolParameter`/`ToolProperty` 类型已删除)
|
||||
- handler 内参数为类型安全的结构体字段,无需 `args["x"].(string)` 类型断言
|
||||
- 支持嵌套结构体、slice、map、基础类型、interface{} 类型
|
||||
|
||||
#### 多步骤编排(runtime 改造)
|
||||
|
||||
移除 `capturedOutput` 覆写机制,子 Agent 结果作为普通工具响应留在对话上下文中:
|
||||
|
||||
- 主 Agent 可以连续多次调 `task()`(weather → train → hotel)
|
||||
- 每次返回后 LLM 继续推理,决定下一步
|
||||
- 信息收集完毕再综合回答,不再被 `capturedOutput` 截断
|
||||
- 单步骤查询(如纯天气)行为不变,LLM 按 prompt 指令直接输出子 Agent 结果
|
||||
|
||||
#### 其他变更
|
||||
|
||||
- `http-get.headers` 从 JSON 字符串改为 `map[string]string`,LLM 直接传对象
|
||||
- `memory.write.value` 从 `string` 改为 `interface{}`,直接存储任意 JSON 值
|
||||
- `types.go`:删除旧的 `ToolParameter`/`ToolProperty` 结构体,新增 `Schema(map[string]any)` 类型
|
||||
- `catalog.go`:`buildToolList` 适配新版 Schema
|
||||
- `agents/dialog-agent.md`:加入多步骤编排指令 + 数据传递说明
|
||||
|
||||
### 文档更新
|
||||
|
||||
- `docs/architecture.md`:更新工具列表、核心流程、当前状态
|
||||
- `docs/AGENTS.md`:工具注册规范更新为 `NewTool[T]` 方式
|
||||
- `docs/会议室架构计划书.md`:添加实现状态标记、多步骤编排章节、泛型注册章节
|
||||
|
||||
---
|
||||
|
||||
### 架构规划:会议室模式
|
||||
|
||||
完成从单 Agent 到"会议室架构"的完整设计,核心变更:
|
||||
|
||||
**新增角色体系**:
|
||||
- `type: main` — 主持者(对话 Agent),唯一用户入口
|
||||
- `type: sub` — 发言人(领域子 Agent),被 `task` 调才说话
|
||||
|
||||
**新增工具**(待实现):
|
||||
- `task` — 调度子 Agent + 缓存管理
|
||||
- `memory.read` / `memory.write` — 长期记忆读写
|
||||
|
||||
**新增 Cache 机制**:
|
||||
- 子 Agent Frontmatter 声明 `cache.ttl` + `cache.keys`
|
||||
- `task` 工具机械化拼 key、查/写缓存
|
||||
- 一个 Agent 一个缓存 JSON 文件,子 Agent 无感知
|
||||
|
||||
**设计文档**:
|
||||
- `docs/会议室架构计划书.md` — 完整架构方案
|
||||
- `docs/architecture.md` — 更新后续演进章节
|
||||
- `docs/AGENTS.md` — 更新 Agent 定义规范(type, cache 字段)
|
||||
- `docs/taolun.md` — 追加 2026-05-11 讨论历史
|
||||
|
||||
**MSN 天气接口更新**:
|
||||
- 新增 `hourlyforecast` 端点文档
|
||||
- 标记 `weathertrends` 为已失效
|
||||
- 更新 `skills/msn-weather-api/SKILL.md` 和 `agents/weather-agent.md`
|
||||
|
||||
### 技术细节
|
||||
- Frontmatter 新增 `type` 字段(main/sub)
|
||||
- Frontmatter 新增 `cache` 字段(`{ttl: int, keys: [string]}`)
|
||||
- 用户配置目录 `~/.config/yunshu/` 下可选覆盖 agents/
|
||||
- 详见 `docs/会议室架构计划书.md`
|
||||
|
||||
## [1.1.0] - 2026-05-09
|
||||
|
||||
### 发布摘要
|
||||
|
||||
122
docs/taolun.md
122
docs/taolun.md
@@ -242,3 +242,125 @@ Markdown 渲染器完成 AST 解析后,需要确定标题的终端展示风格
|
||||
### 验证
|
||||
- 19/19 单元测试通过
|
||||
- 构建成功,二进制运行正常
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-11 会议室架构:从单 Agent 到主-从调度
|
||||
|
||||
### 背景
|
||||
用户发现 `weathertrends` 接口失效,逐小时数据缺失。在排查中意外发现 `hourlyforecast` 端点存在且正常工作,文档之前遗漏了。同时回顾了与天气 Agent 的对话,发现 Agent 汇报 MSN 接口"国内城市不可用"的判断有误——实际 `assets.msn.cn` 按经纬度查一直正常,Agent 用的是 `api.msn.cn` 城市名接口(其文档本就标注"北京返回了也门萨那")。
|
||||
|
||||
这暴露了单 Agent 架构的局限性:Agent 的自我判断不可靠,上下文一多就容易出偏差。
|
||||
|
||||
### 讨论历程
|
||||
|
||||
#### 从"PicoClaw 为什么崩"出发
|
||||
|
||||
| PicoClaw 痛点 | 原因 |
|
||||
|--------------|------|
|
||||
| 上下文污染 | 所有知识/工具/历史全混在同一个 system prompt |
|
||||
| Skill 污染 | skill 内联大量技术细节,退化为长 prompt |
|
||||
| 逃避执行 | LLM 倾向"自己回答"而非调工具 |
|
||||
| 扩展困难 | 加能力 = 改代码或改长 prompt |
|
||||
|
||||
用户想从 0 实现一个干净的架构,先在云枢上验证,再移植到 HxClaw(河虾Claw)。
|
||||
|
||||
#### 方案演进
|
||||
|
||||
| 轮次 | 方案 | 问题 |
|
||||
|------|------|------|
|
||||
| 1 | CLI 切换主 Agent(`--agent weather/earthquake`) | 家庭用户记不住命令,跨域查询(火山附近天气)没法做 |
|
||||
| 2 | 唯一对话入口 + task 调度子 Agent | 但担心记忆管理和上下文混乱 |
|
||||
| 3 | 命名空间隔离记忆 | 无法共享用户画像(住通州 → 查地震也要知道住通州) |
|
||||
| 4 | **会议室模式**(最终方案) | 共享黑板(记忆)+ 主持者 + 发言人,角色隔离而非数据隔离 |
|
||||
|
||||
#### 关键设计决策
|
||||
|
||||
1. **主 Agent 即对话 Agent**(`type: main`),用户唯一入口,扮演"个人助理"角色
|
||||
2. **子 Agent**(`type: sub`)是领域专家,被 `task` 工具调才说话,不直接面对用户
|
||||
3. **`task` 工具**负责:加载子 Agent → 查/写缓存 → 调子 Agent LLM → 返回文本
|
||||
4. **Cache 机制**:Frontmatter 声明 `cache.keys`,`task` 工具机械化拼 key、查/写文件
|
||||
5. **记忆管理员**(`memory` 子 Agent):负责从对话中提取用户画像,写入记忆数据库
|
||||
6. **所有子 Agent 回答经过对话 Agent 返回给用户**,保持单一入口
|
||||
|
||||
#### 最终角色定义
|
||||
|
||||
| Agent | type | 职责 | 工具 | 缓存 |
|
||||
|-------|------|------|------|------|
|
||||
| dialog | main | 入口 + 聊天 + 调度 | `task`, `memory.read/write` | 无(本身就是对话历史) |
|
||||
| weather | sub | 查天气 | `http-get`, `geocode`, `skill` | `keys: [city, forecast_type]`, ttl: 7200 |
|
||||
| earthquake | sub | 查地震 | `http-get`, `skill` | `keys: [region]`, ttl: 300 |
|
||||
| memory | sub | 管理画像/长期记忆 | 读 memory.db | 无 |
|
||||
| narrator | sub(成熟期) | 格式化回答 | `memory.read` | 无 |
|
||||
|
||||
#### Cache 设计
|
||||
|
||||
```json
|
||||
// ~/.config/yunshu/cache/weather.json
|
||||
{
|
||||
"<hash_of_keys>": {
|
||||
"created_at": "2026-05-11T06:00:00+08:00",
|
||||
"ttl": 7200,
|
||||
"data": {...}, // 原始 API 数据
|
||||
"raw": {"city": "北京", "forecast_type": "today"} // 原始参数
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 子 Agent 每次回答末尾可选带 `---CACHE---` + JSON(只在数据更新时带)
|
||||
- `task` 工具查缓存:HIT → 把 `cache.data` 作为 `cache_data` 传给子 Agent;MISS → 子 Agent 自己查 API
|
||||
- 一个 Agent 一个缓存 JSON 文件,里面一个 map,key 是 hash
|
||||
|
||||
#### 会话存储
|
||||
|
||||
| 类型 | 位置 | 内容 | 生命周期 |
|
||||
|------|------|------|---------|
|
||||
| 对话历史 | `session.json` | 只存 user <-> dialog 的消息 | 每次启动清空 |
|
||||
| 子 Agent 内部 | 临时 | tool_calls、LLM 调用 | 用完即毁 |
|
||||
| 长期记忆 | 记忆数据库 | 用户画像、偏好、异常记录 | 持久 |
|
||||
|
||||
#### 对比结论
|
||||
|
||||
| | PicoClaw | 新方案 |
|
||||
|---|---|---|
|
||||
| 架构 | 单 Agent 全能 | 1 主持 + N 领域专家 |
|
||||
| 上下文 | 全混在 system prompt | Host 只有人格+调度,Sub 只有领域 |
|
||||
| 扩展 | 改代码或改长 prompt | 加一个 `.md` 文件 |
|
||||
| 记忆 | 无 | 共享黑板,Memory Agent 管写入 |
|
||||
| 工具污染 | 所有工具混在一起 | 按角色过滤 |
|
||||
| 失败影响 | 一个坏 tool_call 可能污染全部 | 子 Agent 用完即毁 |
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-16 多步骤编排与文档更新
|
||||
|
||||
### 背景
|
||||
用户提出真正想要的场景是"我要去北京" → 主 Agent 自动理解需要查天气、交通、住宿,依次调不同子 Agent,综合回答。当前 runtime 用 `capturedOutput` 覆写机制,子 Agent 结果被截走,主 Agent 没机会继续推理。
|
||||
|
||||
### 讨论:跨 Agent 数据传递
|
||||
|
||||
用户提出:opencode 可以在同上下文中切换不同 agent 继续工作,YunShu 是否也可以?
|
||||
|
||||
经过分析,结论:
|
||||
- `task` 工具调用子 Agent → 子 Agent 返回 TEXT → 普通工具响应,可以留对话上下文
|
||||
- 主 Agent 看到结果后,可以决定再调另一个子 Agent,也可以直接回答
|
||||
- **唯一需要改的**:砍掉 `capturedOutput` 覆写,让 LLM 的自然循环接管
|
||||
|
||||
### 决定:移除 capturedOutput,启用多步骤编排
|
||||
|
||||
在 `runtime.go` 去掉约 5 行代码(`capturedOutput` 变量、覆写逻辑),然后:
|
||||
|
||||
- 主 Agent 的 LLM 可以连续调多个子 Agent(weather → train → hotel)
|
||||
- 数据在步骤间通过对话上下文自然传递
|
||||
- prompt 告诉 LLM:信息不够就继续调,够了就综合回答
|
||||
- 单步骤查询(只要天气)行为不变——LLM 按指令直接输出子 Agent 结果
|
||||
|
||||
### 同步更新文档
|
||||
|
||||
| 文档 | 更新内容 |
|
||||
|------|---------|
|
||||
| `docs/architecture.md` | 工具列表、核心流程图、当前状态树 |
|
||||
| `docs/会议室架构计划书.md` | 实现状态标记、多步骤编排章节、文件结构、调用示例 |
|
||||
| `docs/AGENTS.md` | 工具注册规范改为 `NewTool[T]` 方式 |
|
||||
| `docs/changelog.md` | 新增 2.0.0 版本日志 |
|
||||
| `docs/taolun.md` | 追加本次讨论 |
|
||||
|
||||
535
docs/会议室架构计划书.md
Normal file
535
docs/会议室架构计划书.md
Normal file
@@ -0,0 +1,535 @@
|
||||
# 云枢·Agent 会议室架构计划书
|
||||
|
||||
> **生成日期**:2026-05-11
|
||||
> **最后更新**:2026-05-16
|
||||
> **目的**:从单 Agent 架构升级为"会议室模式"(1 主持 + N 领域专家 + 共享黑板)
|
||||
> **最终目标**:在云枢上验证通过后,移植到 HxClaw(河虾 Claw)
|
||||
>
|
||||
> **实现状态**:
|
||||
> - ✅ 核心引擎(registry + runtime + cache + session)
|
||||
> - ✅ 7 个工具(task, memory.read/write, http-get, skill, read-file, geocode)
|
||||
> - ✅ 泛型+反射工具注册(NewTool[T])
|
||||
> - ✅ 多步骤编排(主 Agent 连续调多个子 Agent)
|
||||
> - ✅ weather-sub.md 天气子 Agent
|
||||
> - ❌ memory-sub.md 记忆管理员子 Agent
|
||||
> - ❌ earthquake / train / hotel 等扩展子 Agent
|
||||
|
||||
---
|
||||
|
||||
## 一、架构总览
|
||||
|
||||
```
|
||||
用户
|
||||
│
|
||||
┌──────▼──────────────────────────────────────────┐
|
||||
│ 主持者(dialog-agent)type: main │
|
||||
│ 人格 + 调度规则 + task + memory 工具 │
|
||||
│ 唯一入口,用户只和它对话 │
|
||||
│ ✨ 可以连续多次调不同子 Agent,综合数据后回答 │
|
||||
└──────┬───────────────────────────────────────────┘
|
||||
│ task("weather", {city: "北京"}) ← 第一步
|
||||
│ ← 北京明天 5°C 晴
|
||||
│ task("train", {city: "北京", date: "明天"}) ← 第二步(看到天气后决定)
|
||||
│ ← G102 08:00 ¥680
|
||||
│ task("hotel", {city: "北京", nights: 3}) ← 第三步
|
||||
│ ← 建国饭店 ¥500/晚
|
||||
│ → 综合: "明天北京5°C…G102早8点…建国饭店…" ← 最终回答
|
||||
▼
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ 发言人(领域子 Agent)type: sub │
|
||||
│ weather / earthquake / memory / narrator │
|
||||
│ 被调才说话,返回文本 + 可选缓存数据 │
|
||||
│ 各自的 cache / skills / tools 互相隔离 │
|
||||
│ 不感知其他子 Agent 存在,结果由主 Agent 整合 │
|
||||
└───────────────────────────────────────────────┘
|
||||
│ 读写
|
||||
▼
|
||||
┌───────────────────────────────────────────┐
|
||||
│ 记录者(记忆系统) │
|
||||
│ 共享黑板:用户画像、偏好、异常记录 │
|
||||
│ memory Agent 负责从对话中提取有价值信息 │
|
||||
│ 所有 Agent 只读,仅 memory Agent 写入 │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、角色定义
|
||||
|
||||
### 2.1 主持者:dialog-agent(type: main)
|
||||
|
||||
**职责**:
|
||||
- 用户的唯一入口
|
||||
- 有血有肉的个人助理,能闲聊
|
||||
- 识别用户意图,**可以连续多次**调度不同的子 Agent
|
||||
- 每次 task() 返回后,决定继续调下一个还是综合回答(多步骤编排)
|
||||
- 把上一步子 Agent 的结果作为上下文,传递给下一次 task() 的参数
|
||||
- 读/写记忆(用户画像、上下文摘要)
|
||||
|
||||
**工具列表**:
|
||||
- `task` — 调度子 Agent
|
||||
- `memory.read` — 读长期记忆
|
||||
- `memory.write` — 写长期记忆
|
||||
|
||||
**System Prompt 包含**:
|
||||
- 人格(从 `memory.personality` 加载)
|
||||
- 调度规则(何时调哪个子 Agent)
|
||||
- 不含任何领域知识
|
||||
|
||||
**System Prompt 不包含**:
|
||||
- 天气知识、地震知识等
|
||||
- `http-get`、`geocode` 等具体工具
|
||||
|
||||
**Session**:
|
||||
- `session.json` 只存 user ↔ dialog 的对话轮次
|
||||
- 子 Agent 内部的 tool_calls 不写入
|
||||
|
||||
### 2.2 发言人:weather-sub(type: sub)
|
||||
|
||||
**职责**:
|
||||
- 响应天气查询
|
||||
- 被 `task` 调才执行,不直接面对用户
|
||||
- 返回显示文本 + 可选缓存数据
|
||||
|
||||
**工具列表**:`http-get`, `geocode`, `skill`
|
||||
|
||||
**Frontmatter**:
|
||||
|
||||
```yaml
|
||||
name: weather
|
||||
type: sub
|
||||
description: 天气查询专家
|
||||
cache:
|
||||
ttl: 7200
|
||||
keys: ["city", "forecast_type"]
|
||||
tools:
|
||||
- http-get
|
||||
- geocode
|
||||
- skill
|
||||
```
|
||||
|
||||
### 2.3 发言人:earthquake-sub(type: sub,预留)
|
||||
|
||||
**职责**:响应地震信息查询
|
||||
|
||||
**Frontmatter**:
|
||||
|
||||
```yaml
|
||||
name: earthquake
|
||||
type: sub
|
||||
description: 地震信息查询
|
||||
cache:
|
||||
ttl: 300
|
||||
keys: ["region", "time_range"]
|
||||
tools:
|
||||
- http-get
|
||||
- skill
|
||||
```
|
||||
|
||||
### 2.4 记录者:memory-sub(type: sub)
|
||||
|
||||
**职责**:
|
||||
- 阅读对话历史,提取用户画像
|
||||
- 把有价值的信息写入长期记忆数据库
|
||||
- 响应其他 Agent 的记忆查询
|
||||
- 记录子 Agent 的异常(如 API 失效)
|
||||
|
||||
**工具列表**:`memory.read`, `memory.write`, `read-file`, `write-file`
|
||||
|
||||
**Frontmatter**:
|
||||
|
||||
```yaml
|
||||
name: memory
|
||||
type: sub
|
||||
description: 记忆管理员
|
||||
tools:
|
||||
- memory.read
|
||||
- memory.write
|
||||
- read-file
|
||||
- write-file
|
||||
```
|
||||
|
||||
### 2.5 汇报员:narrator-sub(type: sub,成熟期)
|
||||
|
||||
**职责**:把结构化数据翻译成个性化回答
|
||||
|
||||
```yaml
|
||||
name: narrator
|
||||
type: sub
|
||||
description: 个性化回答生成器
|
||||
tools:
|
||||
- memory.read
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、核心工具:task
|
||||
|
||||
### 3.1 职责
|
||||
|
||||
```
|
||||
task(agent_name, arguments)
|
||||
│
|
||||
├── 1. 加载 {agent_name}-sub.md Frontmatter
|
||||
│ ├── name, type, tools, cache
|
||||
│ ├── cache.keys → ["city", "forecast_type"]
|
||||
│ └── cache.ttl → 7200
|
||||
│
|
||||
├── 2. 拼缓存 key
|
||||
│ ├── 遍历 cache.keys → 从 arguments 提取值
|
||||
│ ├── 拼接 → "city=北京&forecast_type=today"
|
||||
│ └── hash → "a1b2c3d4e5f6"
|
||||
│
|
||||
├── 3. 读缓存文件 ~/.config/yunshu/cache/{agent_name}.json
|
||||
│ ├── HIT → cache_data = {temp: 25, ...}
|
||||
│ └── MISS → cache_data = null
|
||||
│
|
||||
├── 4. 调子 Agent LLM
|
||||
│ ├── system = {agent_name}-sub.md 内容
|
||||
│ ├── user = {
|
||||
│ │ "args": arguments,
|
||||
│ │ "cache_data": cache_data // 有缓存传数据,没有传 null
|
||||
│ │ }
|
||||
│ └── 子 Agent 返回文本 + 可选 ---CACHE--- + JSON
|
||||
│
|
||||
├── 5. 处理子 Agent 返回
|
||||
│ ├── 有 ---CACHE--- → 提取后面的 JSON → 写缓存
|
||||
│ └── 无 ---CACHE--- → 只传文本
|
||||
│
|
||||
└── 6. 返回显示文本给 Host(dialog Agent 的 LLM)
|
||||
```
|
||||
|
||||
### 3.2 缓存文件格式
|
||||
|
||||
```json
|
||||
// ~/.config/yunshu/cache/{agent_name}.json
|
||||
{
|
||||
"<hash>": {
|
||||
"created_at": "2026-05-11T06:00:00+08:00",
|
||||
"ttl": 7200,
|
||||
"data": {
|
||||
"temp": 25,
|
||||
"condition": "晴"
|
||||
},
|
||||
"raw": {
|
||||
"city": "北京",
|
||||
"forecast_type": "today"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- hash 由 `cache.keys` 从 `arguments` 中提取值 → 拼接 → SHA256 取前 12 位
|
||||
- `raw` 存原始参数,方便调试和遍历
|
||||
- 每次读缓存时惰性清理过期条目
|
||||
|
||||
### 3.3 子 Agent 返回协议
|
||||
|
||||
子 Agent 返回分两段,由 `task` 工具解析:
|
||||
|
||||
```
|
||||
---RESULT---
|
||||
{结构化 JSON 数据(进缓存,不进 dialog 上下文)}
|
||||
---TEXT---
|
||||
子 Agent 想要对用户说的陈述文本(进 dialog 上下文)
|
||||
```
|
||||
|
||||
- `---RESULT---`:原始 API 数据,task 写入缓存文件,**不传给 dialog**
|
||||
- `---TEXT---`:子 Agent 已经组织好的陈述文本,task 返回给 dialog 的 LLM
|
||||
|
||||
**为什么分成两段**:
|
||||
- RESULT 保持子 Agent 的领域数据干净,不进主上下文
|
||||
- TEXT 给 dialog 一个"素材",dialog 用自己的语气说出来,不会产生"复述感"
|
||||
- 如果子 Agent 这次没有更新数据(比如 cache 命中后直接回答),可以只带 `---TEXT---`
|
||||
|
||||
### 3.4 传给子 Agent 的参数
|
||||
|
||||
```json
|
||||
{
|
||||
"args": {
|
||||
"city": "北京",
|
||||
"forecast_type": "today",
|
||||
"units": "C"
|
||||
},
|
||||
"cache_data": {
|
||||
"temp": 25,
|
||||
"condition": "晴"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `args` 是 dialog 传过来的原始参数
|
||||
- `cache_data` 是缓存的数据(有缓存时),子 Agent 据此直接回答,省一次 API 调用
|
||||
- 两者都是原始数据,不是处理过的文本
|
||||
|
||||
---
|
||||
|
||||
## 四、记忆系统
|
||||
|
||||
### 4.1 存储位置
|
||||
|
||||
```
|
||||
~/.config/yunshu/memory.db (或 memory.json,MVP 阶段)
|
||||
```
|
||||
|
||||
### 4.2 数据模型
|
||||
|
||||
```json
|
||||
{
|
||||
"personality": "你是个幽默风趣的北京大妞,说话带点贫",
|
||||
"user_profile": {
|
||||
"location": "北京通州",
|
||||
"unit": "C",
|
||||
"allergies": ["花粉"],
|
||||
"interests": ["户外"],
|
||||
"mood_today": null
|
||||
},
|
||||
"agent_errors": {
|
||||
"weather": ["msn_api_500 at 2026-05-11T06:00:00"],
|
||||
"earthquake": []
|
||||
},
|
||||
"dialog_context": {
|
||||
"last_agent": "weather",
|
||||
"last_topic": "北京天气",
|
||||
"summary": "用户问了北京天气"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 读写规则
|
||||
|
||||
| 操作 | 谁做 | 时机 |
|
||||
|------|------|------|
|
||||
| `memory.read` | dialog / 子 Agent | 需要画像时 |
|
||||
| `memory.write` | 只有 memory Agent | 从对话中提取画像后 |
|
||||
| `memory.write("dialog_context")` | dialog | 每次回答后 |
|
||||
|
||||
### 4.4 memory Agent 的工作流
|
||||
|
||||
```
|
||||
用户: "我住北京通州,最近花粉过敏厉害"
|
||||
→ dialog 聊天回应
|
||||
→ dialog: task("memory", {action: "extract", text: "用户说住通州、花粉过敏"})
|
||||
→ memory: 提取 → memory.write("user_profile.location", "北京通州")
|
||||
memory.write("user_profile.allergies", ["花粉"])
|
||||
|
||||
用户: "今天天气怎么样?"
|
||||
→ dialog: task("memory", {action: "read_context"}) → 有 location
|
||||
→ dialog: task("weather", {city: "北京通州"})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、文件结构
|
||||
|
||||
```
|
||||
yunshu/
|
||||
├── main.go # CLI 入口
|
||||
├── types.go # 核心类型(AgentDef, Schema, ToolDef, Message…)
|
||||
├── loader.go # .md 解析(Frontmatter + Body)
|
||||
├── catalog.go # CatalogAgent 生成 + tools.yml 输出
|
||||
├── registry.go # Agent 注册中心(扫描 + 按 type 分类)
|
||||
├── llm.go # LLM API 封装
|
||||
├── tool.go # 工具注册 + 7 个工具 handler
|
||||
├── toolschema.go # 泛型+反射 Schema 生成(NewTool[T], structToSchema)
|
||||
├── runtime.go # RunAgent + RunSubAgent + cache + session
|
||||
│
|
||||
├── agents/
|
||||
│ ├── dialog-agent.md # type: main — 主持者
|
||||
│ ├── weather-sub.md # type: sub — 天气 ✅
|
||||
│ ├── earthquake-sub.md # type: sub — 地震(预留)❌
|
||||
│ ├── memory-sub.md # type: sub — 记忆管理员 ❌
|
||||
│ └── narrator-sub.md # type: sub — 汇报员(成熟期)❌
|
||||
│
|
||||
├── skills/
|
||||
│ ├── msn-weather-api/SKILL.md
|
||||
│ └── geocoding/SKILL.md
|
||||
│
|
||||
├── docs/
|
||||
│ └── 此目录
|
||||
│
|
||||
└── pkg/
|
||||
├── mdprint/
|
||||
├── style/
|
||||
└── termui/
|
||||
```
|
||||
|
||||
### 用户配置目录
|
||||
|
||||
```
|
||||
~/.config/yunshu/
|
||||
├── config/
|
||||
│ ├── config.yml # LLM 配置
|
||||
│ ├── user.md # 用户画像(## 画像 / ## AI观察到)
|
||||
│ └── soul.md # AI 灵魂(用户可编辑)
|
||||
├── session/
|
||||
│ ├── session.json # 对话历史(仅 user ↔ dialog)
|
||||
│ └── dialog.yml # 对话摘要(每轮覆写)
|
||||
├── notes.md # 备忘录列表
|
||||
├── notes/ # 独立笔记文件
|
||||
├── log.yml # API 异常记录
|
||||
├── cache/
|
||||
│ ├── weather.json
|
||||
│ ├── earthquake.json
|
||||
│ └── ...
|
||||
├── data/
|
||||
│ └── weather/ # 子 Agent 自己的数据目录
|
||||
└── memory.db # 长期记忆数据库
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、调用流程示例
|
||||
|
||||
### 6.1 单子 Agent 查询
|
||||
|
||||
```
|
||||
用户: "北京明天多少度?"
|
||||
|
||||
HOST(runtime.go):
|
||||
1. 加载 dialog-agent.md → system prompt
|
||||
2. 读 session.json → 恢复上下文
|
||||
3. 调 LLM(session + system + tools)
|
||||
4. LLM 返回 tool_call: task("weather", {city: "北京", forecast_type: "tomorrow"})
|
||||
|
||||
task 工具(子 Agent 调用):
|
||||
1. 加载 weather-sub.md Frontmatter
|
||||
→ cache.keys: ["city", "forecast_type"], ttl: 1800
|
||||
2. 拼 key → "city=北京&forecast_type=tomorrow" → sha256[:6]
|
||||
3. 查 cache/weather.json → MISS
|
||||
4. 调子 Agent LLM(RunSubAgent,隔离的循环)
|
||||
system = weather-sub.md
|
||||
user = {args: {city: "北京", forecast_type: "tomorrow"}, cache_data: null}
|
||||
5. 子 Agent 工具链:
|
||||
├── skill("msn-weather-api") → 接口参数
|
||||
├── geocode("北京") → (39.9, 116.4)
|
||||
├── http-get(URL) → JSON
|
||||
└── 返回:
|
||||
---RESULT---
|
||||
{temp: {lo:18, hi:31}, condition: "晴"}
|
||||
---TEXT---
|
||||
▪ 北京明天天气
|
||||
...
|
||||
6. task 提取 RESULT → 写 cache/weather.json
|
||||
7. 返回 TEXT 给 HOST
|
||||
|
||||
HOST(runtime.go):
|
||||
1. tool 结果 → 追加到对话 → LLM 再次推理
|
||||
2. LLM 根据 prompt 指令"子 Agent 输出就是答案"→ 直接输出 TEXT
|
||||
3. 追加 session.json
|
||||
4. 显示给用户
|
||||
```
|
||||
|
||||
### 6.2 多步骤编排(新增能力)
|
||||
|
||||
```
|
||||
用户: "去北京出差,明天走,待三天"
|
||||
|
||||
HOST(runtime.go):
|
||||
1. 加载 dialog-agent.md → system prompt
|
||||
2. 读 session → 恢复上下文
|
||||
3. 调 LLM(session + system + tools)
|
||||
|
||||
┌─ 第 1 轮 LLM 推理 ──────────────────────────────┐
|
||||
│ LLM 决定: 先查天气 │
|
||||
│ tool_call: task("weather", {city:"北京", │
|
||||
│ forecast_type:"tomorrow"})
|
||||
│ → 子 Agent 返回: 北京明天 5°C 晴 │
|
||||
│ → 工具结果追加到对话 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
┌─ 第 2 轮 LLM 推理 ──────────────────────────────┐
|
||||
│ LLM 看到天气结果, 决定查火车票 │
|
||||
│ tool_call: task("train", {city:"北京", date:"明天"})│
|
||||
│ → 子 Agent 返回: G102 08:00 ¥680 │
|
||||
│ → 工具结果追加到对话 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
┌─ 第 3 轮 LLM 推理 ──────────────────────────────┐
|
||||
│ LLM 看到天气+车次, 决定查酒店 │
|
||||
│ tool_call: task("hotel", {city:"北京", nights:3}) │
|
||||
│ → 子 Agent 返回: 建国饭店 ¥500/晚 │
|
||||
│ → 工具结果追加到对话 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
┌─ 第 4 轮 LLM 推理 ──────────────────────────────┐
|
||||
│ LLM 觉得信息够了 → 返回文本 │
|
||||
│ "明天北京5°C记得带外套。G102早8点¥680。 │
|
||||
│ 建国饭店3晚¥1500。总预算约¥2180。" │
|
||||
│ → 追加 session.json → 显示给用户 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、实施阶段 — 当前状态
|
||||
|
||||
### 阶段一:基础架构(已完成,超计划完成)
|
||||
|
||||
| 步骤 | 文件 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1.1 | `types.go` | ✅ | `AgentDef` 加 `Type string`、`Cache *CacheDef`;`Schema` 替代 `ToolParameter` |
|
||||
| 1.2 | `loader.go` | ✅ | Frontmatter 解析加 `type`、`cache` 字段 |
|
||||
| 1.3 | `registry.go` | ✅ | `ScanAgents()` 扫描按 type 分类,同名覆盖 |
|
||||
| 1.4 | `tool.go` | ✅ | `task` / `memory.read` / `memory.write` + 4 个原有工具 |
|
||||
| 1.5 | `runtime.go` | ✅ | `RunSubAgent` + `RunAgent` + cache + session |
|
||||
| 1.6 | `toolschema.go` | ✅ ✨ | **新增(计划外)** — 泛型+反射 `NewTool[T]` 替代手写 Schema |
|
||||
| 1.7 | `main.go` | ✅ | `ScanAgents().GetMain("dialog")` 动态注入子 Agent 列表 |
|
||||
| 1.8 | `agents/dialog-agent.md` | ✅ | 主持者,含多步骤编排指令 |
|
||||
| 1.9 | `agents/weather-sub.md` | ✅ | 天气子 Agent,Markdown 输出 + 生活建议 |
|
||||
| 1.10 | — | ✅ ✨ | **多步骤编排(计划外)** — 砍掉 `capturedOutput`,主 Agent 连续调多个子 Agent |
|
||||
|
||||
#### 计划外新增内容
|
||||
|
||||
1. **泛型+反射工具注册**(`toolschema.go`):
|
||||
- `NewTool[T any]()` 泛型构造函数,自动反射推导 JSON Schema
|
||||
- 输入结构体 + struct tags → 零模板代码的工具注册
|
||||
- handler 内参数为类型安全的结构体字段,无需 `args["x"].(string)`
|
||||
|
||||
2. **多步骤编排**(`runtime.go` 改造):
|
||||
- `capturedOutput` 覆写机制已移除
|
||||
- 子 Agent 结果作为普通工具响应留在对话上下文
|
||||
- LLM 可以连续多次调 `task()`,直到信息收集完毕再回答
|
||||
|
||||
### 阶段二:记忆系统(待开始)
|
||||
|
||||
| 任务 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| 2.1 memory-sub.md | 记忆管理员 Agent(从对话提取画像) | ❌ |
|
||||
| 2.2 记忆数据库 | 结构化存储(画像、偏好、异常记录) | ❌ |
|
||||
| 2.3 画像自动提取 | memory Agent 定期从对话中提取有用信息 | ❌ |
|
||||
|
||||
### 阶段三:扩展(待开始)
|
||||
|
||||
| 任务 | 说明 | 状态 |
|
||||
|------|------|------|
|
||||
| 3.1 earthquake-sub | 地震信息查询 | ❌ |
|
||||
| 3.2 train-sub | 火车票查询 | ❌ |
|
||||
| 3.3 hotel-sub | 住宿查询 | ❌ |
|
||||
| 3.4 narrator-sub | 个性化回答生成(成熟期) | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 八、与 PicoClaw 的对比
|
||||
|
||||
| 维度 | PicoClaw | 云枢·会议室模式 |
|
||||
|------|----------|----------------|
|
||||
| 入口 | 单 Agent(用户直接对话) | 对话 Agent(唯一入口)+ 背后一堆子 Agent |
|
||||
| 上下文 | 所有轮次 + 系统 prompt 混在一起 | session 只存 user↔dialog,子 Agent 用完即毁 |
|
||||
| 知识 | 预置或长 prompt | skill 按需加载 |
|
||||
| 工具 | 所有工具混着用 | 按角色过滤,dialog 只有 task + memory |
|
||||
| 记忆 | 无 | 共享黑板,memory Agent 管写入 |
|
||||
| 扩展 | 改代码或改 prompt | 加一个 .md 文件 |
|
||||
| 失败隔离 | 坏 tool_call 可能污染全部 | 子 Agent 独立,坏就坏一个 |
|
||||
| 用户自定义 | 不可能 | 在 `~/.config/yunshu/agents/` 放 .md 即可 |
|
||||
|
||||
---
|
||||
|
||||
## 九、设计原则
|
||||
|
||||
1. **主持者保持极薄** — 只有人格 + 调度规则,不做领域知识
|
||||
2. **子 Agent 不自知** — 不知道缓存存在、不管理自己的 session,只回答当前问题
|
||||
3. **机械化的不做 LLM** — 缓存 key 拼装、文件读写都是 Go 代码,LLM 不参与
|
||||
4. **数据隔离** — 子 Agent 的 cache 文件、data 目录互相独立
|
||||
5. **记忆共享** — 黑板机制,所有 Agent 可读,仅 memory Agent 可写
|
||||
6. **一个入口** — 用户永远只和 dialog-agent 对话,感受不到子 Agent 的存在
|
||||
28
go.mod
28
go.mod
@@ -1,5 +1,29 @@
|
||||
module hub.gaomia.site/titor/YunShu
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.8
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
require (
|
||||
charm.land/log/v2 v2.0.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/lipgloss/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
)
|
||||
|
||||
44
go.sum
44
go.sum
@@ -1,3 +1,47 @@
|
||||
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
|
||||
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
|
||||
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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=
|
||||
|
||||
327
llm.go
327
llm.go
@@ -1,54 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hub.gaomia.site/titor/YunShu/pkg/mdprint"
|
||||
)
|
||||
|
||||
var (
|
||||
llmOnce sync.Once
|
||||
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
|
||||
func loadLLMConfig() {
|
||||
llmOnce.Do(func() {
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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) {
|
||||
loadLLMConfig()
|
||||
if llmKey == "" {
|
||||
return "", fmt.Errorf("未配置 API Key。请运行 'weather-cia onboard' 初始化,或设置 LLM_API_KEY 环境变量")
|
||||
}
|
||||
@@ -57,11 +63,14 @@ func GetLLMKey() (string, error) {
|
||||
|
||||
// CallLLM 调用大模型 API(兼容 OpenAI Chat Completion 格式)
|
||||
func CallLLM(messages []Message, toolDefs []ToolDef) (*OpenAIResponse, error) {
|
||||
loadLLMConfig()
|
||||
apiKey, err := GetLLMKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"model": llmModel,
|
||||
"messages": messages,
|
||||
@@ -126,5 +135,269 @@ func CallLLM(messages []Message, toolDefs []ToolDef) (*OpenAIResponse, error) {
|
||||
return nil, fmt.Errorf("LLM 返回空结果")
|
||||
}
|
||||
|
||||
if result.Usage.TotalTokens > 0 {
|
||||
infoLog("LLM 调用完成",
|
||||
"tokens", result.Usage.TotalTokens,
|
||||
"duration", time.Since(start).Round(time.Millisecond*100).String(),
|
||||
)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 流式输出 (SSE)
|
||||
// ============================================================
|
||||
|
||||
type accumulatedToolCall struct {
|
||||
ID string
|
||||
Type string
|
||||
Name string
|
||||
Args string
|
||||
}
|
||||
|
||||
type sseChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []sseChoice `json:"choices"`
|
||||
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type sseChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta sseDelta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type sseDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []sseToolCallDelta `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type sseToolCallDelta struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function *sseToolCallFunctionDelta `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
type sseToolCallFunctionDelta struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// CallLLMStream 流式调用 LLM,按 \n\n 段落边界缓冲后通过 mdprint 渲染到 stdout
|
||||
func CallLLMStream(messages []Message, toolDefs []ToolDef) (*OpenAIResponse, error) {
|
||||
loadLLMConfig()
|
||||
apiKey, err := GetLLMKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": llmModel,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respData, _ := io.ReadAll(resp.Body)
|
||||
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))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var fullContent strings.Builder
|
||||
var blockBuf strings.Builder
|
||||
toolCallAccums := make(map[int]*accumulatedToolCall)
|
||||
var responseID, responseModel string
|
||||
var responseCreated int64
|
||||
var usage *OpenAIUsage
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, fmt.Errorf("读取流响应失败: %w", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk sseChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if responseID == "" && chunk.ID != "" {
|
||||
responseID = chunk.ID
|
||||
}
|
||||
if responseModel == "" && chunk.Model != "" {
|
||||
responseModel = chunk.Model
|
||||
}
|
||||
if responseCreated == 0 && chunk.Created != 0 {
|
||||
responseCreated = chunk.Created
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
usage = chunk.Usage
|
||||
}
|
||||
|
||||
for _, choice := range chunk.Choices {
|
||||
delta := choice.Delta
|
||||
|
||||
if delta.Content != "" {
|
||||
fullContent.WriteString(delta.Content)
|
||||
blockBuf.WriteString(delta.Content)
|
||||
tryFlushBlocks(&blockBuf)
|
||||
}
|
||||
|
||||
for _, tc := range delta.ToolCalls {
|
||||
acc, ok := toolCallAccums[tc.Index]
|
||||
if !ok {
|
||||
acc = &accumulatedToolCall{}
|
||||
toolCallAccums[tc.Index] = acc
|
||||
}
|
||||
if tc.ID != "" {
|
||||
acc.ID = tc.ID
|
||||
}
|
||||
if tc.Type != "" {
|
||||
acc.Type = tc.Type
|
||||
}
|
||||
if tc.Function != nil {
|
||||
if tc.Function.Name != "" {
|
||||
acc.Name = tc.Function.Name
|
||||
}
|
||||
acc.Args += tc.Function.Arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流结束,刷残段
|
||||
if blockBuf.Len() > 0 {
|
||||
mdprint.Print(blockBuf.String())
|
||||
}
|
||||
|
||||
// 重建响应
|
||||
var choice OpenAIChoice
|
||||
|
||||
if len(toolCallAccums) > 0 {
|
||||
var tcs []ToolCall
|
||||
for i := 0; i < len(toolCallAccums); i++ {
|
||||
acc := toolCallAccums[i]
|
||||
if acc == nil {
|
||||
continue
|
||||
}
|
||||
tcs = append(tcs, ToolCall{
|
||||
ID: acc.ID,
|
||||
Type: acc.Type,
|
||||
Function: ToolCallFunction{
|
||||
Name: acc.Name,
|
||||
Arguments: acc.Args,
|
||||
},
|
||||
})
|
||||
}
|
||||
choice.Message.ToolCalls = tcs
|
||||
} else {
|
||||
content := fullContent.String()
|
||||
if content != "" {
|
||||
choice.Message.Content = &content
|
||||
}
|
||||
}
|
||||
|
||||
result := &OpenAIResponse{
|
||||
ID: responseID,
|
||||
Object: "chat.completion",
|
||||
Created: responseCreated,
|
||||
Model: responseModel,
|
||||
Choices: []OpenAIChoice{choice},
|
||||
}
|
||||
|
||||
if usage != nil && usage.TotalTokens > 0 {
|
||||
result.Usage = *usage
|
||||
infoLog("LLM 调用完成",
|
||||
"tokens", usage.TotalTokens,
|
||||
"duration", time.Since(start).Round(time.Millisecond*100).String(),
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// tryFlushBlocks 检测 blockBuf 中是否有完整的 Markdown block(以 \n\n 为界)
|
||||
// 有则通过 mdprint 渲染到 stdout,剩余残段留在 buf 中继续缓冲
|
||||
func tryFlushBlocks(buf *strings.Builder) {
|
||||
content := buf.String()
|
||||
idx := strings.LastIndex(content, "\n\n")
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
complete := strings.TrimRight(content[:idx], "\n\r\t ")
|
||||
if complete == "" {
|
||||
return
|
||||
}
|
||||
|
||||
mdprint.Print(complete)
|
||||
|
||||
remainder := content[idx+2:]
|
||||
buf.Reset()
|
||||
if remainder != "" {
|
||||
buf.WriteString(remainder)
|
||||
}
|
||||
}
|
||||
|
||||
249
log.go
Normal file
249
log.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hub.gaomia.site/titor/YunShu/pkg/style"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LevelWarn LogLevel = "warn"
|
||||
LevelError LogLevel = "error"
|
||||
LevelInfo LogLevel = "info"
|
||||
LevelOK LogLevel = "ok"
|
||||
)
|
||||
|
||||
var logToStderr bool
|
||||
|
||||
type logEntry struct {
|
||||
Time string `yaml:"time"`
|
||||
Level LogLevel `yaml:"level"`
|
||||
Msg string `yaml:"msg"`
|
||||
Fields map[string]any `yaml:",inline,omitempty"`
|
||||
}
|
||||
|
||||
func logPath() string {
|
||||
return filepath.Join(ConfigDir(), "log.yml")
|
||||
}
|
||||
|
||||
func buildFields(keyvals []any) map[string]any {
|
||||
m := make(map[string]any)
|
||||
for i := 0; i < len(keyvals)-1; i += 2 {
|
||||
if key, ok := keyvals[i].(string); ok {
|
||||
m[key] = keyvals[i+1]
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func appendLog(level LogLevel, msg string, keyvals ...any) {
|
||||
path := logPath()
|
||||
var entries []logEntry
|
||||
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
|
||||
yaml.Unmarshal(data, &entries)
|
||||
}
|
||||
if entries == nil {
|
||||
entries = make([]logEntry, 0)
|
||||
}
|
||||
|
||||
entry := logEntry{
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
Level: level,
|
||||
Msg: msg,
|
||||
}
|
||||
if len(keyvals) > 0 {
|
||||
entry.Fields = buildFields(keyvals)
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
|
||||
out, err := yaml.Marshal(entries)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.WriteFile(path, out, 0644)
|
||||
}
|
||||
|
||||
func readLogs() ([]logEntry, error) {
|
||||
data, err := os.ReadFile(logPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var entries []logEntry
|
||||
if err := yaml.Unmarshal(data, &entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func clearLogs() {
|
||||
os.WriteFile(logPath(), []byte("[]\n"), 0644)
|
||||
}
|
||||
|
||||
func levelColor(l LogLevel) *style.Style {
|
||||
switch l {
|
||||
case LevelWarn:
|
||||
return style.Yellow
|
||||
case LevelError:
|
||||
return style.Red
|
||||
case LevelInfo:
|
||||
return style.Cyan
|
||||
case LevelOK:
|
||||
return style.Green
|
||||
default:
|
||||
return style.Dim
|
||||
}
|
||||
}
|
||||
|
||||
func displayLogs(entries []logEntry, filterLevel string, top int) {
|
||||
if len(entries) == 0 {
|
||||
fmt.Println(style.Dim.Render("(暂无日志)"))
|
||||
return
|
||||
}
|
||||
|
||||
// 按时间倒序
|
||||
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
||||
entries[i], entries[j] = entries[j], entries[i]
|
||||
}
|
||||
|
||||
// 筛选级别
|
||||
if filterLevel != "" {
|
||||
var filtered []logEntry
|
||||
for _, e := range entries {
|
||||
if string(e.Level) == filterLevel {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
entries = filtered
|
||||
}
|
||||
|
||||
if top > 0 && top < len(entries) {
|
||||
entries = entries[:top]
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
ls := levelColor(e.Level)
|
||||
label := ls.Render(fmt.Sprintf("[%-5s]", strings.ToUpper(string(e.Level))))
|
||||
timeStr := style.Dim.Render(e.Time)
|
||||
fmt.Printf("%s %s %s\n", label, timeStr, e.Msg)
|
||||
for k, v := range e.Fields {
|
||||
fmt.Printf(" %s = %v\n", style.Dim.Render(k), style.Dim.Render(fmt.Sprintf("%v", v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func followLogs(filterLevel string) {
|
||||
lastCount := 0
|
||||
first := true
|
||||
|
||||
for {
|
||||
entries, err := readLogs()
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
n := len(entries)
|
||||
if n == 0 {
|
||||
if first {
|
||||
fmt.Println(style.Dim.Render("等待新日志..."))
|
||||
first = false
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if first {
|
||||
fmt.Println(style.Dim.Render("日志监听中... Ctrl+C 退出"))
|
||||
fmt.Println()
|
||||
lastCount = n
|
||||
first = false
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
if n > lastCount {
|
||||
for _, e := range entries[lastCount:] {
|
||||
if filterLevel != "" && string(e.Level) != filterLevel {
|
||||
continue
|
||||
}
|
||||
ls := levelColor(e.Level)
|
||||
label := ls.Render(fmt.Sprintf("[%-5s]", strings.ToUpper(string(e.Level))))
|
||||
timeStr := style.Dim.Render(e.Time)
|
||||
fmt.Printf("%s %s %s\n", label, timeStr, e.Msg)
|
||||
for k, v := range e.Fields {
|
||||
fmt.Printf(" %s = %v\n", style.Dim.Render(k), style.Dim.Render(fmt.Sprintf("%v", v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
lastCount = n
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func runLogCmd(args []string) {
|
||||
fs := flag.NewFlagSet("log", flag.ExitOnError)
|
||||
top := fs.Int("top", 0, "显示最后 N 条日志")
|
||||
level := fs.String("level", "", "按级别过滤 (warn/error/info/ok)")
|
||||
clear := fs.Bool("clear", false, "清空日志文件")
|
||||
watch := fs.Bool("watch", false, "监听模式,实时输出新日志")
|
||||
fs.Parse(args)
|
||||
|
||||
if *clear {
|
||||
if *top > 0 || *level != "" || *watch {
|
||||
fmt.Fprintln(os.Stderr, style.Red.Render("--clear 不能与其他选项组合"))
|
||||
os.Exit(1)
|
||||
}
|
||||
clearLogs()
|
||||
fmt.Println(style.Green.Render("日志已清空"))
|
||||
return
|
||||
}
|
||||
|
||||
if *watch {
|
||||
followLogs(*level)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := readLogs()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, style.Red.Render("读取日志失败: "+err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
displayLogs(entries, *level, *top)
|
||||
}
|
||||
|
||||
func warnLog(msg string, keyvals ...any) {
|
||||
if logToStderr {
|
||||
Log.Warn(msg, keyvals...)
|
||||
}
|
||||
appendLog(LevelWarn, msg, keyvals...)
|
||||
}
|
||||
|
||||
func errorLog(msg string, keyvals ...any) {
|
||||
if logToStderr {
|
||||
Log.Error(msg, keyvals...)
|
||||
}
|
||||
appendLog(LevelError, msg, keyvals...)
|
||||
}
|
||||
|
||||
func infoLog(msg string, keyvals ...any) {
|
||||
if logToStderr {
|
||||
Log.Info(msg, keyvals...)
|
||||
}
|
||||
appendLog(LevelInfo, msg, keyvals...)
|
||||
}
|
||||
14
logger.go
Normal file
14
logger.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"charm.land/log/v2"
|
||||
)
|
||||
|
||||
var Log = log.NewWithOptions(os.Stderr, log.Options{
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.TimeOnly,
|
||||
Level: log.DebugLevel,
|
||||
})
|
||||
187
main.go
187
main.go
@@ -1,16 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"hub.gaomia.site/titor/YunShu/pkg/style"
|
||||
"hub.gaomia.site/titor/YunShu/pkg/termui"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const version = "1.1.0"
|
||||
const version = "2.3.0"
|
||||
|
||||
func init() {
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
@@ -27,20 +30,18 @@ func printHelp() {
|
||||
fmt.Println()
|
||||
fmt.Println(style.Bold.Render("命令:"))
|
||||
fmt.Println(" onboard 交互式初始化配置")
|
||||
fmt.Println(" log 查看日志 (--top, --level, --clear, --watch)")
|
||||
fmt.Println(" help, -h 显示帮助信息")
|
||||
fmt.Println(" version, -v 显示版本号")
|
||||
fmt.Println()
|
||||
fmt.Println(style.Bold.Render("示例:"))
|
||||
fmt.Println(" yunshu \"北京今天天气\" ", style.Dim.Render("单次天气查询"))
|
||||
fmt.Println(" yunshu ", style.Dim.Render("启动交互模式"))
|
||||
fmt.Println(" yunshu log ", style.Dim.Render("查看日志"))
|
||||
fmt.Println(" yunshu log --watch ", style.Dim.Render("实时监听日志"))
|
||||
fmt.Println(" yunshu onboard ", style.Dim.Render("重新初始化配置"))
|
||||
fmt.Println()
|
||||
fmt.Println(style.Bold.Render("环境变量:"))
|
||||
fmt.Println(" LLM_API_KEY API Key(优先级高于配置文件)")
|
||||
fmt.Println(" LLM_ENDPOINT API 端点")
|
||||
fmt.Println(" LLM_MODEL 模型名")
|
||||
fmt.Println()
|
||||
fmt.Println(style.Bold.Render("配置文件:"), "~/.config/yunshu/config.yaml")
|
||||
fmt.Println(style.Bold.Render("配置文件:"), "~/.config/yunshu/config.yml")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -48,6 +49,118 @@ func printVersion() {
|
||||
fmt.Println("yunshu", version)
|
||||
}
|
||||
|
||||
func migrateMemoryJSON() {
|
||||
memoryPath := filepath.Join(ConfigDir(), "memory.json")
|
||||
data, err := os.ReadFile(memoryPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var store map[string]any
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// personality → config/soul.md
|
||||
if v, ok := store["personality"]; ok {
|
||||
dir := filepath.Join(ConfigDir(), "config")
|
||||
os.MkdirAll(dir, 0755)
|
||||
os.WriteFile(filepath.Join(dir, "soul.md"), []byte("# AI 灵魂\n\n"+fmt.Sprint(v)+"\n"), 0644)
|
||||
}
|
||||
|
||||
// dialog_context → session/dialog.yml
|
||||
if v, ok := store["dialog_context"]; ok {
|
||||
dir := filepath.Join(ConfigDir(), "session")
|
||||
os.MkdirAll(dir, 0755)
|
||||
if out, err := yaml.Marshal(v); err == nil {
|
||||
os.WriteFile(filepath.Join(dir, "dialog.yml"), out, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
// agent_errors → log.yml
|
||||
if v, ok := store["agent_errors"]; ok {
|
||||
if out, err := yaml.Marshal(v); err == nil {
|
||||
os.WriteFile(filepath.Join(ConfigDir(), "log.yml"), out, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
os.Remove(memoryPath)
|
||||
|
||||
// 确保 user.md 模板存在
|
||||
ensureUserConfig()
|
||||
}
|
||||
|
||||
func migrateFilePaths() {
|
||||
dir := ConfigDir()
|
||||
ensureSessionDir := func() string {
|
||||
sd := filepath.Join(dir, "session")
|
||||
os.MkdirAll(sd, 0755)
|
||||
return sd
|
||||
}
|
||||
|
||||
readFile := func(p string) []byte {
|
||||
d, _ := os.ReadFile(p)
|
||||
return d
|
||||
}
|
||||
writeFile := func(p string, d []byte, perm os.FileMode) {
|
||||
if len(d) > 0 {
|
||||
os.WriteFile(p, d, perm)
|
||||
}
|
||||
}
|
||||
|
||||
// config.yaml → config.yml
|
||||
oldYaml := filepath.Join(dir, "config.yaml")
|
||||
newYml := filepath.Join(dir, "config.yml")
|
||||
if _, err := os.Stat(oldYaml); err == nil {
|
||||
if _, err := os.Stat(newYml); os.IsNotExist(err) {
|
||||
writeFile(newYml, readFile(oldYaml), 0600)
|
||||
}
|
||||
os.Remove(oldYaml)
|
||||
}
|
||||
|
||||
// session.json → session/session.json
|
||||
oldSess := filepath.Join(dir, "session.json")
|
||||
newSess := filepath.Join(ensureSessionDir(), "session.json")
|
||||
if _, err := os.Stat(oldSess); err == nil {
|
||||
if _, err := os.Stat(newSess); os.IsNotExist(err) {
|
||||
writeFile(newSess, readFile(oldSess), 0644)
|
||||
}
|
||||
os.Remove(oldSess)
|
||||
}
|
||||
|
||||
// context/dialog.yaml → session/dialog.yml
|
||||
oldDlg := filepath.Join(dir, "context", "dialog.yaml")
|
||||
newDlg := filepath.Join(ensureSessionDir(), "dialog.yml")
|
||||
if _, err := os.Stat(oldDlg); err == nil {
|
||||
if _, err := os.Stat(newDlg); os.IsNotExist(err) {
|
||||
writeFile(newDlg, readFile(oldDlg), 0644)
|
||||
}
|
||||
os.Remove(oldDlg)
|
||||
os.Remove(filepath.Join(dir, "context"))
|
||||
}
|
||||
|
||||
// log.yaml → log.yml
|
||||
oldLog := filepath.Join(dir, "log.yaml")
|
||||
newLog := filepath.Join(dir, "log.yml")
|
||||
if _, err := os.Stat(oldLog); err == nil {
|
||||
if _, err := os.Stat(newLog); os.IsNotExist(err) {
|
||||
writeFile(newLog, readFile(oldLog), 0644)
|
||||
}
|
||||
os.Remove(oldLog)
|
||||
}
|
||||
}
|
||||
|
||||
func getMainAgent() *AgentDef {
|
||||
r := ScanAgents()
|
||||
def := r.GetMain("dialog")
|
||||
if def == nil {
|
||||
if m := r.ListMains(); len(m) > 0 {
|
||||
def = m[0]
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
@@ -56,6 +169,9 @@ func main() {
|
||||
case "onboard":
|
||||
runOnboard()
|
||||
return
|
||||
case "log":
|
||||
runLogCmd(args[1:])
|
||||
return
|
||||
case "help", "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
@@ -65,14 +181,20 @@ func main() {
|
||||
default:
|
||||
if strings.HasPrefix(args[0], "-") {
|
||||
fmt.Fprintln(os.Stderr, style.Red.Render("未知选项: "+args[0]))
|
||||
fmt.Fprintln(os.Stderr, "可用命令: onboard, help, version")
|
||||
fmt.Fprintln(os.Stderr, "可用命令: onboard, log, help, version")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:旧目录、文件路径、旧格式 — 在 LoadConfig 前执行
|
||||
migrateOldConfig()
|
||||
migrateFilePaths()
|
||||
migrateMemoryJSON()
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
// 如果 config.yml 也不存在,才是真没配置
|
||||
fmt.Fprintln(os.Stderr, style.Red.Render("未找到配置文件。请先运行:"))
|
||||
fmt.Fprintln(os.Stderr, " yunshu onboard")
|
||||
os.Exit(1)
|
||||
@@ -81,14 +203,18 @@ func main() {
|
||||
|
||||
GenerateToolsYAML()
|
||||
|
||||
agentPath := SearchFile("agents/weather-agent.md")
|
||||
def, err := LoadAgent(agentPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, style.Red.Render("加载 agent 失败: "+err.Error()))
|
||||
def := getMainAgent()
|
||||
if def == nil {
|
||||
fmt.Fprintln(os.Stderr, style.Red.Render("未找到主持者 Agent (type: main)"))
|
||||
fmt.Fprintln(os.Stderr, "请检查 agents/ 目录下是否有 type: main 的 .md 文件")
|
||||
os.Exit(1)
|
||||
}
|
||||
originalSystemPrompt := def.SystemPrompt
|
||||
|
||||
if len(args) > 0 {
|
||||
logToStderr = true
|
||||
subs := ScanAgents().ListSubs()
|
||||
def.SystemPrompt = originalSystemPrompt + BuildSubAgentPrompt(subs)
|
||||
ClearSession()
|
||||
query := strings.Join(args, " ")
|
||||
if err := RunAgent(def, query); err != nil {
|
||||
@@ -98,6 +224,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
logToStderr = false
|
||||
fmt.Println()
|
||||
fmt.Println(style.Cyan.Render("☁ 云枢·Agent"), style.Dim.Render("· 天气情报官"))
|
||||
fmt.Println(style.Dim.Render(" /exit 退出,// 开头的行不发给 LLM"))
|
||||
@@ -105,6 +232,15 @@ func main() {
|
||||
ClearSession()
|
||||
|
||||
for {
|
||||
// 热加载:每轮重新扫描 agent 文件
|
||||
r := ScanAgents()
|
||||
if d := r.GetMain("dialog"); d != nil {
|
||||
def = d
|
||||
} else if mains := r.ListMains(); len(mains) > 0 {
|
||||
def = mains[0]
|
||||
}
|
||||
def.SystemPrompt = originalSystemPrompt + BuildSubAgentPrompt(r.ListSubs())
|
||||
|
||||
fmt.Print(style.Cyan.Render("❯ "))
|
||||
input := termui.ReadLine()
|
||||
input = strings.TrimSpace(input)
|
||||
@@ -116,6 +252,24 @@ func main() {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "/log") {
|
||||
arg := strings.TrimSpace(strings.TrimPrefix(input, "/log"))
|
||||
switch arg {
|
||||
case "on":
|
||||
logToStderr = true
|
||||
fmt.Println(style.Green.Render("日志显示已开启"))
|
||||
case "off":
|
||||
logToStderr = false
|
||||
fmt.Println(style.Yellow.Render("日志显示已关闭"))
|
||||
default:
|
||||
fmt.Println("用法:")
|
||||
fmt.Println(" /log on 开启日志显示")
|
||||
fmt.Println(" /log off 关闭日志显示")
|
||||
}
|
||||
fmt.Println()
|
||||
continue
|
||||
}
|
||||
|
||||
switch input {
|
||||
case "/exit", "exit", "quit":
|
||||
fmt.Println("再见!")
|
||||
@@ -129,10 +283,11 @@ func main() {
|
||||
continue
|
||||
case "/help":
|
||||
fmt.Println("可用命令:")
|
||||
fmt.Println(" /exit 退出")
|
||||
fmt.Println(" /clear 清空会话")
|
||||
fmt.Println(" /help 显示帮助")
|
||||
fmt.Println(" // 不发给 LLM 的注释行")
|
||||
fmt.Println(" /exit 退出")
|
||||
fmt.Println(" /clear 清空会话")
|
||||
fmt.Println(" /log on|off 控制日志显示")
|
||||
fmt.Println(" /help 显示帮助")
|
||||
fmt.Println(" // 不发给 LLM 的注释行")
|
||||
fmt.Println()
|
||||
continue
|
||||
}
|
||||
|
||||
55
onboard.go
55
onboard.go
@@ -9,6 +9,42 @@ import (
|
||||
"hub.gaomia.site/titor/YunShu/pkg/termui"
|
||||
)
|
||||
|
||||
const userMDTemplate = `# 用户画像
|
||||
|
||||
> 这里记录关于你的信息。通过对话自动更新,你也可以手动修改。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **称呼**:
|
||||
- **常驻地**:
|
||||
- **温度单位**:摄氏度 (C)
|
||||
|
||||
## 偏好
|
||||
|
||||
- 过敏源:
|
||||
- 兴趣:
|
||||
- 出行习惯:
|
||||
|
||||
## 备注
|
||||
|
||||
(自由添加,不会被自动覆盖)
|
||||
`
|
||||
|
||||
const soulMDTemplate = `# AI 灵魂
|
||||
|
||||
> 这里是 AI 的灵魂设定。你可以修改下面的话,改变我的性格和说话方式。
|
||||
|
||||
## 人设
|
||||
|
||||
你是一个友好、亲切的个人助理。说话简洁直接,偶尔可以幽默。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 尊重用户的隐私
|
||||
- 不主动推荐第三方服务
|
||||
- 对不确定的事情要坦诚,不编造
|
||||
`
|
||||
|
||||
func runOnboard() {
|
||||
fmt.Println()
|
||||
fmt.Println(style.Cyan.Render("☁ 云枢·Agent · 初始化配置"))
|
||||
@@ -57,6 +93,7 @@ func runOnboard() {
|
||||
CopyDefaultDir("agents", "agents")
|
||||
CopyDefaultDir("skills", "skills")
|
||||
CopyDefaultDir("data", "data")
|
||||
ensureUserConfig()
|
||||
|
||||
fmt.Println()
|
||||
|
||||
@@ -67,7 +104,7 @@ func runOnboard() {
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(style.Green.Render("✔ 配置完成!"))
|
||||
fmt.Println(" 配置文件:", style.Dim.Render(filepath.Join(ConfigDir(), "config.yaml")))
|
||||
fmt.Println(" 配置文件:", style.Dim.Render(filepath.Join(ConfigDir(), "config.yml")))
|
||||
fmt.Println()
|
||||
fmt.Println(" 运行示例:")
|
||||
fmt.Println(" " + style.Cyan.Render("yunshu \"北京今天天气\""))
|
||||
@@ -99,3 +136,19 @@ func testLLM() {
|
||||
|
||||
fmt.Println(style.Green.Render("\r✔ 连接成功!"))
|
||||
}
|
||||
|
||||
func ensureUserConfig() {
|
||||
dir := ConfigDir()
|
||||
os.MkdirAll(filepath.Join(dir, "notes"), 0755)
|
||||
os.MkdirAll(filepath.Join(dir, "config"), 0755)
|
||||
|
||||
userPath := filepath.Join(dir, "config", "user.md")
|
||||
if _, err := os.Stat(userPath); os.IsNotExist(err) {
|
||||
os.WriteFile(userPath, []byte(userMDTemplate), 0644)
|
||||
}
|
||||
|
||||
soulPath := filepath.Join(dir, "config", "soul.md")
|
||||
if _, err := os.Stat(soulPath); os.IsNotExist(err) {
|
||||
os.WriteFile(soulPath, []byte(soulMDTemplate), 0644)
|
||||
}
|
||||
}
|
||||
|
||||
92
registry.go
Normal file
92
registry.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AgentRegistry struct {
|
||||
mains map[string]*AgentDef
|
||||
subs map[string]*AgentDef
|
||||
}
|
||||
|
||||
func ScanAgents() *AgentRegistry {
|
||||
r := &AgentRegistry{
|
||||
mains: make(map[string]*AgentDef),
|
||||
subs: make(map[string]*AgentDef),
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
"agents",
|
||||
filepath.Join(ConfigDir(), "agents"),
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
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())
|
||||
def, err := LoadAgent(agentPath)
|
||||
if err != nil {
|
||||
warnLog("跳过 agent", "file", e.Name(), "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch def.Type {
|
||||
case "main":
|
||||
r.mains[def.Name] = def
|
||||
case "sub":
|
||||
r.subs[def.Name] = def
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) GetMain(name string) *AgentDef {
|
||||
return r.mains[name]
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) GetSub(name string) *AgentDef {
|
||||
return r.subs[name]
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) ListMains() []*AgentDef {
|
||||
list := make([]*AgentDef, 0, len(r.mains))
|
||||
for _, def := range r.mains {
|
||||
list = append(list, def)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) ListSubs() []*AgentDef {
|
||||
list := make([]*AgentDef, 0, len(r.subs))
|
||||
for _, def := range r.subs {
|
||||
list = append(list, def)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (r *AgentRegistry) String() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("mains: %d, subs: %d\n", len(r.mains), len(r.subs)))
|
||||
for _, def := range r.mains {
|
||||
b.WriteString(fmt.Sprintf(" [main] %s: %s\n", def.Name, def.Description))
|
||||
}
|
||||
for _, def := range r.subs {
|
||||
b.WriteString(fmt.Sprintf(" [sub] %s: %s\n", def.Name, def.Description))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
189
runtime.go
189
runtime.go
@@ -1,22 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"hub.gaomia.site/titor/YunShu/pkg/mdprint"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sessionPath() string {
|
||||
return filepath.Join(ConfigDir(), "session.json")
|
||||
return filepath.Join(ConfigDir(), "session", "session.json")
|
||||
}
|
||||
|
||||
func ClearSession() {
|
||||
os.Remove(sessionPath())
|
||||
infoLog("会话已清空")
|
||||
}
|
||||
|
||||
const maxSessionMessages = 40
|
||||
|
||||
func LoadSession() []Message {
|
||||
data, err := os.ReadFile(sessionPath())
|
||||
if err != nil {
|
||||
@@ -25,9 +29,14 @@ func LoadSession() []Message {
|
||||
|
||||
var messages []Message
|
||||
if err := json.Unmarshal(data, &messages); err != nil {
|
||||
warnLog("解析 session.json 失败", "err", err)
|
||||
return nil
|
||||
}
|
||||
if len(messages) > maxSessionMessages {
|
||||
messages = messages[len(messages)-maxSessionMessages:]
|
||||
}
|
||||
return messages
|
||||
|
||||
}
|
||||
|
||||
func AppendToSession(msg Message) {
|
||||
@@ -37,11 +46,179 @@ func AppendToSession(msg Message) {
|
||||
|
||||
data, err := json.MarshalIndent(messages, "", " ")
|
||||
if err != nil {
|
||||
warnLog("序列化 session 失败", "err", err)
|
||||
return
|
||||
}
|
||||
os.WriteFile(sessionPath(), data, 0644)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cache 辅助
|
||||
// ============================================================
|
||||
|
||||
func cacheDir() string {
|
||||
return filepath.Join(ConfigDir(), "cache")
|
||||
}
|
||||
|
||||
func cacheFilePath(agentName string) string {
|
||||
return filepath.Join(cacheDir(), agentName+".json")
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TTL int `json:"ttl"`
|
||||
Data interface{} `json:"data"`
|
||||
Raw map[string]interface{} `json:"raw"`
|
||||
}
|
||||
|
||||
func buildCacheKey(keys []string, args map[string]interface{}) string {
|
||||
parts := make([]string, 0)
|
||||
for _, k := range keys {
|
||||
if v, ok := args[k]; ok {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256([]byte(strings.Join(parts, "&")))
|
||||
return fmt.Sprintf("%x", h[:6])
|
||||
}
|
||||
|
||||
func readCache(agentName, key string) *cacheEntry {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(cacheFilePath(agentName))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var store map[string]cacheEntry
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
warnLog("解析缓存失败", "agent", agentName, "err", err)
|
||||
return nil
|
||||
}
|
||||
entry, ok := store[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if time.Since(entry.CreatedAt) > time.Duration(entry.TTL)*time.Second {
|
||||
delete(store, key)
|
||||
return nil
|
||||
}
|
||||
return &entry
|
||||
}
|
||||
|
||||
func writeCache(agentName, key string, data interface{}, raw map[string]interface{}, ttl int) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
store := make(map[string]cacheEntry)
|
||||
existing, err := os.ReadFile(cacheFilePath(agentName))
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(existing, &store); err != nil {
|
||||
warnLog("读取旧缓存解析失败", "agent", agentName, "err", err)
|
||||
}
|
||||
}
|
||||
store[key] = cacheEntry{
|
||||
CreatedAt: time.Now(),
|
||||
TTL: ttl,
|
||||
Data: data,
|
||||
Raw: raw,
|
||||
}
|
||||
dir := cacheDir()
|
||||
os.MkdirAll(dir, 0755)
|
||||
out, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
warnLog("序列化缓存失败", "agent", agentName, "err", err)
|
||||
return
|
||||
}
|
||||
os.WriteFile(cacheFilePath(agentName), out, 0644)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 子 Agent 返回解析
|
||||
// ============================================================
|
||||
|
||||
func parseSubResult(raw string) (text string, resultData interface{}) {
|
||||
const resultMarker = "---RESULT---\n"
|
||||
const textMarker = "\n---TEXT---"
|
||||
|
||||
if !strings.Contains(raw, resultMarker) {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(raw, resultMarker, 2)
|
||||
remaining := parts[1]
|
||||
|
||||
resultEnd := strings.Index(remaining, textMarker)
|
||||
if resultEnd == -1 {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimSpace(remaining[:resultEnd])
|
||||
json.Unmarshal([]byte(jsonStr), &resultData)
|
||||
text = strings.TrimSpace(remaining[resultEnd+len(textMarker):])
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RunSubAgent — 隔离的子 Agent 执行(不读写 session)
|
||||
// ============================================================
|
||||
|
||||
func RunSubAgent(def *AgentDef, userInput string) (string, error) {
|
||||
infoLog("子 Agent 开始", "agent", def.Name)
|
||||
messages := []Message{
|
||||
{Role: RoleSystem, Content: def.SystemPrompt},
|
||||
{Role: RoleUser, Content: userInput},
|
||||
}
|
||||
|
||||
toolDefs := GetToolDefs(def.Tools)
|
||||
maxToolCalls := 2
|
||||
toolCallCount := 0
|
||||
|
||||
for {
|
||||
resp, err := CallLLM(messages, toolDefs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
toolCallCount++
|
||||
if toolCallCount > maxToolCalls {
|
||||
warnLog("子 Agent 执行轮次超限", "agent", def.Name, "rounds", toolCallCount)
|
||||
return "---TEXT---\n(子 Agent 执行轮次超限,已终止)", nil
|
||||
}
|
||||
assistantMsg := Message{
|
||||
Role: RoleAssistant,
|
||||
ToolCalls: choice.Message.ToolCalls,
|
||||
}
|
||||
messages = append(messages, 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,
|
||||
}
|
||||
messages = append(messages, toolMsg)
|
||||
}
|
||||
} else {
|
||||
content := ""
|
||||
if choice.Message.Content != nil {
|
||||
content = *choice.Message.Content
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RunAgent(def *AgentDef, userInput string) error {
|
||||
messages := LoadSession()
|
||||
|
||||
@@ -56,7 +233,8 @@ func RunAgent(def *AgentDef, userInput string) error {
|
||||
toolDefs := GetToolDefs(def.Tools)
|
||||
|
||||
for {
|
||||
resp, err := CallLLM(fullMessages, toolDefs)
|
||||
fmt.Println()
|
||||
resp, err := CallLLMStream(fullMessages, toolDefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -89,6 +267,7 @@ func RunAgent(def *AgentDef, userInput string) error {
|
||||
if choice.Message.Content != nil {
|
||||
content = *choice.Message.Content
|
||||
}
|
||||
|
||||
assistantMsg := Message{
|
||||
Role: RoleAssistant,
|
||||
Content: content,
|
||||
@@ -96,8 +275,6 @@ func RunAgent(def *AgentDef, userInput string) error {
|
||||
fullMessages = append(fullMessages, assistantMsg)
|
||||
AppendToSession(assistantMsg)
|
||||
|
||||
fmt.Println()
|
||||
mdprint.Print(content)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ description: MSN 天气 API 详细知识
|
||||
|------|-----|
|
||||
| 当前天气 | `https://assets.msn.cn/service/weather/current` |
|
||||
| 每日预报 | `https://assets.msn.cn/service/weather/dailyforecast` |
|
||||
| 逐小时预报 | `https://assets.msn.cn/service/weather/hourlyforecast` |
|
||||
|
||||
## 必须参数
|
||||
|
||||
@@ -21,7 +22,7 @@ description: MSN 天气 API 详细知识
|
||||
|
||||
## 可选参数
|
||||
|
||||
- `days`: 预报天数(最大 10)
|
||||
- `days`: 预报天数(最大 10,仅 dailyforecast 和 hourlyforecast 可用)
|
||||
|
||||
## 必须请求头
|
||||
|
||||
@@ -49,6 +50,32 @@ value[].responses[].weather[].days[].daily.{
|
||||
}
|
||||
```
|
||||
|
||||
### hourlyforecast 接口
|
||||
返回未来 10 天每天逐小时预报(今天从当前小时开始,后续每天 24 个点)。
|
||||
|
||||
```
|
||||
value[].responses[].weather[].days[].hourly[].{
|
||||
valid, // ISO 时间戳,如 "2026-05-11T07:00:00+08:00"
|
||||
temp, // 温度 °C
|
||||
feels, // 体感温度 °C
|
||||
cap, // 天气描述(中文)
|
||||
precip, // 降水概率 %
|
||||
rh, // 相对湿度 %
|
||||
baro, // 气压 hPa
|
||||
windSpd, // 风速 km/h
|
||||
windDir, // 风向(度)
|
||||
windGust, // 阵风 km/h
|
||||
uv, // 紫外线指数
|
||||
cloudCover, // 云量 %
|
||||
vis, // 能见度 km
|
||||
dewPt, // 露点 °C
|
||||
rainAmount, // 降雨量 mm
|
||||
snowAmount, // 降雪量 mm
|
||||
icon, symbol, // 天气图标代码
|
||||
sky, wx // 天空状况代码
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 数据源为微软 MSN 天气后台接口
|
||||
- 国内访问速度快(msn.cn 国内节点)
|
||||
|
||||
398
tool.go
398
tool.go
@@ -6,8 +6,11 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var registeredTools = make(map[string]*ToolDef)
|
||||
@@ -34,13 +37,33 @@ func GetToolDefs(names []string) []ToolDef {
|
||||
return defs
|
||||
}
|
||||
|
||||
func safeMemoryPath(path string) (string, error) {
|
||||
cleanPath := filepath.Clean(path)
|
||||
fullPath := filepath.Join(ConfigDir(), cleanPath)
|
||||
|
||||
realPath, err := filepath.EvalSymlinks(fullPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("路径解析失败: %w", err)
|
||||
}
|
||||
realPath = fullPath
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(ConfigDir(), realPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("路径越界: %s", path)
|
||||
}
|
||||
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
return "", fmt.Errorf("解析工具参数失败: %w", err)
|
||||
}
|
||||
@@ -48,36 +71,142 @@ func ExecuteTool(tc ToolCall) (string, error) {
|
||||
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)
|
||||
type HTTPGetInput struct {
|
||||
URL string `json:"url" description:"请求的完整 URL 地址"`
|
||||
Headers map[string]string `json:"headers,omitempty" description:"请求头键值对,如 {\"User-Agent\": \"...\"}"`
|
||||
}
|
||||
|
||||
type SkillInput struct {
|
||||
Name string `json:"name" description:"Skill 名称,如 msn-weather-api"`
|
||||
}
|
||||
|
||||
type ReadFileInput struct {
|
||||
Path string `json:"path" description:"文件路径,相对于项目根目录"`
|
||||
}
|
||||
|
||||
type TaskInput struct {
|
||||
Agent string `json:"agent" description:"子 Agent 名称,如 weather"`
|
||||
Args map[string]any `json:"args" description:"子 Agent 参数对象"`
|
||||
}
|
||||
|
||||
type MemoryReadInput struct {
|
||||
Path string `json:"path" description:"文件路径。config/user.md(画像)、config/soul.md(AI灵魂)、session/dialog.yml(对话摘要)、notes/*.md(备忘录) 等。留空返回可用文件列表"`
|
||||
}
|
||||
|
||||
type MemoryWriteInput struct {
|
||||
Path string `json:"path" description:"文件路径。.md 按 ## 标题合并(value 传字符串);.yml 按 key 合并(value 传对象)"`
|
||||
Value interface{} `json:"value" description:"内容。格式取决于文件类型"`
|
||||
}
|
||||
|
||||
type GeocodeInput struct {
|
||||
City string `json:"city" description:"城市名称,支持中文(如 北京)或英文(如 Beijing)"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具注册
|
||||
// ============================================================
|
||||
|
||||
// mdMerge 按 ## 标题合并 Markdown 文件。
|
||||
// incoming 中的标题覆盖 existing 中同名的,其他段保留。
|
||||
func mdMerge(existing, incoming string) string {
|
||||
if existing == "" {
|
||||
return incoming
|
||||
}
|
||||
if incoming == "" {
|
||||
return existing
|
||||
}
|
||||
|
||||
type section struct {
|
||||
heading string
|
||||
content string
|
||||
}
|
||||
|
||||
parse := func(text string) []section {
|
||||
var secs []section
|
||||
var cur section
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if h, ok := strings.CutPrefix(line, "## "); ok && h != "" {
|
||||
if cur.heading != "" || cur.content != "" {
|
||||
secs = append(secs, cur)
|
||||
}
|
||||
cur = section{heading: h}
|
||||
} else {
|
||||
if cur.content != "" {
|
||||
cur.content += "\n"
|
||||
}
|
||||
cur.content += line
|
||||
}
|
||||
}
|
||||
if cur.heading != "" || cur.content != "" {
|
||||
secs = append(secs, cur)
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
existingSecs := parse(existing)
|
||||
incomingSecs := parse(incoming)
|
||||
|
||||
headingIdx := make(map[string]int)
|
||||
for i, s := range existingSecs {
|
||||
headingIdx[s.heading] = i
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var merged []section
|
||||
|
||||
for _, s := range existingSecs {
|
||||
found := false
|
||||
for _, in := range incomingSecs {
|
||||
if in.heading == s.heading {
|
||||
merged = append(merged, in)
|
||||
seen[in.heading] = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
merged = append(merged, s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, in := range incomingSecs {
|
||||
if !seen[in.heading] {
|
||||
merged = append(merged, in)
|
||||
}
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
for i, s := range merged {
|
||||
if i > 0 {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if s.heading != "" {
|
||||
out.WriteString("## ")
|
||||
out.WriteString(s.heading)
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if strings.TrimSpace(s.content) != "" {
|
||||
out.WriteString(strings.TrimRight(s.content, "\n"))
|
||||
out.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterTool(NewTool[HTTPGetInput]("http-get",
|
||||
"发送 HTTP GET 请求获取数据",
|
||||
func(args HTTPGetInput) (string, error) {
|
||||
req, err := http.NewRequest("GET", args.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)
|
||||
}
|
||||
}
|
||||
for k, v := range args.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
@@ -95,70 +224,162 @@ func init() {
|
||||
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"},
|
||||
RegisterTool(NewTool[SkillInput]("skill",
|
||||
"加载指定名称的 Skill 知识内容到当前上下文,获取专业知识",
|
||||
func(args SkillInput) (string, error) {
|
||||
return LoadSkill(args.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)
|
||||
RegisterTool(NewTool[ReadFileInput]("read-file",
|
||||
"读取本地文件内容",
|
||||
func(args ReadFileInput) (string, error) {
|
||||
data, err := os.ReadFile(args.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
return 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 参数")
|
||||
RegisterTool(NewTool[TaskInput]("task",
|
||||
"调度子 Agent 执行领域任务。sub-agent 加载后自动查缓存,有缓存直接返回,无缓存调 LLM + 工具链获取新数据",
|
||||
func(args TaskInput) (string, error) {
|
||||
infoLog("task("+args.Agent+") 开始")
|
||||
registry := ScanAgents()
|
||||
sub := registry.GetSub(args.Agent)
|
||||
if sub == nil {
|
||||
errorLog("未找到子 Agent", "agent", args.Agent)
|
||||
return "", fmt.Errorf("未找到子 Agent: %s", args.Agent)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://wttr.in/%s?format=j1", city)
|
||||
var cacheKey string
|
||||
var cacheData interface{}
|
||||
if sub.Cache != nil && len(sub.Cache.Keys) > 0 {
|
||||
cacheKey = buildCacheKey(sub.Cache.Keys, args.Args)
|
||||
if entry := readCache(args.Agent, cacheKey); entry != nil {
|
||||
cacheData = entry.Data
|
||||
}
|
||||
}
|
||||
|
||||
subInput := map[string]any{
|
||||
"args": args.Args,
|
||||
"cache_data": cacheData,
|
||||
}
|
||||
subInputBytes, _ := json.Marshal(subInput)
|
||||
|
||||
result, err := RunSubAgent(sub, string(subInputBytes))
|
||||
if err != nil {
|
||||
errorLog("task("+args.Agent+") 失败", "err", err)
|
||||
return "", fmt.Errorf("子 Agent %s 执行失败: %w", args.Agent, err)
|
||||
}
|
||||
|
||||
text, resultData := parseSubResult(result)
|
||||
|
||||
if cacheKey != "" && resultData != nil && sub.Cache != nil {
|
||||
writeCache(args.Agent, cacheKey, resultData, args.Args, sub.Cache.TTL)
|
||||
}
|
||||
|
||||
infoLog("task("+args.Agent+") 完成")
|
||||
return text, nil
|
||||
},
|
||||
))
|
||||
|
||||
RegisterTool(NewTool[MemoryReadInput]("memory.read",
|
||||
"读取记忆文件。路径支持 config/, session/, log.yml, notes/ 等。返回文件原始内容",
|
||||
func(args MemoryReadInput) (string, error) {
|
||||
fullPath, err := safeMemoryPath(args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "null", nil
|
||||
}
|
||||
return "", fmt.Errorf("读取失败: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
entries, err := os.ReadDir(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取目录失败: %w", err)
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
out, _ := yaml.Marshal(names)
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
},
|
||||
))
|
||||
|
||||
RegisterTool(NewTool[MemoryWriteInput]("memory.write",
|
||||
"写入记忆文件。.md 按 ## 标题合并(value 传字符串),.yml 按 key 合并(value 传对象)。目录自动创建",
|
||||
func(args MemoryWriteInput) (string, error) {
|
||||
fullPath, err := safeMemoryPath(args.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
|
||||
ext := filepath.Ext(args.Path)
|
||||
switch ext {
|
||||
case ".yaml", ".yml":
|
||||
existing := make(map[string]any)
|
||||
if data, err := os.ReadFile(fullPath); err == nil {
|
||||
if err := yaml.Unmarshal(data, &existing); err != nil {
|
||||
warnLog("解析 yml 失败", "path", args.Path, "err", err)
|
||||
}
|
||||
}
|
||||
if m, ok := args.Value.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
}
|
||||
out, err := yaml.Marshal(existing)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 yml 失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(fullPath, out, 0644); err != nil {
|
||||
return "", fmt.Errorf("写入失败: %w", err)
|
||||
}
|
||||
default:
|
||||
str, ok := args.Value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf(".md 文件 value 必须是字符串")
|
||||
}
|
||||
existing := ""
|
||||
if data, err := os.ReadFile(fullPath); err == nil {
|
||||
existing = string(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
warnLog("读取 md 失败", "path", args.Path, "err", err)
|
||||
}
|
||||
merged := mdMerge(existing, str)
|
||||
if err := os.WriteFile(fullPath, []byte(merged), 0644); err != nil {
|
||||
return "", fmt.Errorf("写入失败: %w", err)
|
||||
}
|
||||
}
|
||||
return "ok", nil
|
||||
},
|
||||
))
|
||||
|
||||
RegisterTool(NewTool[GeocodeInput]("geocode",
|
||||
"查询城市或地点的经纬度坐标,返回 lat/lon/name/country。支持中文城市名(如 北京、上海、成都)和英文名",
|
||||
func(args GeocodeInput) (string, error) {
|
||||
url := fmt.Sprintf("https://wttr.in/%s?format=j1", args.City)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %w", err)
|
||||
@@ -182,15 +403,10 @@ func init() {
|
||||
|
||||
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"`
|
||||
AreaName []struct{ Value string `json:"value"` } `json:"areaName"`
|
||||
Country []struct{ Value string `json:"value"` } `json:"country"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
} `json:"nearest_area"`
|
||||
}
|
||||
|
||||
@@ -199,7 +415,7 @@ func init() {
|
||||
}
|
||||
|
||||
if len(result.NearestArea) == 0 {
|
||||
return "", fmt.Errorf("未找到城市: %s", city)
|
||||
return "", fmt.Errorf("未找到城市: %s", args.City)
|
||||
}
|
||||
|
||||
area := result.NearestArea[0]
|
||||
@@ -212,7 +428,7 @@ func init() {
|
||||
country = area.Country[0].Value
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
out, _ := json.Marshal(map[string]any{
|
||||
"lat": area.Latitude,
|
||||
"lon": area.Longitude,
|
||||
"name": name,
|
||||
@@ -220,5 +436,5 @@ func init() {
|
||||
})
|
||||
return string(out), nil
|
||||
},
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
133
toolschema.go
Normal file
133
toolschema.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func structToSchema(t reflect.Type) Schema {
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return typeToSchema(t)
|
||||
}
|
||||
|
||||
schema := Schema{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
|
||||
properties := schema["properties"].(map[string]any)
|
||||
var required []any
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonTag := f.Tag.Get("json")
|
||||
if jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.Split(jsonTag, ",")[0]
|
||||
if name == "" {
|
||||
name = strings.ToLower(f.Name[:1]) + f.Name[1:]
|
||||
}
|
||||
|
||||
if !strings.Contains(jsonTag, "omitempty") {
|
||||
required = append(required, name)
|
||||
}
|
||||
|
||||
fieldSchema := typeToSchema(f.Type)
|
||||
|
||||
if desc := f.Tag.Get("description"); desc != "" {
|
||||
fieldSchema["description"] = desc
|
||||
}
|
||||
|
||||
if enum := f.Tag.Get("enum"); enum != "" {
|
||||
vals := strings.Split(enum, ",")
|
||||
enumVals := make([]any, len(vals))
|
||||
for i, v := range vals {
|
||||
enumVals[i] = strings.TrimSpace(v)
|
||||
}
|
||||
fieldSchema["enum"] = enumVals
|
||||
}
|
||||
|
||||
properties[name] = fieldSchema
|
||||
}
|
||||
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
func typeToSchema(t reflect.Type) Schema {
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return Schema{"type": "string"}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return Schema{"type": "integer"}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return Schema{"type": "number"}
|
||||
case reflect.Bool:
|
||||
return Schema{"type": "boolean"}
|
||||
case reflect.Slice, reflect.Array:
|
||||
items := typeToSchema(t.Elem())
|
||||
return Schema{"type": "array", "items": items}
|
||||
case reflect.Interface:
|
||||
return Schema{}
|
||||
case reflect.Map:
|
||||
m := Schema{"type": "object"}
|
||||
if t.Elem().Kind() != reflect.Interface {
|
||||
m["additionalProperties"] = typeToSchema(t.Elem())
|
||||
}
|
||||
return m
|
||||
case reflect.Struct:
|
||||
return structToSchema(t)
|
||||
default:
|
||||
return Schema{"type": "string"}
|
||||
}
|
||||
}
|
||||
|
||||
func NewTool[T any](name, description string, fn func(T) (string, error)) *ToolDef {
|
||||
var zero T
|
||||
t := reflect.TypeOf(zero)
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
panic(fmt.Sprintf("NewTool: %T 不是结构体类型", zero))
|
||||
}
|
||||
|
||||
schema := structToSchema(t)
|
||||
|
||||
return &ToolDef{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Parameters: schema,
|
||||
Execute: func(args map[string]any) (string, error) {
|
||||
data, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化参数失败: %w", err)
|
||||
}
|
||||
var typed T
|
||||
if err := json.Unmarshal(data, &typed); err != nil {
|
||||
return "", fmt.Errorf("参数解析失败: %w", err)
|
||||
}
|
||||
return fn(typed)
|
||||
},
|
||||
}
|
||||
}
|
||||
26
types.go
26
types.go
@@ -27,28 +27,26 @@ type ToolCallFunction struct {
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type CacheDef struct {
|
||||
TTL int `yaml:"ttl"`
|
||||
Keys []string `yaml:"keys"`
|
||||
}
|
||||
|
||||
type AgentDef struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tools []string `yaml:"tools"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Type string `yaml:"type"`
|
||||
Cache *CacheDef `yaml:"cache,omitempty"`
|
||||
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 Schema map[string]any
|
||||
|
||||
type ToolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters ToolParameter
|
||||
Parameters Schema
|
||||
Execute func(args map[string]interface{}) (string, error)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user