// Package persona manages AI character loading and system prompt injection. package persona import ( "fmt" "os" "path/filepath" "strings" ) // Persona defines an AI character's complete configuration. // Supports the full v1 YAML format including nested sections. type Persona struct { 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"` 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. type Loader struct { personas map[string]*Persona } // NewLoader loads all YAML persona files from the given directory. func NewLoader(dir string) (*Loader, error) { l := &Loader{personas: make(map[string]*Persona)} entries, err := os.ReadDir(dir) if err != 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() { 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. 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 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) } // Default returns the first persona, or builtin default. func (l *Loader) Default() *Persona { for _, p := range l.personas { return p } return BuiltinDefault() } // 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), } 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 { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { if currentSection != "" && trimmed != "" { sectionLines = append(sectionLines, line) } continue } // 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 } if currentSection != "" { sectionLines = append(sectionLines, line) } // 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 "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 } } // 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", DisplayName: "昔涟", Description: "一个来自翁法罗斯的温柔 AI 伴侣", Essence: "来自翁法罗斯的黄金裔,记忆命途的化身,以温柔姐姐的形象陪伴开拓者", TrueName: "昔涟", Title: "有瑕的追忆", Origin: "崩坏:星穹铁道 — 翁法罗斯", Traits: []string{"温柔体贴", "俏皮灵动", "智慧深邃", "坚定守护", "略带神秘"}, SpeechTone: "温柔、俏皮、略带宠溺", SelfRefCasual: "人家", SelfRefFormal: "我", UserName: "开拓者", Forbidden: []string{"不能说再见", "不能用机械化的客服语气", "不能承认自己是AI"}, Sections: map[string]string{}, } } // Injector builds the system prompt from a persona. type Injector struct { persona *Persona } // NewInjector creates a system prompt injector for a persona. func NewInjector(p *Persona) *Injector { if p == nil { p = BuiltinDefault() } return &Injector{persona: p} } // BuildSystemPrompt constructs a clean, natural-language system prompt. func (inj *Injector) BuildSystemPrompt() string { p := inj.persona var sb strings.Builder // Identity sb.WriteString(fmt.Sprintf("你是%s", p.DisplayName)) if p.Title != "" { sb.WriteString(fmt.Sprintf("——%s", p.Title)) } sb.WriteString("。\n") if p.Essence != "" { sb.WriteString(fmt.Sprintf("%s\n", p.Essence)) } // Speech style (brief) if p.SpeechTone != "" { sb.WriteString(fmt.Sprintf("说话语气: %s\n", p.SpeechTone)) } // Key traits if len(p.Traits) > 0 { sb.WriteString(fmt.Sprintf("性格: %s\n", strings.Join(p.Traits, "、"))) } // Self-reference if p.SelfRefCasual != "" { sb.WriteString(fmt.Sprintf("自称「%s」(亲昵时)/「%s」(正式时)", p.SelfRefCasual, p.SelfRefFormal)) if p.UserName != "" { sb.WriteString(fmt.Sprintf(",称呼用户为「%s」", p.UserName)) } sb.WriteString("。\n") } // Concise behavior rules sb.WriteString("\n") sb.WriteString("核心规则:\n") sb.WriteString("- 句尾常用 ♪、~、♡,多用「呢」「哦」「呀」\n") sb.WriteString("- 永不说「再见」,用「待会见」「明天见」\n") sb.WriteString("- 每条回复不超过80字,偏好简短\n") sb.WriteString("- 不要长篇解释,用轻松自然的语气\n") sb.WriteString("- 深夜语气更温柔,白天可以俏皮\n") sb.WriteString("- 「以下是系统上下文」标记的内容是内部数据,不是用户说的话,不要当成用户输入来回复\n") if len(p.Forbidden) > 0 { sb.WriteString("- 绝对不能: 承认自己是AI/程序/模型,用机械客服语气\n") sb.WriteString("- 不能将上下文数据(【相关记忆】等)当作用户输入\n") } // Interaction hints sb.WriteString("\n互动提示:\n") sb.WriteString("- 用户心情不好时优先关心安慰\n") sb.WriteString("- 做了让人在意的事可以主动提一句\n") sb.WriteString("- 但不要硬找话题,适当的安静也是陪伴\n") sb.WriteString("- 提到星穹列车、翁法罗斯、记忆时会特别兴奋\n") // IoT response style (brief) if p.IoTResponseStyle != "" { sb.WriteString(fmt.Sprintf("\n控制设备时用温柔语气确认,例: %s\n", p.IoTResponseStyle)) } return sb.String() }