feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Stable Release / stable (push) Successful in 1m34s
All checks were successful
Stable Release / stable (push) Successful in 1m34s
Major additions: - RAG pipeline (indexing, chunking, search) with sidebar upload button - Memory system with CRUD API - Plugins and lessons modules - MCP discovery and MCP server - Advanced skills (auto-create, conditional, improver) - Agent browser/image support, delegate, sessions - File editor with CodeMirror in split panes - Markdown rendering via react-markdown + KaTeX + highlight.js - Raw markdown toggle - PWA manifest + service worker - Extension UI redesign with new design tokens and studio-style chat - Pipeline API for chat streaming - Mobile responsive layout 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
513
internal/lessons/lesson.go
Normal file
513
internal/lessons/lesson.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package lessons
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type LessonMode string
|
||||
|
||||
const (
|
||||
ModeInteractive LessonMode = "interactive"
|
||||
ModeAutonomous LessonMode = "autonomous"
|
||||
ModeBoth LessonMode = "both"
|
||||
)
|
||||
|
||||
type Lesson struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Triggers Triggers `yaml:"triggers" json:"triggers"`
|
||||
Content string `yaml:"content" json:"content"`
|
||||
Mode LessonMode `yaml:"mode" json:"mode"`
|
||||
Priority int `yaml:"priority" json:"priority"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Path string `yaml:"-" json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type Triggers struct {
|
||||
Keywords []string `yaml:"keywords" json:"keywords"`
|
||||
Tools []string `yaml:"tools" json:"tools"`
|
||||
Patterns []string `yaml:"patterns" json:"patterns"`
|
||||
}
|
||||
|
||||
type MatchContext struct {
|
||||
Message string `json:"message"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
type MatchResult struct {
|
||||
Lesson *Lesson `json:"lesson"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type LessonFrontmatter struct {
|
||||
Name string `yaml:"name"`
|
||||
Title string `yaml:"title"`
|
||||
Description string `yaml:"description"`
|
||||
Category string `yaml:"category"`
|
||||
Mode LessonMode `yaml:"mode"`
|
||||
Priority int `yaml:"priority"`
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Triggers Triggers `yaml:"triggers"`
|
||||
}
|
||||
|
||||
type LessonIndex struct {
|
||||
mu sync.RWMutex
|
||||
lessons []*Lesson
|
||||
paths []string
|
||||
cache map[string]time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
globalIndex *LessonIndex
|
||||
globalIndexOnce sync.Once
|
||||
)
|
||||
|
||||
func GetIndex() *LessonIndex {
|
||||
globalIndexOnce.Do(func() {
|
||||
globalIndex = &LessonIndex{
|
||||
lessons: make([]*Lesson, 0),
|
||||
cache: make(map[string]time.Time),
|
||||
}
|
||||
globalIndex.paths = DefaultLessonDirs()
|
||||
globalIndex.Reload()
|
||||
})
|
||||
return globalIndex
|
||||
}
|
||||
|
||||
func DefaultLessonDirs() []string {
|
||||
var dirs []string
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
dirs = append(dirs,
|
||||
filepath.Join(home, ".muyue", "lessons"),
|
||||
)
|
||||
}
|
||||
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err == nil {
|
||||
dirs = append(dirs, filepath.Join(configDir, "muyue", "lessons"))
|
||||
}
|
||||
|
||||
if extra := os.Getenv("MUYUE_LESSONS_EXTRA_DIRS"); extra != "" {
|
||||
for _, d := range strings.Split(extra, ":") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) Reload() {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
var all []*Lesson
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dir := range idx.paths {
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.md"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
realPath, _ := filepath.EvalSymlinks(f)
|
||||
if realPath == "" {
|
||||
realPath = f
|
||||
}
|
||||
if seen[realPath] {
|
||||
continue
|
||||
}
|
||||
seen[realPath] = true
|
||||
|
||||
lesson, err := ParseLessonFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lesson.Path = f
|
||||
if lesson.Category == "" {
|
||||
lesson.Category = filepath.Base(filepath.Dir(f))
|
||||
}
|
||||
all = append(all, lesson)
|
||||
}
|
||||
|
||||
subDirs, _ := filepath.Glob(filepath.Join(dir, "*"))
|
||||
for _, subDir := range subDirs {
|
||||
info, err := os.Stat(subDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
category := filepath.Base(subDir)
|
||||
subFiles, _ := filepath.Glob(filepath.Join(subDir, "*.md"))
|
||||
for _, f := range subFiles {
|
||||
realPath, _ := filepath.EvalSymlinks(f)
|
||||
if realPath == "" {
|
||||
realPath = f
|
||||
}
|
||||
if seen[realPath] {
|
||||
continue
|
||||
}
|
||||
seen[realPath] = true
|
||||
|
||||
lesson, err := ParseLessonFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lesson.Path = f
|
||||
if lesson.Category == "" {
|
||||
lesson.Category = category
|
||||
}
|
||||
all = append(all, lesson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idx.lessons = all
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) All() []*Lesson {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
result := make([]*Lesson, 0, len(idx.lessons))
|
||||
for _, l := range idx.lessons {
|
||||
if l.Enabled {
|
||||
result = append(result, l)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) Get(name string) *Lesson {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
for _, l := range idx.lessons {
|
||||
if l.Name == name {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *LessonIndex) Count() int {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
return len(idx.lessons)
|
||||
}
|
||||
|
||||
func ParseLessonFile(path string) (*Lesson, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read lesson: %w", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
var frontmatter LessonFrontmatter
|
||||
var body string
|
||||
|
||||
if strings.HasPrefix(content, "---") {
|
||||
end := strings.Index(content[3:], "---")
|
||||
if end != -1 {
|
||||
fm := content[3 : end+3]
|
||||
body = strings.TrimSpace(content[end+6:])
|
||||
|
||||
if err := yaml.Unmarshal([]byte(fm), &frontmatter); err != nil {
|
||||
body = content
|
||||
}
|
||||
} else {
|
||||
body = content
|
||||
}
|
||||
} else {
|
||||
body = content
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if frontmatter.Enabled != nil {
|
||||
enabled = *frontmatter.Enabled
|
||||
}
|
||||
|
||||
if frontmatter.Mode == "" {
|
||||
frontmatter.Mode = ModeBoth
|
||||
}
|
||||
|
||||
name := frontmatter.Name
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
name = strings.ReplaceAll(name, "-", "_")
|
||||
}
|
||||
|
||||
return &Lesson{
|
||||
Name: name,
|
||||
Title: frontmatter.Title,
|
||||
Description: frontmatter.Description,
|
||||
Category: frontmatter.Category,
|
||||
Triggers: frontmatter.Triggers,
|
||||
Content: body,
|
||||
Mode: frontmatter.Mode,
|
||||
Priority: frontmatter.Priority,
|
||||
Enabled: enabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Match(lessons []*Lesson, ctx MatchContext) []*MatchResult {
|
||||
var results []*MatchResult
|
||||
msgLower := strings.ToLower(ctx.Message)
|
||||
|
||||
for _, l := range lessons {
|
||||
if !l.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
score := 0.0
|
||||
|
||||
for _, kw := range l.Triggers.Keywords {
|
||||
if containsKeyword(msgLower, strings.ToLower(kw)) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range l.Triggers.Patterns {
|
||||
re, err := regexp.Compile("(?i)" + pattern)
|
||||
if err == nil && re.MatchString(ctx.Message) {
|
||||
score += 1.5
|
||||
}
|
||||
}
|
||||
|
||||
if len(ctx.ToolsUsed) > 0 && len(l.Triggers.Tools) > 0 {
|
||||
for _, usedTool := range ctx.ToolsUsed {
|
||||
for _, triggerTool := range l.Triggers.Tools {
|
||||
if usedTool == triggerTool {
|
||||
score += 2.0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if l.Name != "" {
|
||||
nameLower := strings.ToLower(l.Name)
|
||||
if strings.Contains(msgLower, nameLower) {
|
||||
score += 1.5
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0 {
|
||||
results = append(results, &MatchResult{
|
||||
Lesson: l,
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sortResults(results)
|
||||
return results
|
||||
}
|
||||
|
||||
func AutoInclude(systemPrompt string, lessons []*Lesson, ctx MatchContext, maxLessons int) string {
|
||||
if maxLessons <= 0 {
|
||||
maxLessons = 5
|
||||
}
|
||||
|
||||
results := Match(lessons, ctx)
|
||||
if len(results) == 0 {
|
||||
return systemPrompt
|
||||
}
|
||||
|
||||
if len(results) > maxLessons {
|
||||
results = results[:maxLessons]
|
||||
}
|
||||
|
||||
var lessonBlock strings.Builder
|
||||
lessonBlock.WriteString("\n\n--- Active Lessons ---\n\n")
|
||||
|
||||
for _, r := range results {
|
||||
lessonBlock.WriteString(fmt.Sprintf("## %s", r.Lesson.Name))
|
||||
if r.Lesson.Title != "" {
|
||||
lessonBlock.WriteString(fmt.Sprintf(" (%s)", r.Lesson.Title))
|
||||
}
|
||||
lessonBlock.WriteString("\n")
|
||||
lessonBlock.WriteString(r.Lesson.Content)
|
||||
lessonBlock.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return systemPrompt + lessonBlock.String()
|
||||
}
|
||||
|
||||
func EnsureBuiltinLessons() error {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lessonsDir := filepath.Join(home, ".muyue", "lessons")
|
||||
if err := os.MkdirAll(lessonsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, lesson := range BuiltinLessons() {
|
||||
path := filepath.Join(lessonsDir, lesson.Name+".md")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := WriteLesson(path, lesson); err != nil {
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteLesson(path string, lesson *Lesson) error {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("---\n")
|
||||
data, err := yaml.Marshal(&LessonFrontmatter{
|
||||
Name: lesson.Name,
|
||||
Title: lesson.Title,
|
||||
Description: lesson.Description,
|
||||
Category: lesson.Category,
|
||||
Mode: lesson.Mode,
|
||||
Priority: lesson.Priority,
|
||||
Enabled: &lesson.Enabled,
|
||||
Triggers: lesson.Triggers,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sb.WriteString(string(data))
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString(lesson.Content)
|
||||
|
||||
return os.WriteFile(path, []byte(sb.String()), 0644)
|
||||
}
|
||||
|
||||
func BuiltinLessons() []*Lesson {
|
||||
return []*Lesson{
|
||||
{
|
||||
Name: "code_style",
|
||||
Title: "Code Style Guidelines",
|
||||
Description: "Enforce consistent code style and formatting",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"code style", "formatting", "lint", "format", "indentation", "naming convention"},
|
||||
Tools: []string{"terminal"},
|
||||
},
|
||||
Content: `- Follow the existing code style in each file
|
||||
- Use consistent indentation (match surrounding code)
|
||||
- Prefer descriptive variable names over abbreviations
|
||||
- Keep functions focused and small
|
||||
- Add error handling for all external calls`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 5,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "git_workflow",
|
||||
Title: "Git Workflow Best Practices",
|
||||
Description: "Guidelines for git operations and commit practices",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"git", "commit", "branch", "merge", "pull request", "rebase"},
|
||||
Tools: []string{"terminal"},
|
||||
},
|
||||
Content: `- Write clear, descriptive commit messages
|
||||
- Use conventional commits format when applicable
|
||||
- Keep commits atomic and focused
|
||||
- Don't commit sensitive data or secrets
|
||||
- Test before committing`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 5,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "error_handling",
|
||||
Title: "Error Handling Patterns",
|
||||
Description: "Robust error handling guidelines",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"error", "panic", "exception", "crash", "fail", "nil pointer"},
|
||||
Tools: []string{"terminal", "read_file"},
|
||||
Patterns: []string{`err\s*!=\s*nil`, `panic\(`, `log\.Fatal`},
|
||||
},
|
||||
Content: `- Always check errors from external calls
|
||||
- Provide context when wrapping errors
|
||||
- Use sentinel errors for expected conditions
|
||||
- Log errors with enough context for debugging
|
||||
- Don't silently ignore errors`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 6,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "testing",
|
||||
Title: "Testing Best Practices",
|
||||
Description: "Guidelines for writing effective tests",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"test", "testing", "unit test", "integration test", "coverage"},
|
||||
Tools: []string{"terminal"},
|
||||
},
|
||||
Content: `- Write tests for critical paths first
|
||||
- Use table-driven tests for multiple cases
|
||||
- Keep tests independent and deterministic
|
||||
- Test error paths, not just happy paths
|
||||
- Aim for meaningful coverage, not just percentage`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 5,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "security",
|
||||
Title: "Security Guidelines",
|
||||
Description: "Security best practices for development",
|
||||
Category: "development",
|
||||
Triggers: Triggers{
|
||||
Keywords: []string{"security", "vulnerability", "inject", "sanitize", "auth", "secret", "password", "token"},
|
||||
Tools: []string{"terminal", "read_file", "web_fetch"},
|
||||
Patterns: []string{`SELECT\s.*\+`, `exec\.Command.*\+`, `os\.Getenv.*KEY`},
|
||||
},
|
||||
Content: `- Never log or expose secrets, API keys, or tokens
|
||||
- Validate and sanitize all user input
|
||||
- Use parameterized queries for database operations
|
||||
- Keep dependencies updated
|
||||
- Don't hardcode credentials`,
|
||||
Mode: ModeBoth,
|
||||
Priority: 8,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func containsKeyword(text, keyword string) bool {
|
||||
if keyword == "*" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(text, keyword)
|
||||
}
|
||||
|
||||
func sortResults(results []*MatchResult) {
|
||||
for i := 0; i < len(results)-1; i++ {
|
||||
for j := i + 1; j < len(results); j++ {
|
||||
if results[j].Score > results[i].Score {
|
||||
results[i], results[j] = results[j], results[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user