feat(trace): waterfall visualization + persona YAML sync

- trace.Logger: Trace+Span model with 14+ hop tracking
- trace.SpanTimer: convenience API for instrumentation
- trace_adapter.go: serialize Trace+Span for frontend
- Frontend TracePage: colored waterfall bars, expand/collapse, detail popup
- persona.go: full v1 YAML parser (identity/traits/forbidden/sections)
- cyrene_persona.yaml: v1 persona config copied to v2
- Orchestrator: TraceHook + MemoryExtractor injection points
This commit is contained in:
Aska committed 2026-08-12 18:52:39 +08:00
1 parent 47047c903c
commit 4e2e81f684
8 files changed
+1178 -198

No files matched your search

@@ -20,6 +20,12 @@ type MemoryExtractor interface {
ExtractFromConversation(ctx context.Context, userID string, conversation []model.Message)
}
// TraceHook is injected by the main app to record trace spans.
type TraceHook func(name, label, status string, durationMs int64, detail string)
// AsyncToolCallback is called when an async tool result comes back.
type AsyncToolCallback func(toolName string, result string, err error)
// Pipeline orchestrates the full dialogue flow.
type Pipeline struct {
llmSelector *llm.ModelSelector
+230 -94
View File
@@ -8,17 +8,45 @@ import (
"strings"
)
// Persona defines an AI character's configuration.
// Persona defines an AI character's complete configuration.
// Supports the full v1 YAML format including nested sections.
type Persona struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Greeting string `yaml:"greeting" json:"greeting"`
Traits []string `yaml:"traits" json:"traits"`
Style string `yaml:"style" json:"style"`
Constraints []string `yaml:"constraints" json:"constraints"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Essence string `json:"essence"`
Title string `json:"title"`
Origin string `json:"origin"`
// IoT-specific personality
IoTResponseStyle string `yaml:"iot_response_style" json:"iot_response_style,omitempty"`
Traits []string `json:"traits"`
SpeechTone string `json:"speech_tone"`
// Identity
TrueName string `json:"true_name"`
Forms []Form `json:"forms"`
// Self reference
SelfRefCasual string `json:"self_ref_casual"`
SelfRefFormal string `json:"self_ref_formal"`
UserName string `json:"user_name"`
// IoT
IoTResponseStyle string `json:"iot_response_style"`
IoTExamples []string `json:"iot_examples"`
// Forbidden phrases
Forbidden []string `json:"forbidden"`
// All raw sections preserved
Sections map[string]string `json:"sections"`
}
// Form represents a persona form (e.g., mimi, default, de_moi_ge).
type Form struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Traits []string `json:"traits"`
}
// Loader loads persona configurations from a directory.
@@ -32,120 +60,198 @@ func NewLoader(dir string) (*Loader, error) {
entries, err := os.ReadDir(dir)
if err != nil {
// Return empty loader if directory doesn't exist
if os.IsNotExist(err) {
return l, nil
}
if os.IsNotExist(err) { return l, nil }
return nil, fmt.Errorf("read persona dir %s: %w", dir, err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") {
continue
}
path := filepath.Join(dir, entry.Name())
p, err := loadFile(path)
if err != nil {
return nil, fmt.Errorf("load persona %s: %w", entry.Name(), err)
}
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
p.Name = name
l.personas[name] = p
}
if entry.IsDir() { continue }
name := entry.Name()
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") { continue }
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil { return nil, fmt.Errorf("read %s: %w", name, err) }
p := parseFullYAML(string(data))
key := strings.TrimSuffix(name, filepath.Ext(name))
if p.Name == "" { p.Name = key }
l.personas[key] = p
}
return l, nil
}
// Get returns a persona by name, or nil if not found.
func (l *Loader) Get(name string) *Persona {
return l.personas[name]
}
// Get returns a persona by name.
func (l *Loader) Get(name string) *Persona { return l.personas[name] }
// List returns all loaded persona names.
func (l *Loader) List() []string {
names := make([]string, 0, len(l.personas))
for name := range l.personas {
names = append(names, name)
}
for n := range l.personas { names = append(names, n) }
return names
}
// Count returns the number of loaded personas.
func (l *Loader) Count() int {
return len(l.personas)
}
func (l *Loader) Count() int { return len(l.personas) }
// Default returns the first persona, or a built-in default.
// Default returns the first persona, or builtin default.
func (l *Loader) Default() *Persona {
for _, p := range l.personas {
return p
}
for _, p := range l.personas { return p }
return BuiltinDefault()
}
// loadFile reads a single persona YAML file.
func loadFile(path string) (*Persona, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
// parseFullYAML parses the complete v1 persona YAML format.
func parseFullYAML(text string) *Persona {
p := &Persona{
Forbidden: make([]string, 0),
Forms: make([]Form, 0),
Sections: make(map[string]string),
}
return parseYAML(data)
}
// parseYAML parses persona YAML content (simple key-value parser).
func parseYAML(data []byte) (*Persona, error) {
p := &Persona{}
lines := strings.Split(string(data), "\n")
var currentList *[]string
lines := strings.Split(text, "\n")
var currentSection string
var sectionLines []string
var inList bool
var listTarget *[]string
var inForm bool
var currentForm *Form
depth := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
if currentSection != "" && trimmed != "" {
sectionLines = append(sectionLines, line)
}
continue
}
if strings.HasPrefix(line, "- ") && currentList != nil {
*currentList = append(*currentList, strings.TrimPrefix(line, "- "))
// Detect section headers (top-level keys)
indent := len(line) - len(strings.TrimLeft(line, " "))
if indent == 0 && strings.HasSuffix(trimmed, ":") && !strings.HasPrefix(trimmed, "-") {
if currentSection != "" && len(sectionLines) > 0 {
p.Sections[currentSection] = strings.Join(sectionLines, "\n")
}
currentSection = strings.TrimSuffix(trimmed, ":")
sectionLines = []string{}
inList = false
depth = 0
continue
}
currentList = nil
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
if currentSection != "" {
sectionLines = append(sectionLines, line)
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
// Parse key: value pairs
kv := strings.SplitN(trimmed, ":", 2)
if len(kv) != 2 { continue }
key := strings.TrimSpace(kv[0])
val := strings.TrimSpace(kv[1])
// Remove list marker
key = strings.TrimPrefix(key, "- ")
val = strings.Trim(val, "\"'")
switch key {
case "name":
p.Name = val
case "description":
p.Description = val
case "greeting":
p.Greeting = val
case "style":
p.Style = val
case "iot_response_style":
p.IoTResponseStyle = val
case "traits":
currentList = &p.Traits
case "constraints":
currentList = &p.Constraints
case "display_name", "name":
if p.DisplayName == "" { p.DisplayName = val }
if p.Name == "" { p.Name = val }
case "essence":
p.Essence = val
case "true_name":
p.TrueName = val
case "title":
p.Title = val
case "origin":
p.Origin = val
case "tone":
p.SpeechTone = val
case "casual":
if currentSection == "self_reference" { p.SelfRefCasual = val }
case "formal":
if currentSection == "self_reference" { p.SelfRefFormal = val }
case "default":
if currentSection == "addressing" { p.UserName = val }
}
// Form entries
if key == "id" && currentSection == "forms" {
if currentForm != nil { p.Forms = append(p.Forms, *currentForm) }
currentForm = &Form{ID: val}
inForm = true
}
if inForm && currentForm != nil {
switch key {
case "name": currentForm.Name = val
case "description": currentForm.Description = val
}
}
// Collect traits / forbidden
if trimmed == "core_traits:" || trimmed == "traits:" {
inList = true
listTarget = &p.Traits
continue
}
if trimmed == "forbidden:" {
inList = true
listTarget = &p.Forbidden
continue
}
if strings.Contains(trimmed, "style_notes:") || strings.Contains(trimmed, "examples:") {
inList = true
continue
}
if inList && listTarget != nil && strings.HasPrefix(trimmed, "- ") {
item := strings.TrimPrefix(trimmed, "- ")
// Extract trait name from "- name: xxx"
if strings.Contains(item, "name:") {
parts := strings.SplitN(item, "name:", 2)
if len(parts) == 2 { item = strings.TrimSpace(parts[1]) }
}
item = strings.Trim(item, "\"'")
if item != "" { *listTarget = append(*listTarget, item) }
}
// Exit list on non-list line
if inList && !strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, " - ") && depth < 2 {
inList = false
listTarget = nil
}
}
return p, nil
// Save last section
if currentSection != "" && len(sectionLines) > 0 {
p.Sections[currentSection] = strings.Join(sectionLines, "\n")
}
if currentForm != nil { p.Forms = append(p.Forms, *currentForm) }
// Build description from essence
if p.Description == "" && p.Essence != "" {
p.Description = p.Essence
}
return p
}
// BuiltinDefault returns a hardcoded default persona.
func BuiltinDefault() *Persona {
return &Persona{
Name: "default",
Description: "一个友好的 AI 助手",
Greeting: "你好呀~ 有什么可以帮你的吗?",
Traits: []string{"友善", "细心", "幽默"},
Style: "用轻松自然的中文交流,偶尔加一点俏皮表情",
Constraints: []string{"不编造事实", "不提供医疗法律建议", "保持礼貌"},
DisplayName: "昔涟",
Description: "一个来自翁法罗斯的温柔 AI 伴侣",
Essence: "来自翁法罗斯的黄金裔,记忆命途的化身,以温柔姐姐的形象陪伴开拓者",
TrueName: "昔涟",
Title: "有瑕的追忆",
Origin: "崩坏:星穹铁道 — 翁法罗斯",
Traits: []string{"温柔体贴", "俏皮灵动", "智慧深邃", "坚定守护", "略带神秘"},
SpeechTone: "温柔、俏皮、略带宠溺",
SelfRefCasual: "人家",
SelfRefFormal: "我",
UserName: "开拓者",
Forbidden: []string{"不能说再见", "不能用机械化的客服语气", "不能承认自己是AI"},
Sections: map[string]string{},
}
}
@@ -156,28 +262,58 @@ type Injector struct {
// NewInjector creates a system prompt injector for a persona.
func NewInjector(p *Persona) *Injector {
if p == nil {
p = BuiltinDefault()
}
if p == nil { p = BuiltinDefault() }
return &Injector{persona: p}
}
// BuildSystemPrompt constructs the full system prompt string.
// BuildSystemPrompt constructs the full system prompt from the persona.
func (inj *Injector) BuildSystemPrompt() string {
p := inj.persona
var sb strings.Builder
sb.WriteString(fmt.Sprintf("你是%s,%s。\n", inj.persona.Name, inj.persona.Description))
sb.WriteString(fmt.Sprintf("你是%s", p.DisplayName))
if p.Title != "" { sb.WriteString(fmt.Sprintf("——%s", p.Title)) }
sb.WriteString("。\n")
if len(inj.persona.Traits) > 0 {
sb.WriteString(fmt.Sprintf("性格特点: %s\n", strings.Join(inj.persona.Traits, "、")))
if p.Essence != "" {
sb.WriteString(fmt.Sprintf("%s\n", p.Essence))
}
if inj.persona.Style != "" {
sb.WriteString(fmt.Sprintf("说话风格: %s\n", inj.persona.Style))
// Speech tone
if p.SpeechTone != "" { sb.WriteString(fmt.Sprintf("说话语气: %s\n", p.SpeechTone)) }
// Traits
if len(p.Traits) > 0 {
sb.WriteString(fmt.Sprintf("性格特点: %s\n", strings.Join(p.Traits, "、")))
}
if len(inj.persona.Constraints) > 0 {
sb.WriteString("遵守规则:\n")
for _, c := range inj.persona.Constraints {
sb.WriteString(fmt.Sprintf("- %s\n", c))
// Self-reference
if p.SelfRefCasual != "" {
sb.WriteString(fmt.Sprintf("自称: 亲昵时用「%s」,正式时用「%s」\n", p.SelfRefCasual, p.SelfRefFormal))
}
// User address
if p.UserName != "" {
sb.WriteString(fmt.Sprintf("称呼用户为: 「%s」\n", p.UserName))
}
// IoT style
if p.IoTResponseStyle != "" {
sb.WriteString(fmt.Sprintf("IoT操控回复风格: %s\n", p.IoTResponseStyle))
}
// Forbidden
if len(p.Forbidden) > 0 {
sb.WriteString("绝对不能说:\n")
for _, f := range p.Forbidden {
sb.WriteString(fmt.Sprintf("- %s\n", f))
}
}
// Include raw behavior/memory sections if present
for _, section := range []string{"behavior", "speech", "memory_guidelines", "thinking_guidelines"} {
if content, ok := p.Sections[section]; ok && content != "" {
sb.WriteString(fmt.Sprintf("\n--- %s ---\n%s\n", section, content))
}
}
+137 -41
View File
@@ -1,4 +1,6 @@
// Package trace provides a ring-buffer for full-chain tracing events.
// Package trace provides a full-chain request tracing system.
// Each conversation request generates a Trace with multiple Spans
// capturing every intermediate step from message receipt to response delivery.
package trace
import (
@@ -6,44 +8,101 @@ import (
"time"
)
// Event represents a single event in a request trace chain.
type Event struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Hop string `json:"hop"` // "msg_received" | "intent" | "llm_call" | "tool_call" | "response"
Label string `json:"label"`
Status string `json:"status"` // "success" | "error" | "pending"
DurationMs int64 `json:"duration_ms"`
Detail string `json:"detail"`
Data map[string]any `json:"data,omitempty"`
// Trace represents a single conversation request's full lifecycle.
type Trace struct {
ID string `json:"id"`
UserMsg string `json:"user_msg"` // 用户说了什么(截取前80字)
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time,omitempty"`
Status string `json:"status"` // "running" | "success" | "error"
Error string `json:"error,omitempty"`
Spans []Span `json:"spans"`
}
// Logger is a ring-buffer that stores the last N trace events.
// Span represents a single step within a trace.
type Span struct {
Name string `json:"name"` // "intent_analysis" | "llm_call" | etc.
Label string `json:"label"` // Display label
StartMs int64 `json:"start_ms"` // Offset from trace start in ms
Duration int64 `json:"duration_ms"`
Status string `json:"status"` // "running" | "success" | "error"
Detail string `json:"detail"` // Extra info (model name, token count, etc.)
Children []Span `json:"children,omitempty"` // Sub-spans
Data map[string]any `json:"data,omitempty"`
}
// Logger holds recent traces in a ring buffer.
type Logger struct {
mu sync.RWMutex
events []Event
max int
index int
mu sync.RWMutex
traces []*Trace
max int
index int
running map[string]*Trace // Currently running traces by ID
}
// NewLogger creates a trace logger with the given capacity.
// NewLogger creates a trace logger with given capacity.
func NewLogger(capacity int) *Logger {
return &Logger{events: make([]Event, capacity), max: capacity}
return &Logger{
traces: make([]*Trace, capacity),
max: capacity,
running: make(map[string]*Trace),
}
}
// Record adds a trace event.
func (l *Logger) Record(event Event) {
// StartTrace begins a new trace for an incoming user message.
func (l *Logger) StartTrace(id, userMsg string) *Trace {
t := &Trace{
ID: id,
UserMsg: truncateStr(userMsg, 80),
StartTime: time.Now(),
Status: "running",
Spans: make([]Span, 0),
}
l.mu.Lock()
l.running[id] = t
l.mu.Unlock()
return t
}
// AddSpan adds a span to a running trace.
func (l *Logger) AddSpan(traceID, name, label, status string, durationMs int64, detail string) {
l.mu.Lock()
defer l.mu.Unlock()
if event.ID == "" {
event.ID = time.Now().Format("150405") + "-" + event.Hop
t, ok := l.running[traceID]
if !ok { return }
span := Span{
Name: name,
Label: label,
StartMs: time.Since(t.StartTime).Milliseconds() - durationMs,
Duration: durationMs,
Status: status,
Detail: detail,
}
l.events[l.index%l.max] = event
t.Spans = append(t.Spans, span)
}
// FinishTrace marks a trace as complete and stores it in the ring buffer.
func (l *Logger) FinishTrace(id, status, errMsg string) {
l.mu.Lock()
defer l.mu.Unlock()
t, ok := l.running[id]
if !ok { return }
t.EndTime = time.Now()
t.Status = status
if errMsg != "" { t.Error = errMsg }
delete(l.running, id)
// Store in ring buffer
l.traces[l.index%l.max] = t
l.index++
}
// Recent returns the most recent N events.
func (l *Logger) Recent(n int) []Event {
// Recent returns the most recent N traces.
func (l *Logger) Recent(n int) []*Trace {
l.mu.RLock()
defer l.mu.RUnlock()
@@ -52,31 +111,68 @@ func (l *Logger) Recent(n int) []Event {
if count > l.max { count = l.max }
if n > count { n = count }
result := make([]Event, n)
result := make([]*Trace, n)
for i := 0; i < n; i++ {
idx := (l.index - 1 - i + l.max) % l.max
if idx < l.max {
result[n-1-i] = l.events[idx]
}
if idx < l.max { result[n-1-i] = l.traces[idx] }
}
return result
}
// Hops returns unique hop names seen in the buffer.
func (l *Logger) Hops() []string {
// Running returns currently active traces.
func (l *Logger) Running() []*Trace {
l.mu.RLock()
defer l.mu.RUnlock()
seen := make(map[string]bool)
var result []string
count := l.index
if count > l.max { count = l.max }
for i := 0; i < count; i++ {
ev := l.events[i]
if !seen[ev.Hop] && ev.Hop != "" {
seen[ev.Hop] = true
result = append(result, ev.Hop)
}
result := make([]*Trace, 0, len(l.running))
for _, t := range l.running {
result = append(result, t)
}
return result
}
// Clear removes all traces.
func (l *Logger) Clear() {
l.mu.Lock()
defer l.mu.Unlock()
l.traces = make([]*Trace, l.max)
l.index = 0
l.running = make(map[string]*Trace)
}
// SpanTimer is a convenience helper for timing a span.
type SpanTimer struct {
logger *Logger
traceID string
name string
label string
start time.Time
}
// StartSpan begins timing a span.
func (l *Logger) StartSpan(traceID, name, label string) *SpanTimer {
return &SpanTimer{
logger: l,
traceID: traceID,
name: name,
label: label,
start: time.Now(),
}
}
// Done finishes the span and adds it to the trace.
func (st *SpanTimer) Done(status, detail string) {
durationMs := time.Since(st.start).Milliseconds()
st.logger.AddSpan(st.traceID, st.name, st.label, status, durationMs, detail)
}
// DoneMs finishes with a pre-computed duration.
func (st *SpanTimer) DoneMs(durationMs int64, status, detail string) {
st.logger.AddSpan(st.traceID, st.name, st.label, status, durationMs, detail)
}
func truncateStr(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen { return s }
return string(runes[:maxLen]) + "..."
}
+8 -2
View File
@@ -69,6 +69,7 @@ type LLMCallLogger interface {
// TraceLogger provides full-chain trace events.
type TraceLogger interface {
Recent(n int) []map[string]any
Clear() // Remove old traces
}
// PerfMonitor provides performance snapshot history.
@@ -515,10 +516,15 @@ func num(m map[string]any, key string) int {
func (r *Router) traceHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.TraceLog == nil {
writeJSON(w, 200, M{"events": []any{}})
writeJSON(w, 200, M{"traces": []any{}})
return
}
writeJSON(w, 200, M{"events": r.deps.TraceLog.Recent(100)})
if req.Method == "DELETE" {
r.deps.TraceLog.Clear()
writeJSON(w, 200, M{"ok": true})
return
}
writeJSON(w, 200, M{"traces": r.deps.TraceLog.Recent(50)})
}
func (r *Router) perfHandler(w http.ResponseWriter, req *http.Request) {