feat: v2.3.0 流式输出 + 日志系统 + 会议室架构全面升级

- 流式输出: SSE 逐 token 接收, \\n\n\ 段落缓冲后 mdprint 彩色渲染
- 日志系统: charmbracelet/log v2 双写(stderr + log.yml), yunshu log 命令
- 会议室架构: dialog(main) + weather/profile/note(sub) 多 Agent 编排
- 泛型工具注册: NewTool[T] 反射推导 JSON Schema, 类型安全
- 安全加固: safeMemoryPath 三段校验(EvalSymlinks+Rel), maxToolCalls=2
- 性能优化: sync.Once 延迟加载, note 一步完成, obs/summary 合并
- Prompt 适配: 流式输出原则(先调工具不说话), 单 Agent 查询跳过 obs+summary
- 文档: AGENTS.md + architecture.md + changelog.md 全部同步至 v2.3.0
This commit is contained in:
titor
2026-05-16 17:21:29 +08:00
parent 0898188086
commit c4a0e3ef53
24 changed files with 2769 additions and 338 deletions

187
main.go
View File

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