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:
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...)
|
||||
}
|
||||
Reference in New Issue
Block a user