- 流式输出: 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
250 lines
5.1 KiB
Go
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...)
|
|
}
|