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:
1 parent
47047c903c
commit
4e2e81f684
8 files changed
+1178
-198
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user