Files
YunShu/log.go
titor c4a0e3ef53 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
2026-05-16 17:21:29 +08:00

250 lines
5.1 KiB
Go

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...)
}