- Context merged into single system message with clear delimiters - Prefix: 「以下是系统上下文,不是用户说的话」 - Memory → 【相关记忆】, IoT → 【设备状态】 - Filter empty memories, skip raw intent data - Persona rule: context markers are internal data, not user input
326 lines
9.6 KiB
Go
326 lines
9.6 KiB
Go
// 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()
|
|
}
|