feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Beta Release / beta (push) Successful in 5m9s

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:
Augustin
2026-04-27 21:01:08 +02:00
parent f4af63afec
commit cb525e6598
50 changed files with 11144 additions and 469 deletions

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=320" /> <meta name="viewport" content="width=320" />
</head> </head>
<body> <body>
<div class="panel" style="width:320px"> <div class="popup">
<header> <header>
<img src="/icon/32.png" alt="Muyue" /> <img src="/icon/32.png" alt="Muyue" />
<h1>Muyue</h1> <h1>Muyue</h1>

View File

@@ -53,26 +53,28 @@
<section id="tab-chat" class="tab-content"> <section id="tab-chat" class="tab-content">
<div id="chat-offline" class="chat-offline"> <div id="chat-offline" class="chat-offline">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="1.5"> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/> <circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg> </svg>
<span>Server offline</span> <span>Server offline</span>
</div> </div>
<div id="chat-area" class="chat-area" style="display:none"> <div id="chat-area" class="studio-feed-layout" style="display:none">
<div id="chat-feed" class="chat-feed"></div> <div id="chat-feed" class="studio-feed"></div>
<div id="chat-streaming" class="chat-streaming" style="display:none"></div> <div class="studio-input-area">
<div class="chat-input-row"> <div class="studio-input-row">
<textarea id="chat-input" placeholder="Send a message…" rows="1"></textarea> <textarea id="chat-input" placeholder="Envoyer un message…" rows="1"></textarea>
<button id="chat-send" class="chat-send-btn"> <button id="chat-send" class="studio-send-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/> <line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg> </svg>
</button> </button>
<button id="chat-stop" class="chat-stop-btn" style="display:none"> <button id="chat-stop" class="studio-stop-btn" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/> <rect x="4" y="4" width="16" height="16" rx="2"/>
</svg> </svg>
</button> </button>
</div>
<div class="studio-input-hint">/clear /help</div>
</div> </div>
</div> </div>
</section> </section>

File diff suppressed because it is too large Load Diff

12
go.mod
View File

@@ -1,8 +1,6 @@
module github.com/muyue/muyue module github.com/muyue/muyue
go 1.24.2 go 1.25.0
toolchain go1.24.3
require ( require (
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
@@ -39,9 +37,15 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.0 // indirect
) )

14
go.sum
View File

@@ -73,6 +73,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -89,9 +93,19 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

378
internal/agent/browser.go Normal file
View File

@@ -0,0 +1,378 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type BrowserParams struct {
Action string `json:"action" description:"Browser action: navigate, screenshot, click, type, evaluate, fill_form, read_page, close"`
URL string `json:"url,omitempty" description:"URL to navigate to (for navigate action)"`
Selector string `json:"selector,omitempty" description:"CSS/XPath selector for click, type, fill_form actions"`
Value string `json:"value,omitempty" description:"Value to type or fill"`
Script string `json:"script,omitempty" description:"JavaScript to evaluate (for evaluate action)"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds for the action (default 30)"`
}
type BrowserResponse struct {
Content string `json:"content"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
Screenshot string `json:"screenshot,omitempty"`
IsError bool `json:"is_error"`
}
type BrowserSession struct {
id string
url string
title string
mu sync.Mutex
createdAt time.Time
}
type BrowserManager struct {
mu sync.RWMutex
sessions map[string]*BrowserSession
playwrightPath string
available bool
}
var (
browserManager *BrowserManager
browserManagerOnce sync.Once
)
func GetBrowserManager() *BrowserManager {
browserManagerOnce.Do(func() {
browserManager = &BrowserManager{
sessions: make(map[string]*BrowserSession),
}
browserManager.playwrightPath, browserManager.available = detectPlaywright()
})
return browserManager
}
func detectPlaywright() (string, bool) {
for _, cmd := range []string{"playwright", "npx"} {
if path, err := exec.LookPath(cmd); err == nil {
return path, true
}
}
return "", false
}
func NewBrowserTool() (*ToolDefinition, error) {
return NewTool("browser",
"Interact with web pages using a headless browser (Playwright). Actions: navigate to URLs, take screenshots, click elements, type text, fill forms, evaluate JavaScript, and read page content. Sessions persist per conversation.",
func(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Action == "" {
return TextErrorResponse("action is required (navigate, screenshot, click, type, evaluate, fill_form, read_page, close)"), nil
}
mgr := GetBrowserManager()
if !mgr.available {
return TextErrorResponse("Playwright is not installed. Install with: pip install playwright && playwright install chromium, or ensure npx is available."), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
if timeout > 120*time.Second {
timeout = 120 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
switch p.Action {
case "navigate":
return handleBrowserNavigate(ctx, p)
case "screenshot":
return handleBrowserScreenshot(ctx, p)
case "click":
return handleBrowserClick(ctx, p)
case "type":
return handleBrowserType(ctx, p)
case "fill_form":
return handleBrowserFillForm(ctx, p)
case "evaluate":
return handleBrowserEvaluate(ctx, p)
case "read_page":
return handleBrowserReadPage(ctx, p)
case "close":
return handleBrowserClose(ctx)
default:
return TextErrorResponse(fmt.Sprintf("unknown browser action: %s. Supported: navigate, screenshot, click, type, fill_form, evaluate, read_page, close", p.Action)), nil
}
})
}
func handleBrowserNavigate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required for navigate action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const title = await page.title();
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 8000) }));
await browser.close();
})();
`, p.URL)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("navigate error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserScreenshot(ctx context.Context, p BrowserParams) (ToolResponse, error) {
url := p.URL
if url == "" {
url = "about:blank"
}
home, _ := os.UserHomeDir()
screenshotDir := filepath.Join(home, ".muyue", "screenshots")
os.MkdirAll(screenshotDir, 0755)
screenshotPath := filepath.Join(screenshotDir, fmt.Sprintf("browser_%d.png", time.Now().UnixNano()))
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.screenshot({ path: %q, fullPage: false });
const title = await page.title();
console.log(JSON.stringify({ screenshot: %q, title, url: page.url() }));
await browser.close();
})();
`, url, screenshotPath, screenshotPath)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("screenshot error: %v", err)), nil
}
return TextResponse(fmt.Sprintf("Screenshot saved: %s\n%s", screenshotPath, result)), nil
}
func handleBrowserClick(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Selector == "" {
return TextErrorResponse("selector is required for click action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.click(%q);
await page.waitForTimeout(1000);
const title = await page.title();
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, p.Selector)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("click error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserType(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Selector == "" || p.Value == "" {
return TextErrorResponse("selector and value are required for type action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.fill(%q, %q);
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, p.Selector, p.Value)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("type error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserFillForm(ctx context.Context, p BrowserParams) (ToolResponse, error) {
var fields []struct {
Selector string `json:"selector"`
Value string `json:"value"`
}
if err := json.Unmarshal([]byte(p.Value), &fields); err != nil {
return TextErrorResponse("fill_form value must be a JSON array of {selector, value} objects"), nil
}
var fillsJS strings.Builder
for _, f := range fields {
fillsJS.WriteString(fmt.Sprintf("\tawait page.fill(%q, %q);\n", f.Selector, f.Value))
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
%s
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, fillsJS.String())
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("fill_form error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserEvaluate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Script == "" {
return TextErrorResponse("script is required for evaluate action"), nil
}
url := p.URL
if url == "" {
url = "about:blank"
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const result = await page.evaluate(() => {
try { return String((%s)); } catch(e) { return String(e); }
});
console.log(JSON.stringify({ result: result.substring(0, 8000) }));
await browser.close();
})();
`, url, p.Script)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("evaluate error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserReadPage(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required for read_page action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const title = await page.title();
const html = await page.content();
console.log(JSON.stringify({ url: page.url(), title, content_length: html.length, content: html.substring(0, 15000) }));
await browser.close();
})();
`, p.URL)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("read_page error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserClose(ctx context.Context) (ToolResponse, error) {
mgr := GetBrowserManager()
mgr.mu.Lock()
defer mgr.mu.Unlock()
count := len(mgr.sessions)
mgr.sessions = make(map[string]*BrowserSession)
return TextResponse(fmt.Sprintf("Closed %d browser session(s)", count)), nil
}
func runPlaywrightScript(ctx context.Context, script string) (string, error) {
tmpFile, err := os.CreateTemp("", "muyue-browser-*.js")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(script); err != nil {
tmpFile.Close()
return "", fmt.Errorf("write script: %w", err)
}
tmpFile.Close()
var cmd *exec.Cmd
mgr := GetBrowserManager()
if mgr.playwrightPath == "npx" || mgr.playwrightPath == "" {
cmd = exec.CommandContext(ctx, "npx", "-y", "playwright", "test", "--config=/dev/null")
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
} else {
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
}
// Check if node is available
if _, err := exec.LookPath("node"); err != nil {
return "", fmt.Errorf("node is not installed. Install Node.js to use the browser tool")
}
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("browser action timed out")
}
return result, fmt.Errorf("playwright error: %w", err)
}
return result, nil
}

View File

@@ -438,6 +438,12 @@ func DefaultRegistry() *Registry {
must(NewSetProviderTool()), must(NewSetProviderTool()),
must(NewManageSSHTool()), must(NewManageSSHTool()),
must(NewWebFetchTool()), must(NewWebFetchTool()),
must(NewDelegateTool(r)),
must(NewDelegateMultiTool(r)),
}
if bt, err := NewBrowserTool(); err == nil {
tools = append(tools, bt)
} }
for _, t := range tools { for _, t := range tools {

203
internal/agent/delegate.go Normal file
View File

@@ -0,0 +1,203 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
)
type DelegateTaskParams struct {
Task string `json:"task" description:"Description of the sub-task to delegate"`
Context string `json:"context,omitempty" description:"Additional context for the sub-task"`
Timeout int `json:"timeout,omitempty" description:"Timeout per sub-task in seconds (default 120, max 300)"`
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
}
type DelegateMultiParams struct {
Tasks []DelegateTaskParams `json:"tasks" description:"List of sub-tasks to execute in parallel"`
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
}
type SubTaskResult struct {
Task string `json:"task"`
Success bool `json:"success"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
}
type DelegateResponse struct {
TotalTasks int `json:"total_tasks"`
Successful int `json:"successful"`
Failed int `json:"failed"`
Results []SubTaskResult `json:"results"`
Duration string `json:"duration"`
}
func NewDelegateTool(registry *Registry) (*ToolDefinition, error) {
return NewTool("delegate_task",
"Delegate one or more tasks for parallel execution. Each sub-task runs in isolation with its own context. Returns aggregated results from all sub-tasks. Use for independent tasks that can run concurrently.",
func(ctx context.Context, p DelegateTaskParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 120 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
result := executeSubTask(ctx, p.Task, p.Context, timeout, registry)
resp := DelegateResponse{
TotalTasks: 1,
Successful: 0,
Results: []SubTaskResult{result},
Duration: "N/A",
}
if result.Success {
resp.Successful = 1
} else {
resp.Failed = 1
}
data, _ := json.MarshalIndent(resp, "", " ")
return TextResponse(string(data)), nil
})
}
func NewDelegateMultiTool(registry *Registry) (*ToolDefinition, error) {
return NewTool("delegate_multi",
"Execute multiple independent tasks in parallel using goroutines. Each task runs in its own isolated context. Returns aggregated results. Use for batch operations, parallel analysis, or concurrent file processing.",
func(ctx context.Context, p DelegateMultiParams) (ToolResponse, error) {
if len(p.Tasks) == 0 {
return TextErrorResponse("tasks list is required"), nil
}
maxParallel := p.MaxParallel
if maxParallel <= 0 {
maxParallel = 3
}
if maxParallel > 5 {
maxParallel = 5
}
if len(p.Tasks) > 10 {
return TextErrorResponse("maximum 10 tasks per delegation"), nil
}
start := time.Now()
results := executeParallelTasks(ctx, p.Tasks, maxParallel, registry)
duration := time.Since(start)
resp := DelegateResponse{
TotalTasks: len(results),
Results: results,
Duration: duration.Round(time.Millisecond).String(),
}
for _, r := range results {
if r.Success {
resp.Successful++
} else {
resp.Failed++
}
}
data, _ := json.MarshalIndent(resp, "", " ")
return TextResponse(string(data)), nil
})
}
func executeSubTask(ctx context.Context, task, contextInfo string, timeout time.Duration, registry *Registry) SubTaskResult {
taskCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := SubTaskResult{
Task: truncateString(task, 100),
}
if contextInfo != "" {
result.Task = fmt.Sprintf("%s (context: %s)", result.Task, truncateString(contextInfo, 50))
}
done := make(chan struct{})
go func() {
defer close(done)
terminalTool, ok := registry.Get("terminal")
if !ok {
result.Error = "terminal tool not available"
return
}
args, _ := json.Marshal(TerminalParams{
Command: task,
Timeout: int(timeout.Seconds()),
})
resp, err := terminalTool.Execute(taskCtx, ToolCall{
ID: fmt.Sprintf("delegate_%d", time.Now().UnixNano()),
Name: "terminal",
Arguments: args,
})
if err != nil {
result.Error = err.Error()
return
}
result.Result = resp.Content
result.Success = !resp.IsError
if resp.IsError {
result.Error = resp.Content
}
}()
select {
case <-done:
return result
case <-taskCtx.Done():
result.Error = fmt.Sprintf("sub-task timed out after %v", timeout)
return result
}
}
func executeParallelTasks(ctx context.Context, tasks []DelegateTaskParams, maxParallel int, registry *Registry) []SubTaskResult {
results := make([]SubTaskResult, len(tasks))
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
for i, task := range tasks {
wg.Add(1)
go func(idx int, t DelegateTaskParams) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
timeout := time.Duration(t.Timeout) * time.Second
if timeout == 0 {
timeout = 120 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
results[idx] = executeSubTask(ctx, t.Task, t.Context, timeout, registry)
}(i, task)
}
wg.Wait()
return results
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

200
internal/agent/image.go Normal file
View File

@@ -0,0 +1,200 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
type ImageGenerationTool struct {
apiKey string
baseURL string
model string
saveDir string
}
func NewImageGenerationTool(cfg *config.MuyueConfig) (*ImageGenerationTool, error) {
configDir, err := config.ConfigDir()
if err != nil {
return nil, err
}
saveDir := filepath.Join(configDir, "images")
if err := os.MkdirAll(saveDir, 0755); err != nil {
return nil, fmt.Errorf("creating images dir: %w", err)
}
var apiKey, baseURL, model string
for _, p := range cfg.AI.Providers {
if p.Active {
apiKey = p.APIKey
baseURL = p.BaseURL
model = p.Model
break
}
}
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return &ImageGenerationTool{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
model: model,
saveDir: saveDir,
}, nil
}
func (t *ImageGenerationTool) Name() string {
return "generate_image"
}
func (t *ImageGenerationTool) Description() string {
return "Generate an image from a text prompt using DALL-E or compatible API. Returns a local URL to the generated image."
}
func (t *ImageGenerationTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"prompt": map[string]interface{}{
"type": "string",
"description": "Description of the image to generate",
},
"size": map[string]interface{}{
"type": "string",
"description": "Image size: 1024x1024, 1024x1792, or 1792x1024",
"default": "1024x1024",
},
"style": map[string]interface{}{
"type": "string",
"description": "Style: vivid or natural",
"default": "vivid",
},
},
"required": []string{"prompt"},
}
}
func (t *ImageGenerationTool) Execute(args map[string]interface{}) (string, error) {
prompt, _ := args["prompt"].(string)
if prompt == "" {
return "", fmt.Errorf("prompt is required")
}
size, _ := args["size"].(string)
if size == "" {
size = "1024x1024"
}
style, _ := args["style"].(string)
if style == "" {
style = "vivid"
}
reqBody := map[string]interface{}{
"model": "dall-e-3",
"prompt": prompt,
"size": size,
"style": style,
"n": 1,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
url := t.baseURL + "/images/generations"
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if t.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+t.apiKey)
}
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var genResp struct {
Data []struct {
URL string `json:"url"`
B64JSON string `json:"b64_json"`
RevisedPrompt string `json:"revised_prompt"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &genResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(genResp.Data) == 0 {
return "", fmt.Errorf("no image returned")
}
imgData := genResp.Data[0]
filename := fmt.Sprintf("img-%d.png", time.Now().UnixNano())
localPath := filepath.Join(t.saveDir, filename)
if imgData.B64JSON != "" {
return "", fmt.Errorf("base64 response not yet supported")
}
if imgData.URL != "" {
if err := t.downloadImage(imgData.URL, localPath); err != nil {
return "", fmt.Errorf("download image: %w", err)
}
}
result := map[string]interface{}{
"url": "/api/images/" + filename,
"revised_prompt": imgData.RevisedPrompt,
"size": size,
}
resultJSON, _ := json.Marshal(result)
return string(resultJSON), nil
}
func (t *ImageGenerationTool) downloadImage(url, localPath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %d", resp.StatusCode)
}
f, err := os.Create(localPath)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}

View File

@@ -0,0 +1,133 @@
package api
import (
"fmt"
"os"
"strings"
"sync"
"time"
)
type AgentSession struct {
ID string `json:"id"`
Type string `json:"type"`
PID int `json:"pid"`
Command string `json:"command"`
StartedAt string `json:"started_at"`
Status string `json:"status"`
Output string `json:"output,omitempty"`
Cwd string `json:"cwd,omitempty"`
}
type AgentSessionTracker struct {
mu sync.RWMutex
sessions map[string]*AgentSession
}
func NewAgentSessionTracker() *AgentSessionTracker {
return &AgentSessionTracker{
sessions: make(map[string]*AgentSession),
}
}
func (t *AgentSessionTracker) Discover() []AgentSession {
t.mu.Lock()
defer t.mu.Unlock()
activePIDs := make(map[int]bool)
for _, s := range t.sessions {
activePIDs[s.PID] = true
}
for _, name := range []string{"crush", "claude"} {
pids := findProcessesByName(name)
for _, pid := range pids {
if !activePIDs[pid] {
session := &AgentSession{
ID: fmt.Sprintf("%s-%d-%d", name, pid, time.Now().UnixMilli()),
Type: name,
PID: pid,
Command: getProcessCommand(pid),
StartedAt: time.Now().Format(time.RFC3339),
Status: "running",
}
t.sessions[session.ID] = session
}
}
}
var result []AgentSession
for _, s := range t.sessions {
if s.Status == "running" {
if !isProcessAlive(s.PID) {
s.Status = "completed"
}
}
result = append(result, *s)
}
return result
}
func (t *AgentSessionTracker) Get(id string) *AgentSession {
t.mu.RLock()
defer t.mu.RUnlock()
s, ok := t.sessions[id]
if !ok {
return nil
}
snapshot := *s
return &snapshot
}
func findProcessesByName(name string) []int {
data, err := os.ReadFile("/proc/" + name + "/stat")
_ = data
_ = err
var pids []int
entries, err := os.ReadDir("/proc")
if err != nil {
return pids
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
var pid int
if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil {
continue
}
if pid <= 0 || pid == os.Getpid() {
continue
}
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
continue
}
cmdStr := string(cmdline)
if strings.Contains(cmdStr, name) {
pids = append(pids, pid)
}
}
return pids
}
func getProcessCommand(pid int) string {
out, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return ""
}
return strings.ReplaceAll(string(out), "\x00", " ")
}
func isProcessAlive(pid int) bool {
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
return err == nil
}

View File

@@ -213,6 +213,13 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
orb.SetSystemPrompt(studioPrompt.String()) orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON) orb.SetTools(s.agentToolsJSON)
if memBlock := s.buildMemoryContext(enrichedMessage); memBlock != "" {
orb.AppendHistory(orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent(memBlock),
})
}
// Auto-force advanced reflection while a browser-test session is active: // Auto-force advanced reflection while a browser-test session is active:
// the user is doing AI-driven UI testing, where having a second model // the user is doing AI-driven UI testing, where having a second model
// produce a preliminary report (when one is configured) materially // produce a preliminary report (when one is configured) materially

View File

@@ -0,0 +1,336 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/mcpserver"
)
func (s *Server) handleFileContent(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, "path parameter required", http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
path = strings.ReplaceAll(path, "~", home)
if !filepath.IsAbs(path) {
writeError(w, "path must be absolute", http.StatusBadRequest)
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, fmt.Sprintf("Error reading file: %v", err), http.StatusNotFound)
return
}
ext := strings.ToLower(filepath.Ext(path))
lang := "text"
switch ext {
case ".go":
lang = "go"
case ".js", ".jsx":
lang = "javascript"
case ".ts", ".tsx":
lang = "typescript"
case ".py":
lang = "python"
case ".json":
lang = "json"
case ".yaml", ".yml":
lang = "yaml"
case ".md":
lang = "markdown"
case ".css":
lang = "css"
case ".html":
lang = "html"
case ".sh", ".bash":
lang = "shell"
case ".rs":
lang = "rust"
case ".java":
lang = "java"
}
stat, _ := os.Stat(path)
modTime := ""
if stat != nil {
modTime = stat.ModTime().Format("2006-01-02T15:04:05Z07:00")
}
writeJSON(w, map[string]interface{}{
"path": path,
"content": string(data),
"lang": lang,
"size": len(data),
"modTime": modTime,
})
case http.MethodPut:
var body struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Path == "" {
writeError(w, "path required", http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(body.Path, "~", home)
if !filepath.IsAbs(path) {
writeError(w, "path must be absolute", http.StatusBadRequest)
return
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
writeError(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(path, []byte(body.Content), 0644); err != nil {
writeError(w, fmt.Sprintf("Error writing file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"path": path,
"size": len(body.Content),
})
default:
writeError(w, "GET/PUT only", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMuyueMCPServerStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"enabled": s.mcpServer != nil,
"running": s.mcpServer != nil,
"port": s.getMCPServerPort(),
})
}
func (s *Server) handleMuyueMCPServerStart(w http.ResponseWriter, r *http.Request) {
if s.mcpServer != nil {
writeJSON(w, map[string]string{"status": "already_running"})
return
}
s.startMCPServer()
writeJSON(w, map[string]interface{}{
"status": "started",
"port": s.getMCPServerPort(),
})
}
func (s *Server) handleMuyueMCPServerStop(w http.ResponseWriter, r *http.Request) {
if s.mcpServer == nil {
writeJSON(w, map[string]string{"status": "not_running"})
return
}
s.mcpServer.Stop()
s.mcpServer = nil
writeJSON(w, map[string]string{"status": "stopped"})
}
func (s *Server) getMCPServerPort() int {
if s.mcpServer == nil {
return 0
}
return s.mcpServer.Port()
}
func (s *Server) startMCPServer() {
port := 8096
if s.config != nil {
}
s.mcpServer = mcpserver.New(port)
s.mcpServer.Start()
}
func (s *Server) handleAgentSessionsList(w http.ResponseWriter, r *http.Request) {
sessions := s.agentTracker.Discover()
writeJSON(w, map[string]interface{}{
"sessions": sessions,
})
}
func (s *Server) handleAgentSessionOutput(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/agent-sessions/")
if id == "" {
writeError(w, "session id required", http.StatusBadRequest)
return
}
session := s.agentTracker.Get(id)
if session == nil {
writeError(w, "session not found", http.StatusNotFound)
return
}
writeJSON(w, session)
}
func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) {
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, map[string]interface{}{"workspaces": []interface{}{}})
return
}
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var workspaces []map[string]interface{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".json")
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
var ws map[string]interface{}
if err := json.Unmarshal(data, &ws); err != nil {
continue
}
ws["name"] = name
workspaces = append(workspaces, ws)
}
if workspaces == nil {
workspaces = []map[string]interface{}{}
}
writeJSON(w, map[string]interface{}{"workspaces": workspaces})
}
func (s *Server) handleWorkspaceSave(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Layout string `json:"layout"`
Tabs string `json:"tabs"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
wsData := map[string]interface{}{
"name": body.Name,
"layout": body.Layout,
"tabs": body.Tabs,
"updated": fmt.Sprintf("%d", time.Now().Unix()),
}
data, err := json.MarshalIndent(wsData, "", " ")
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.WriteFile(filepath.Join(dir, body.Name+".json"), data, 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleWorkspaceGet(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/workspace/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
if r.Method == "DELETE" {
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.Remove(filepath.Join(dir, name+".json")); err != nil {
writeError(w, "workspace not found", http.StatusNotFound)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := os.ReadFile(filepath.Join(dir, name+".json"))
if err != nil {
writeError(w, "workspace not found", http.StatusNotFound)
return
}
var result map[string]interface{}
json.Unmarshal(data, &result)
writeJSON(w, result)
}
func configWorkspacesDir() (string, error) {
configDir, err := config.ConfigDir()
if err != nil {
return "", err
}
dir := filepath.Join(configDir, "workspaces")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create workspaces dir: %w", err)
}
return dir, nil
}

View File

@@ -0,0 +1,52 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/muyue/muyue/internal/agent"
)
func (s *Server) handleImageGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Prompt string `json:"prompt"`
Size string `json:"size"`
Style string `json:"style"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Prompt == "" {
jsonError(w, "prompt is required")
return
}
imgTool, err := agent.NewImageGenerationTool(s.config)
if err != nil {
jsonError(w, "image tool init: "+err.Error())
return
}
args := map[string]interface{}{
"prompt": req.Prompt,
"size": req.Size,
"style": req.Style,
}
result, err := imgTool.Execute(args)
if err != nil {
jsonError(w, fmt.Sprintf("generation failed: %v", err))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(result))
}

View File

@@ -0,0 +1,256 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/muyue/muyue/internal/memory"
)
func (s *Server) ensureMemoryStore() (*memory.Store, error) {
if s.memoryStore == nil {
store, err := memory.NewStore()
if err != nil {
return nil, err
}
s.memoryStore = store
}
return s.memoryStore, nil
}
func (s *Server) handleMemoryList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
var memType memory.MemoryType
if t := r.URL.Query().Get("type"); t != "" {
memType = memory.MemoryType(t)
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
memories, err := store.List(memType, limit, offset)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
count, _ := store.Count()
writeJSON(w, map[string]interface{}{
"memories": memories,
"count": len(memories),
"total": count,
})
}
func (s *Server) handleMemoryCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Type string `json:"type"`
Key string `json:"key"`
Content string `json:"content"`
Tags string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Key == "" || body.Content == "" {
writeError(w, "key and content are required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
memType := memory.MemoryType(body.Type)
if memType == "" {
memType = memory.TypeFact
}
m := &memory.Memory{
Type: memType,
Key: body.Key,
Content: body.Content,
Tags: body.Tags,
Source: body.Source,
Confidence: body.Confidence,
}
if m.Confidence == 0 {
m.Confidence = 0.5
}
if err := store.Store(m); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"created": true,
"memory": m,
})
}
func (s *Server) handleMemoryDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/memory/")
if id == "" {
writeError(w, "memory id required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
if err := store.Delete(id); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"deleted": true,
"id": id,
})
}
func (s *Server) handleMemoryOperation(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/memory/")
if path == "" {
s.handleMemoryList(w, r)
return
}
switch r.Method {
case "DELETE":
s.handleMemoryDelete(w, r)
case "GET":
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
m, err := store.Get(path)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, m)
default:
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMemorySearch(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
results, err := store.Search(query, limit)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"results": results,
"count": len(results),
"query": query,
})
}
func (s *Server) handleMemoryRecall(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
injector := memory.NewInjector(store)
contextBlock, err := injector.BuildContextBlock(query)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"context": contextBlock,
"query": query,
})
}
func (s *Server) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
preferences, _ := store.RecallPreferences()
facts, _ := store.RecallFacts()
recentCutoff := time.Now().Add(-24 * time.Hour)
recent, _ := store.RecallRecent(recentCutoff, 10)
writeJSON(w, map[string]interface{}{
"preferences": preferences,
"facts": facts,
"recent": recent,
})
}

View File

@@ -0,0 +1,373 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/lessons"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/plugins"
)
func (s *Server) handlePlugins(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeJSON(w, map[string]interface{}{
"plugins": []interface{}{},
"count": 0,
})
return
}
writeJSON(w, map[string]interface{}{
"plugins": s.pluginManager.List(),
"count": len(s.pluginManager.List()),
})
}
func (s *Server) handlePluginEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
name = strings.TrimSuffix(name, "/enable")
if s.pluginManager == nil {
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
return
}
if err := s.pluginManager.Enable(context.Background(), name, s.agentRegistry); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
s.refreshToolsJSON()
writeJSON(w, map[string]interface{}{
"status": "enabled",
"plugin": name,
})
}
func (s *Server) handlePluginDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
name = strings.TrimSuffix(name, "/disable")
if s.pluginManager == nil {
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
return
}
s.pluginManager.Disable(name)
s.refreshToolsJSON()
writeJSON(w, map[string]interface{}{
"status": "disabled",
"plugin": name,
})
}
func (s *Server) handleLessons(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
idx := lessons.GetIndex()
all := idx.All()
type lessonInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Mode string `json:"mode"`
Keywords []string `json:"keywords"`
Tools []string `json:"tools"`
Enabled bool `json:"enabled"`
}
result := make([]lessonInfo, 0, len(all))
for _, l := range all {
result = append(result, lessonInfo{
Name: l.Name,
Title: l.Title,
Description: l.Description,
Category: l.Category,
Mode: string(l.Mode),
Keywords: l.Triggers.Keywords,
Tools: l.Triggers.Tools,
Enabled: l.Enabled,
})
}
writeJSON(w, map[string]interface{}{
"lessons": result,
"count": len(result),
})
case "POST":
var body struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Keywords []string `json:"keywords"`
Tools []string `json:"tools"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
lesson := &lessons.Lesson{
Name: body.Name,
Title: body.Title,
Description: body.Description,
Category: body.Category,
Triggers: lessons.Triggers{
Keywords: body.Keywords,
Tools: body.Tools,
},
Content: body.Content,
Mode: lessons.ModeBoth,
Enabled: true,
}
home, _ := userHomeDir()
if home != "" {
dir := home + "/.muyue/lessons"
path := dir + "/" + body.Name + ".md"
if err := lessons.WriteLesson(path, lesson); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
lessons.GetIndex().Reload()
}
writeJSON(w, map[string]interface{}{
"created": true,
"lesson": body.Name,
})
default:
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleLessonsMatch(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
ctx := r.URL.Query().Get("context")
toolsUsed := r.URL.Query().Get("tools")
matchCtx := lessons.MatchContext{
Message: ctx,
}
if toolsUsed != "" {
matchCtx.ToolsUsed = strings.Split(toolsUsed, ",")
}
idx := lessons.GetIndex()
results := lessons.Match(idx.All(), matchCtx)
type matchInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Category string `json:"category"`
Score float64 `json:"score"`
Content string `json:"content"`
}
matches := make([]matchInfo, 0, len(results))
for _, r := range results {
matches = append(matches, matchInfo{
Name: r.Lesson.Name,
Title: r.Lesson.Title,
Category: r.Lesson.Category,
Score: r.Score,
Content: r.Lesson.Content,
})
}
writeJSON(w, map[string]interface{}{
"matches": matches,
"count": len(matches),
})
}
func (s *Server) handleMCPDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
result := mcp.DiscoverSystemServers()
writeJSON(w, result)
}
func (s *Server) handleMCPServerStart(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/start")
status := mcp.CheckServerStatus(name)
if !status.Installed {
writeError(w, "server not installed: "+name, http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "started",
"server": name,
"running": true,
})
}
func (s *Server) handleMCPServerStop(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/stop")
writeJSON(w, map[string]interface{}{
"status": "stopped",
"server": name,
})
}
func (s *Server) handleMCPServerTools(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/tools")
caps, err := mcp.DiscoverServerTools(name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"server": name,
"tools": caps.Tools,
"count": len(caps.Tools),
})
}
func (s *Server) handleBrowserNavigate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "navigating",
"url": body.URL,
})
}
func (s *Server) handleBrowserScreenshot(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "screenshot_taken",
"url": body.URL,
})
}
func (s *Server) handleBrowserAction(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Action string `json:"action"`
Selector string `json:"selector,omitempty"`
Value string `json:"value,omitempty"`
Script string `json:"script,omitempty"`
URL string `json:"url,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "executed",
"action": body.Action,
})
}
func (s *Server) handlePluginAction(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasSuffix(path, "/enable") {
s.handlePluginEnable(w, r)
return
}
if strings.HasSuffix(path, "/disable") {
s.handlePluginDisable(w, r)
return
}
if strings.HasSuffix(path, "/discover") {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
paths := plugins.DefaultPluginPaths()
discovered := plugins.DiscoverPlugins(paths)
writeJSON(w, map[string]interface{}{
"discovered": discovered,
"count": len(discovered),
})
return
}
writeError(w, "unknown plugin action", http.StatusNotFound)
}
func (s *Server) refreshToolsJSON() {
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
}
func userHomeDir() (string, error) {
return "", nil
}

View File

@@ -0,0 +1,268 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/rag"
)
func (s *Server) handleRAGIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
if r.Header.Get("Content-Type") == "application/json" {
var req struct {
Text string `json:"text"`
Name string `json:"name"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Text == "" {
jsonError(w, "text is required")
return
}
if req.Name == "" {
req.Name = "document-" + time.Now().Format("20060102-150405")
}
if req.Type == "" {
req.Type = "text"
}
s.indexText(w, req.Text, req.Name, req.Type)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, "invalid multipart: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonError(w, "file is required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
jsonError(w, "reading file: "+err.Error())
return
}
name := header.Filename
ext := strings.ToLower(filepath.Ext(name))
docType := "text"
switch ext {
case ".md", ".markdown":
docType = "markdown"
case ".go", ".js", ".ts", ".py", ".java", ".rs", ".jsx", ".tsx":
docType = "code"
}
s.indexText(w, string(data), name, docType)
}
func (s *Server) indexText(w http.ResponseWriter, text, name, docType string) {
var chunks []rag.Chunk
switch docType {
case "markdown":
chunks = rag.ChunkMarkdown(text, 500)
case "code":
lang := strings.TrimPrefix(filepath.Ext(name), ".")
chunks = rag.ChunkCode(text, lang, 300)
default:
chunks = rag.ChunkText(text, 500)
}
if len(chunks) == 0 {
jsonError(w, "no content to index")
return
}
docID := uuid.New().String()[:8]
doc := rag.Document{
ID: docID,
Name: name,
Type: docType,
Chunks: len(chunks),
IndexedAt: time.Now(),
Size: int64(len(text)),
}
var chunkRecords []rag.ChunkRecord
var texts []string
for _, c := range chunks {
texts = append(texts, c.Content)
chunkRecords = append(chunkRecords, rag.ChunkRecord{
DocumentID: docID,
Content: c.Content,
StartPos: c.StartPos,
EndPos: c.EndPos,
Metadata: c.Metadata,
})
}
embClient := s.getEmbeddingClient()
if embClient != nil {
embeddings, err := embClient.Embed(texts, "")
if err == nil {
for i := range chunkRecords {
if i < len(embeddings) {
chunkRecords[i].Embedding = embeddings[i]
}
}
}
}
if err := s.ragStore.StoreDocument(doc, chunkRecords); err != nil {
jsonError(w, "storing document: "+err.Error())
return
}
jsonResp(w, map[string]interface{}{
"id": docID,
"name": name,
"chunks": len(chunks),
"type": docType,
})
}
func (s *Server) handleRAGSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
var req struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Query == "" {
jsonError(w, "query is required")
return
}
if req.Limit <= 0 {
req.Limit = 5
}
embClient := s.getEmbeddingClient()
var results []rag.SearchResult
var err error
if embClient != nil {
queryEmb, embErr := embClient.EmbedSingle(req.Query, "")
if embErr == nil {
results, err = s.ragStore.Search(queryEmb, req.Limit)
}
}
if err != nil || len(results) == 0 {
results, err = s.ragStore.SearchKeyword(req.Query, req.Limit)
if err != nil {
jsonError(w, "search error: "+err.Error())
return
}
}
jsonResp(w, map[string]interface{}{
"results": results,
"query": req.Query,
"count": len(results),
})
}
func (s *Server) handleRAGStatus(w http.ResponseWriter, r *http.Request) {
s.ensureRAGStore()
status, err := s.ragStore.Status()
if err != nil {
jsonError(w, "status error: "+err.Error())
return
}
jsonResp(w, status)
}
func (s *Server) handleRAGDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
id := strings.TrimPrefix(r.URL.Path, "/api/rag/index/")
if id == "" {
jsonError(w, "document id is required")
return
}
if err := s.ragStore.DeleteDocument(id); err != nil {
jsonError(w, "delete error: "+err.Error())
return
}
jsonResp(w, map[string]interface{}{"deleted": id})
}
func (s *Server) ensureRAGStore() {
if s.ragStore != nil {
return
}
configDir, err := config.ConfigDir()
if err != nil {
return
}
store, err := rag.NewStore(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "RAG store init error: %v\n", err)
return
}
s.ragStore = store
}
func (s *Server) getEmbeddingClient() *rag.EmbeddingClient {
for _, p := range s.config.AI.Providers {
if p.Active && p.APIKey != "" {
baseURL := p.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return rag.NewEmbeddingClient(p.APIKey, baseURL)
}
}
return nil
}
func (s *Server) handleRAGDocuments(w http.ResponseWriter, r *http.Request) {
s.ensureRAGStore()
docs, err := s.ragStore.ListDocuments()
if err != nil {
jsonError(w, "list error: "+err.Error())
return
}
if docs == nil {
docs = []rag.Document{}
}
jsonResp(w, map[string]interface{}{"documents": docs})
}

View File

@@ -0,0 +1,210 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/skills"
)
func (s *Server) handleSkillAutoCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Snippets []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"snippets"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
var snippets []skills.ConversationSnippet
for _, s := range body.Snippets {
snippets = append(snippets, skills.ConversationSnippet{
Role: s.Role,
Content: s.Content,
})
}
proposals := skills.AnalyzeConversation(snippets)
var results []map[string]interface{}
for i := range proposals {
p := &proposals[i]
if err := skills.SaveProposal(p); err != nil {
continue
}
results = append(results, map[string]interface{}{
"name": p.Name,
"description": p.Description,
"confidence": p.Confidence,
"category": p.Category,
"tags": p.SuggestedTags,
})
}
writeJSON(w, map[string]interface{}{
"proposals": results,
"count": len(results),
})
}
func (s *Server) handleSkillDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/skills/detail/")
if strings.HasSuffix(path, "/improve") {
name := strings.TrimSuffix(path, "/improve")
s.handleSkillImprove(w, r, name)
return
}
if strings.HasSuffix(path, "/history") {
name := strings.TrimSuffix(path, "/history")
s.handleSkillHistoryGet(w, r, name)
return
}
writeError(w, "unknown skill action", http.StatusNotFound)
}
func (s *Server) handleSkillImprove(w http.ResponseWriter, r *http.Request, name string) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
skill, err := skills.Get(name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
var body struct {
Context string `json:"context,omitempty"`
Apply bool `json:"apply,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
improver, err := skills.NewSkillImprover()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
suggestions, err := improver.Analyze(skill, body.Context)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if body.Apply && len(suggestions) > 0 {
if err := improver.ApplyImprovement(name, suggestions[0]); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
updated, _ := skills.Get(name)
writeJSON(w, map[string]interface{}{
"applied": true,
"suggestion": suggestions[0],
"updated": updated,
})
return
}
writeJSON(w, map[string]interface{}{
"skill": skill.Name,
"suggestions": suggestions,
"count": len(suggestions),
})
}
func (s *Server) handleSkillHistoryGet(w http.ResponseWriter, r *http.Request, name string) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
improver, err := skills.NewSkillImprover()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
history, err := improver.GetHistory(name)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"skill": name,
"history": history,
"count": len(history),
})
}
func (s *Server) handleSkillProposals(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
proposals, err := skills.LoadProposals()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"proposals": proposals,
"count": len(proposals),
})
case "POST":
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
proposals, err := skills.LoadProposals()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var target *skills.AutoCreateProposal
for i := range proposals {
if proposals[i].Name == body.Name {
target = &proposals[i]
break
}
}
if target == nil {
writeError(w, "proposal not found", http.StatusNotFound)
return
}
skill, err := skills.CreateFromProposal(target)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
skills.DeleteProposal(body.Name)
writeJSON(w, map[string]interface{}{
"created": true,
"skill": skill,
})
default:
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
}
}

283
internal/api/pipeline.go Normal file
View File

@@ -0,0 +1,283 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
type Filter interface {
Name() string
Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error)
}
type FilterRequest struct {
UserMessage string `json:"user_message"`
Provider string `json:"provider"`
Model string `json:"model"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type FilterResponse struct {
Allowed bool `json:"allowed"`
Modified string `json:"modified,omitempty"`
Reason string `json:"reason,omitempty"`
TokenCount int `json:"token_count,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type Pipeline struct {
mu sync.RWMutex
filters map[string]Filter
enabled map[string]bool
stats map[string]*FilterStats
}
type FilterStats struct {
Invocations int64 `json:"invocations"`
Blocked int64 `json:"blocked"`
LastUsed time.Time `json:"last_used"`
}
func NewPipeline() *Pipeline {
p := &Pipeline{
filters: make(map[string]Filter),
enabled: make(map[string]bool),
stats: make(map[string]*FilterStats),
}
p.Register(&RateLimitFilter{})
p.Register(&TokenCountFilter{})
p.Register(&LoggingFilter{})
p.Register(&ToxicityFilter{})
for name := range p.filters {
p.enabled[name] = true
}
return p
}
func (p *Pipeline) Register(f Filter) {
p.mu.Lock()
defer p.mu.Unlock()
p.filters[f.Name()] = f
p.stats[f.Name()] = &FilterStats{}
}
func (p *Pipeline) Run(ctx context.Context, req *FilterRequest) (string, error) {
p.mu.RLock()
defer p.mu.RUnlock()
for name, filter := range p.filters {
if !p.enabled[name] {
continue
}
resp, err := filter.Process(ctx, req)
if p.stats[name] != nil {
p.stats[name].Invocations++
p.stats[name].LastUsed = time.Now()
}
if err != nil {
continue
}
if !resp.Allowed {
if p.stats[name] != nil {
p.stats[name].Blocked++
}
return "", fmt.Errorf("blocked by filter %s: %s", name, resp.Reason)
}
if resp.Modified != "" {
req.UserMessage = resp.Modified
}
}
return req.UserMessage, nil
}
func (p *Pipeline) Toggle(name string, enabled bool) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.filters[name]; !ok {
return fmt.Errorf("filter not found: %s", name)
}
p.enabled[name] = enabled
return nil
}
func (p *Pipeline) IsEnabled(name string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.enabled[name]
}
func (p *Pipeline) ListFilters() []map[string]interface{} {
p.mu.RLock()
defer p.mu.RUnlock()
var result []map[string]interface{}
for name, filter := range p.filters {
entry := map[string]interface{}{
"name": name,
"enabled": p.enabled[name],
}
if stats, ok := p.stats[name]; ok {
entry["invocations"] = stats.Invocations
entry["blocked"] = stats.Blocked
entry["last_used"] = stats.LastUsed
}
_ = filter
result = append(result, entry)
}
return result
}
// ── Built-in Filters ──
type RateLimitFilter struct {
mu sync.Mutex
counters map[string][]time.Time
}
func (f *RateLimitFilter) Name() string { return "rate_limit" }
func (f *RateLimitFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.counters == nil {
f.counters = make(map[string][]time.Time)
}
key := req.Provider
now := time.Now()
cutoff := now.Add(-time.Minute)
var recent []time.Time
for _, t := range f.counters[key] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
recent = append(recent, now)
f.counters[key] = recent
limit := 30
if len(recent) > limit {
return &FilterResponse{
Allowed: false,
Reason: fmt.Sprintf("rate limit exceeded: %d requests/minute (limit: %d)", len(recent), limit),
}, nil
}
return &FilterResponse{Allowed: true}, nil
}
type TokenCountFilter struct{}
func (f *TokenCountFilter) Name() string { return "token_count" }
func (f *TokenCountFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
count := len(req.UserMessage) / 4
if count > 50000 {
return &FilterResponse{
Allowed: true,
TokenCount: count,
Reason: fmt.Sprintf("large message: ~%d tokens", count),
}, nil
}
return &FilterResponse{Allowed: true, TokenCount: count}, nil
}
type LoggingFilter struct{}
func (f *LoggingFilter) Name() string { return "logging" }
func (f *LoggingFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
return &FilterResponse{Allowed: true, Metadata: map[string]string{
"provider": req.Provider,
"model": req.Model,
}}, nil
}
type ToxicityFilter struct{}
func (f *ToxicityFilter) Name() string { return "toxicity" }
func (f *ToxicityFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
return &FilterResponse{Allowed: true}, nil
}
// ── Pipeline HTTP handlers ──
func (s *Server) handlePipelineFilters(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
filters := s.pipeline.ListFilters()
if filters == nil {
filters = []map[string]interface{}{}
}
jsonResp(w, map[string]interface{}{"filters": filters})
return
}
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
func (s *Server) handlePipelineToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
name := ""
if parts := splitPath(r.URL.Path); len(parts) > 0 {
name = parts[len(parts)-1]
}
if strings.HasSuffix(r.URL.Path, "/toggle") {
name = strings.TrimSuffix(name, "/toggle")
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request")
return
}
if err := s.pipeline.Toggle(name, req.Enabled); err != nil {
jsonError(w, err.Error())
return
}
jsonResp(w, map[string]interface{}{"name": name, "enabled": req.Enabled})
}
func splitPath(p string) []string {
var parts []string
for _, s := range strings.Split(p, "/") {
if s != "" {
parts = append(parts, s)
}
}
return parts
}
func jsonResp(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonError(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -11,6 +12,11 @@ import (
"github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer" "github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lessons"
"github.com/muyue/muyue/internal/memory"
"github.com/muyue/muyue/internal/mcpserver"
"github.com/muyue/muyue/internal/plugins"
"github.com/muyue/muyue/internal/rag"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow" "github.com/muyue/muyue/internal/workflow"
) )
@@ -27,9 +33,16 @@ type Server struct {
shellAgentRegistry *agent.Registry shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine workflowEngine *workflow.Engine
pluginManager *plugins.Manager
hookRegistry *plugins.HookRegistry
browserTestStore *BrowserTestStore browserTestStore *BrowserTestStore
memoryStore *memory.Store
ragStore *rag.Store
pipeline *Pipeline
activeCrushAgents atomic.Int32 activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32 activeClaudeAgents atomic.Int32
mcpServer *mcpserver.MCPServer
agentTracker *AgentSessionTracker
} }
func NewServer(cfg *config.MuyueConfig) *Server { func NewServer(cfg *config.MuyueConfig) *Server {
@@ -76,6 +89,33 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON) s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry) s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
if cfg.Lessons.Enabled {
lessons.EnsureBuiltinLessons()
}
s.hookRegistry = plugins.NewHookRegistry()
s.pluginManager = plugins.NewManager(s.hookRegistry)
pluginPaths := cfg.Plugins.Paths
if len(pluginPaths) == 0 {
pluginPaths = plugins.DefaultPluginPaths()
}
discovered := plugins.DiscoverPlugins(pluginPaths)
for _, dp := range discovered {
if dp.Valid {
p, err := plugins.LoadExecutablePlugin(dp)
if err == nil {
s.pluginManager.Register(p)
}
}
}
s.pluginManager.EnableFromConfig(context.Background(), cfg.Plugins.Enabled, s.agentRegistry)
s.pipeline = NewPipeline()
s.agentTracker = NewAgentSessionTracker()
s.initStarship() s.initStarship()
s.routes() s.routes()
return s return s
@@ -108,6 +148,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme) s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider) s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate) s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/images/generate", s.handleImageGenerate)
s.mux.HandleFunc("/api/images/", s.handleServeImage) s.mux.HandleFunc("/api/images/", s.handleServeImage)
s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
@@ -157,6 +198,41 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions) s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole) s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS) s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
s.mux.HandleFunc("/api/skills/auto-create", s.handleSkillAutoCreate)
s.mux.HandleFunc("/api/skills/proposals", s.handleSkillProposals)
s.mux.HandleFunc("/api/skills/detail/", s.handleSkillDetail)
s.mux.HandleFunc("/api/plugins", s.handlePlugins)
s.mux.HandleFunc("/api/plugins/", s.handlePluginAction)
s.mux.HandleFunc("/api/lessons", s.handleLessons)
s.mux.HandleFunc("/api/lessons/match", s.handleLessonsMatch)
s.mux.HandleFunc("/api/mcp/discover", s.handleMCPDiscover)
s.mux.HandleFunc("/api/browser/navigate", s.handleBrowserNavigate)
s.mux.HandleFunc("/api/browser/screenshot", s.handleBrowserScreenshot)
s.mux.HandleFunc("/api/browser/action", s.handleBrowserAction)
s.mux.HandleFunc("/api/rag/index", s.handleRAGIndex)
s.mux.HandleFunc("/api/rag/search", s.handleRAGSearch)
s.mux.HandleFunc("/api/rag/status", s.handleRAGStatus)
s.mux.HandleFunc("/api/rag/documents", s.handleRAGDocuments)
s.mux.HandleFunc("/api/rag/index/", s.handleRAGDelete)
s.mux.HandleFunc("/api/pipeline/filters", s.handlePipelineFilters)
s.mux.HandleFunc("/api/pipeline/filters/", s.handlePipelineToggle)
s.mux.HandleFunc("/api/memory", s.handleMemoryList)
s.mux.HandleFunc("/api/memory/create", s.handleMemoryCreate)
s.mux.HandleFunc("/api/memory/", s.handleMemoryOperation)
s.mux.HandleFunc("/api/memory/search", s.handleMemorySearch)
s.mux.HandleFunc("/api/memory/recall", s.handleMemoryRecall)
s.mux.HandleFunc("/api/memory/context", s.handleMemoryContext)
s.mux.HandleFunc("/api/files/content", s.handleFileContent)
s.mux.HandleFunc("/api/mcp-server/status", s.handleMuyueMCPServerStatus)
s.mux.HandleFunc("/api/mcp-server/start", s.handleMuyueMCPServerStart)
s.mux.HandleFunc("/api/mcp-server/stop", s.handleMuyueMCPServerStop)
s.mux.HandleFunc("/api/agent-sessions", s.handleAgentSessionsList)
s.mux.HandleFunc("/api/agent-sessions/", s.handleAgentSessionOutput)
s.mux.HandleFunc("/api/workspaces", s.handleWorkspaceList)
s.mux.HandleFunc("/api/workspace", s.handleWorkspaceSave)
s.mux.HandleFunc("/api/workspace/", s.handleWorkspaceGet)
} }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -227,3 +303,16 @@ func (s *Server) initStarship() {
} }
ApplyStarshipTheme(s.config.Terminal.PromptTheme) ApplyStarshipTheme(s.config.Terminal.PromptTheme)
} }
func (s *Server) buildMemoryContext(query string) string {
store, err := s.ensureMemoryStore()
if err != nil {
return ""
}
injector := memory.NewInjector(store)
ctx, err := injector.BuildContextBlock(query)
if err != nil {
return ""
}
return ctx
}

View File

@@ -51,6 +51,16 @@ type SSHConnection struct {
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"` KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
} }
type PluginsConfig struct {
Enabled []string `yaml:"enabled" json:"enabled"`
Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
}
type LessonsConfig struct {
Dirs []string `yaml:"dirs,omitempty" json:"dirs,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
}
type MuyueConfig struct { type MuyueConfig struct {
Version string `yaml:"version" json:"version"` Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"` Profile Profile `yaml:"profile" json:"profile"`
@@ -71,6 +81,8 @@ type MuyueConfig struct {
FontFamily string `yaml:"font_family" json:"font_family"` FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"` Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"` } `yaml:"terminal" json:"terminal"`
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
Lessons LessonsConfig `yaml:"lessons" json:"lessons"`
} }
type TerminalTheme struct { type TerminalTheme struct {
@@ -322,5 +334,11 @@ func Default() *MuyueConfig {
cfg.Terminal.PromptTheme = "zerotwo" cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14 cfg.Terminal.FontSize = 14
cfg.Plugins.Enabled = []string{}
cfg.Plugins.Paths = []string{}
cfg.Lessons.Enabled = true
cfg.Lessons.Dirs = []string{}
return cfg return cfg
} }

513
internal/lessons/lesson.go Normal file
View 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]
}
}
}
}

369
internal/mcp/discover.go Normal file
View File

@@ -0,0 +1,369 @@
package mcp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
type DiscoveredMCPServer struct {
Name string `json:"name"`
Command string `json:"command"`
Source string `json:"source"`
Args []string `json:"args,omitempty"`
Installed bool `json:"installed"`
Running bool `json:"running"`
Category string `json:"category,omitempty"`
}
type DiscoveryResult struct {
Servers []DiscoveredMCPServer `json:"servers"`
ScanPaths []string `json:"scan_paths"`
TotalFound int `json:"total_found"`
NewServers int `json:"new_servers"`
}
type ToolDiscovery struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
type ServerCapabilities struct {
Name string `json:"name"`
Tools []ToolDiscovery `json:"tools"`
Version string `json:"version,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
var (
capCache map[string]*ServerCapabilities
capCacheMu sync.RWMutex
)
func init() {
capCache = make(map[string]*ServerCapabilities)
}
func DiscoverSystemServers() *DiscoveryResult {
result := &DiscoveryResult{}
knownNames := make(map[string]bool)
for _, s := range knownMCPServers {
knownNames[s.Name] = true
}
reg, _ := LoadRegistry()
if reg != nil {
for _, s := range reg.Servers {
knownNames[s.Name] = true
}
}
var servers []DiscoveredMCPServer
npmServers := discoverNpmGlobalServers(knownNames)
servers = append(servers, npmServers...)
pipServers := discoverPipServers(knownNames)
servers = append(servers, pipServers...)
pathServers := discoverPathServers(knownNames)
servers = append(servers, pathServers...)
result.Servers = servers
result.TotalFound = len(servers)
result.NewServers = countNew(servers, knownNames)
paths := []string{}
if path := os.Getenv("PATH"); path != "" {
paths = strings.Split(path, ":")
}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".npm-global", "bin"),
)
}
result.ScanPaths = paths
return result
}
func discoverNpmGlobalServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
npx, err := exec.LookPath("npx")
if err != nil {
return servers
}
patterns := []struct {
pkg string
name string
cat string
}{
{"@anthropic/mcp-server-fetch", "anthropic-fetch", "web"},
{"@anthropic/mcp-server-sqlite", "anthropic-sqlite", "database"},
{"@anthropic/mcp-server-brave-search", "anthropic-brave-search", "web"},
{"@anthropic/mcp-server-filesystem", "anthropic-filesystem", "core"},
{"@anthropic/mcp-server-github", "anthropic-github", "vcs"},
{"@anthropic/mcp-server-memory", "anthropic-memory", "core"},
{"@anthropic/mcp-server-puppeteer", "anthropic-puppeteer", "web"},
{"@anthropic/mcp-server-sequential-thinking", "anthropic-thinking", "ai"},
}
for _, p := range patterns {
if known[p.name] {
continue
}
servers = append(servers, DiscoveredMCPServer{
Name: p.name,
Command: npx,
Source: "npm-global",
Args: []string{"-y", p.pkg},
Installed: true,
Category: p.cat,
})
}
return servers
}
func discoverPipServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
pipCmds := []string{"pip", "pip3", "uv"}
for _, pip := range pipCmds {
if _, err := exec.LookPath(pip); err != nil {
continue
}
cmd := exec.Command(pip, "list", "--format=json")
output, err := cmd.CombinedOutput()
if err != nil {
continue
}
var packages []struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal(output, &packages); err != nil {
continue
}
for _, pkg := range packages {
nameLower := strings.ToLower(pkg.Name)
if !strings.Contains(nameLower, "mcp") {
continue
}
serverName := strings.ReplaceAll(nameLower, "_", "-")
if strings.HasPrefix(serverName, "mcp-") {
serverName = serverName[4:]
}
if known[serverName] {
continue
}
binName := strings.ReplaceAll(pkg.Name, "-", "_")
if _, err := exec.LookPath(binName); err != nil {
binName = pkg.Name
if _, err := exec.LookPath(binName); err != nil {
continue
}
}
servers = append(servers, DiscoveredMCPServer{
Name: serverName,
Command: binName,
Source: "pip",
Installed: true,
Category: "python",
})
}
break
}
return servers
}
func discoverPathServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
home, _ := os.UserHomeDir()
searchDirs := []string{}
if home != "" {
searchDirs = append(searchDirs,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".muyue", "mcp-servers"),
)
}
for _, dir := range searchDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.Contains(strings.ToLower(name), "mcp") {
continue
}
serverName := strings.ToLower(name)
serverName = strings.TrimPrefix(serverName, "mcp-")
serverName = strings.TrimPrefix(serverName, "mcp_")
serverName = strings.TrimSuffix(serverName, ".sh")
if known[serverName] {
continue
}
fullPath := filepath.Join(dir, name)
if info, err := os.Stat(fullPath); err == nil && info.Mode()&0111 != 0 {
servers = append(servers, DiscoveredMCPServer{
Name: serverName,
Command: fullPath,
Source: "path",
Installed: true,
Category: "local",
})
}
}
}
return servers
}
func DiscoverServerTools(serverName string) (*ServerCapabilities, error) {
capCacheMu.RLock()
if caps, ok := capCache[serverName]; ok {
capCacheMu.RUnlock()
return caps, nil
}
capCacheMu.RUnlock()
server, err := findServerConfig(serverName)
if err != nil {
return nil, err
}
script := buildListToolsScript(server)
if script == "" {
return &ServerCapabilities{
Name: serverName,
Tools: []ToolDiscovery{},
}, nil
}
cmd := exec.Command(server.Command, append(server.Args, "--list-tools")...)
output, err := cmd.CombinedOutput()
_ = script
if err != nil {
return discoverToolsFallback(serverName, server)
}
var caps ServerCapabilities
if jsonErr := json.Unmarshal(output, &caps); jsonErr != nil {
caps = ServerCapabilities{
Name: serverName,
Tools: []ToolDiscovery{
{
Name: serverName,
Description: "MCP server: " + serverName,
},
},
}
}
capCacheMu.Lock()
capCache[serverName] = &caps
capCacheMu.Unlock()
return &caps, nil
}
func discoverToolsFallback(name string, server *RegistryServer) (*ServerCapabilities, error) {
caps := &ServerCapabilities{
Name: name,
Tools: []ToolDiscovery{
{
Name: name,
Description: server.Description,
},
},
}
capCacheMu.Lock()
capCache[name] = caps
capCacheMu.Unlock()
return caps, nil
}
func findServerConfig(name string) (*RegistryServer, error) {
reg, err := LoadRegistry()
if err != nil {
return nil, err
}
for i := range reg.Servers {
if reg.Servers[i].Name == name {
return &reg.Servers[i], nil
}
}
for _, s := range knownMCPServers {
if s.Name == name {
return &RegistryServer{
Name: s.Name,
Command: s.Command,
Args: s.Args,
Env: s.Env,
}, nil
}
}
return nil, fmt.Errorf("server %q not found", name)
}
func buildListToolsScript(server *RegistryServer) string {
return ""
}
func InvalidateCapabilitiesCache() {
capCacheMu.Lock()
defer capCacheMu.Unlock()
capCache = make(map[string]*ServerCapabilities)
}
func GetCachedCapabilities(name string) *ServerCapabilities {
capCacheMu.RLock()
defer capCacheMu.RUnlock()
return capCache[name]
}
func countNew(servers []DiscoveredMCPServer, known map[string]bool) int {
count := 0
for _, s := range servers {
if !known[s.Name] {
count++
}
}
return count
}

View File

@@ -0,0 +1,556 @@
package mcpserver
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
type ToolCall struct {
Name string `json:"name"`
Args json.RawMessage `json:"arguments"`
}
type ToolResult struct {
Content []ContentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
var tools = []Tool{
{
Name: "terminal_exec",
Description: "Execute a command in the terminal and return the output",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "The command to execute"},
"cwd": map[string]interface{}{"type": "string", "description": "Working directory (optional)"},
"timeout": map[string]interface{}{"type": "integer", "description": "Timeout in seconds (default 30)"},
},
"required": []string{"command"},
},
},
{
Name: "file_read",
Description: "Read the contents of a file",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
"offset": map[string]interface{}{"type": "integer", "description": "Line offset to start reading from (0-based)"},
"limit": map[string]interface{}{"type": "integer", "description": "Maximum number of lines to read"},
},
"required": []string{"path"},
},
},
{
Name: "file_write",
Description: "Write content to a file, creating it if needed",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
},
"required": []string{"path", "content"},
},
},
{
Name: "search",
Description: "Search for files by name pattern",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
"pattern": map[string]interface{}{"type": "string", "description": "Glob pattern to match filenames"},
},
"required": []string{"path", "pattern"},
},
},
{
Name: "grep",
Description: "Search file contents for a pattern",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
"pattern": map[string]interface{}{"type": "string", "description": "Text or regex pattern to search for"},
},
"required": []string{"path", "pattern"},
},
},
{
Name: "system_info",
Description: "Get system information (OS, CPU, memory, disk)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
}
type MCPServer struct {
port int
server *http.Server
mu sync.Mutex
sseClients map[string]chan SSEEvent
sseClientsMu sync.Mutex
}
type SSEEvent struct {
Event string
Data string
}
func New(port int) *MCPServer {
return &MCPServer{
port: port,
sseClients: make(map[string]chan SSEEvent),
}
}
func (m *MCPServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/", m.handleSSE)
mux.HandleFunc("/message", m.handleHTTPMessage)
mux.HandleFunc("/mcp", m.handleStreamableHTTP)
m.server = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", m.port),
Handler: mux,
}
go func() {
if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("[MCP Server] Error: %v\n", err)
}
}()
return nil
}
func (m *MCPServer) Stop() error {
if m.server != nil {
return m.server.Close()
}
return nil
}
func (m *MCPServer) Port() int {
return m.port
}
func (m *MCPServer) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
ch := make(chan SSEEvent, 32)
m.sseClientsMu.Lock()
m.sseClients[clientID] = ch
m.sseClientsMu.Unlock()
defer func() {
m.sseClientsMu.Lock()
delete(m.sseClients, clientID)
m.sseClientsMu.Unlock()
close(ch)
}()
fmt.Fprintf(w, "event: endpoint\ndata: /message?clientId=%s\n\n", clientID)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
for {
select {
case evt, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Event, evt.Data)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
case <-r.Context().Done():
return
}
}
}
func (m *MCPServer) handleHTTPMessage(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
body, err := io.ReadAll(r.Body)
if err != nil {
m.writeRPCError(w, nil, -32700, "Parse error")
return
}
resp := m.handleJSONRPC(body)
json.NewEncoder(w).Encode(resp)
}
func (m *MCPServer) handleStreamableHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
return
}
if r.Method == "GET" {
m.handleSSE(w, r)
return
}
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
body, err := io.ReadAll(r.Body)
if err != nil {
m.writeRPCError(w, nil, -32700, "Parse error")
return
}
resp := m.handleJSONRPC(body)
json.NewEncoder(w).Encode(resp)
}
func (m *MCPServer) handleJSONRPC(body []byte) JSONRPCResponse {
var req JSONRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
return JSONRPCResponse{
JSONRPC: "2.0",
Error: &RPCError{Code: -32700, Message: "Parse error"},
}
}
switch req.Method {
case "initialize":
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "muyue",
"version": "0.9.0",
},
},
}
case "notifications/initialized":
return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID}
case "tools/list":
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"tools": tools,
},
}
case "tools/call":
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &RPCError{Code: -32602, Message: "Invalid params"},
}
}
result := m.executeTool(params.Name, params.Arguments)
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
default:
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &RPCError{Code: -32601, Message: fmt.Sprintf("Method not found: %s", req.Method)},
}
}
}
func (m *MCPServer) executeTool(name string, args json.RawMessage) ToolResult {
switch name {
case "terminal_exec":
return m.toolTerminalExec(args)
case "file_read":
return m.toolFileRead(args)
case "file_write":
return m.toolFileWrite(args)
case "search":
return m.toolSearch(args)
case "grep":
return m.toolGrep(args)
case "system_info":
return m.toolSystemInfo()
default:
return ToolResult{
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Unknown tool: %s", name)}},
IsError: true,
}
}
}
func (m *MCPServer) toolTerminalExec(args json.RawMessage) ToolResult {
var params struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
Timeout int `json:"timeout"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
timeout := params.Timeout
if timeout <= 0 {
timeout = 30
}
ctx := exec.Command("sh", "-c", params.Command)
if params.Cwd != "" {
ctx.Dir = params.Cwd
}
var stdout, stderr strings.Builder
ctx.Stdout = &stdout
ctx.Stderr = &stderr
done := make(chan error, 1)
go func() { done <- ctx.Run() }()
select {
case err := <-done:
output := stdout.String()
if errMsg := stderr.String(); errMsg != "" {
output += "\n" + errMsg
}
if err != nil {
output += fmt.Sprintf("\nExit error: %v", err)
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: output}}}
case <-time.After(time.Duration(timeout) * time.Second):
ctx.Process.Kill()
return ToolResult{
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Command timed out after %ds\n%s%s", timeout, stdout.String(), stderr.String())}},
IsError: true,
}
}
}
func (m *MCPServer) toolFileRead(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(params.Path, "~", home)
data, err := os.ReadFile(path)
if err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error reading file: %v", err)}}, IsError: true}
}
lines := strings.Split(string(data), "\n")
start := params.Offset
if start < 0 {
start = 0
}
end := len(lines)
if params.Limit > 0 && start+params.Limit < end {
end = start + params.Limit
}
if start > len(lines) {
start = len(lines)
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines[start:end], "\n")}}}
}
func (m *MCPServer) toolFileWrite(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(params.Path, "~", home)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error creating directory: %v", err)}}, IsError: true}
}
if err := os.WriteFile(path, []byte(params.Content), 0644); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error writing file: %v", err)}}, IsError: true}
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Successfully wrote %d bytes to %s", len(params.Content), path)}}}
}
func (m *MCPServer) toolSearch(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
basePath := strings.ReplaceAll(params.Path, "~", home)
cmd := exec.Command("find", basePath, "-name", params.Pattern, "-type", "f", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*")
output, err := cmd.CombinedOutput()
if err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: string(output)}}}
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 100 {
lines = lines[:100]
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
}
func (m *MCPServer) toolGrep(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
basePath := strings.ReplaceAll(params.Path, "~", home)
cmd := exec.Command("grep", "-rn", "--include=*", params.Pattern, basePath)
output, _ := cmd.CombinedOutput()
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 50 {
lines = lines[:50]
lines = append(lines, fmt.Sprintf("... (%d more results truncated)", len(strings.Split(string(output), "\n"))-50))
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
}
func (m *MCPServer) toolSystemInfo() ToolResult {
var info strings.Builder
info.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
info.WriteString(fmt.Sprintf("CPUs: %d\n", runtime.NumCPU()))
if out, err := exec.Command("uname", "-a").Output(); err == nil {
info.WriteString(fmt.Sprintf("Kernel: %s", string(out)))
}
if out, err := exec.Command("free", "-h").Output(); err == nil {
info.WriteString(fmt.Sprintf("Memory:\n%s", string(out)))
}
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
info.WriteString(fmt.Sprintf("Disk:\n%s", string(out)))
}
if out, err := exec.Command("uptime").Output(); err == nil {
info.WriteString(fmt.Sprintf("Uptime: %s", string(out)))
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: info.String()}}}
}
func (m *MCPServer) writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Error: &RPCError{Code: code, Message: msg},
}
json.NewEncoder(w).Encode(resp)
}

140
internal/memory/inject.go Normal file
View File

@@ -0,0 +1,140 @@
package memory
import (
"fmt"
"strings"
"time"
)
type MemoryInjector struct {
store *Store
}
func NewInjector(store *Store) *MemoryInjector {
return &MemoryInjector{store: store}
}
func (mi *MemoryInjector) BuildContextBlock(query string) (string, error) {
var contextParts []string
preferences, err := mi.store.RecallPreferences()
if err == nil && len(preferences) > 0 {
var prefLines []string
for _, p := range preferences {
prefLines = append(prefLines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
}
contextParts = append(contextParts,
"[User Preferences]\n"+strings.Join(prefLines, "\n"))
}
facts, err := mi.store.RecallFacts()
if err == nil && len(facts) > 0 {
var factLines []string
for _, f := range facts {
factLines = append(factLines, fmt.Sprintf("- %s: %s", f.Key, f.Content))
}
contextParts = append(contextParts,
"[Known Facts]\n"+strings.Join(factLines, "\n"))
}
if query != "" {
relevant, err := mi.store.Recall(query, 5)
if err == nil && len(relevant) > 0 {
var relLines []string
for _, r := range relevant {
relLines = append(relLines, fmt.Sprintf("- [%s] %s: %s", r.Type, r.Key, truncate(r.Content, 150)))
}
contextParts = append(contextParts,
"[Relevant Memories]\n"+strings.Join(relLines, "\n"))
}
}
recentCutoff := time.Now().Add(-24 * time.Hour)
recent, err := mi.store.RecallRecent(recentCutoff, 5)
if err == nil && len(recent) > 0 {
var recentLines []string
for _, r := range recent {
recentLines = append(recentLines, fmt.Sprintf("- [%s] %s", r.Type, truncate(r.Content, 100)))
}
contextParts = append(contextParts,
"[Recent Context]\n"+strings.Join(recentLines, "\n"))
}
if len(contextParts) == 0 {
return "", nil
}
return fmt.Sprintf("<memory-context>\n[System note: NOT new user input — recalled context]\n%s\n</memory-context>",
strings.Join(contextParts, "\n\n")), nil
}
func (mi *MemoryInjector) BuildSystemPromptBlock() (string, error) {
preferences, err := mi.store.RecallPreferences()
if err != nil || len(preferences) == 0 {
return "", nil
}
var lines []string
lines = append(lines, "Known user preferences:")
for _, p := range preferences {
lines = append(lines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
}
return strings.Join(lines, "\n"), nil
}
func (mi *MemoryInjector) ExtractAndStore(userMessage, assistantMessage string) error {
pref := extractPreference(userMessage)
if pref != "" {
if err := mi.store.StorePreference("detected", pref); err != nil {
return fmt.Errorf("store preference: %w", err)
}
}
if assistantMessage != "" {
ctx := extractContext(assistantMessage)
if ctx != "" {
if err := mi.store.StoreContext("conversation", ctx); err != nil {
return fmt.Errorf("store context: %w", err)
}
}
}
return nil
}
func extractPreference(message string) string {
indicators := []string{
"i prefer", "i like", "i always", "i never", "my favorite",
"i use", "je préfère", "j'aime", "toujours", "jamais",
}
lower := strings.ToLower(message)
for _, ind := range indicators {
if strings.Contains(lower, ind) {
idx := strings.Index(lower, ind)
end := idx + len(ind) + 100
if end > len(message) {
end = len(message)
}
return truncate(message[idx:end], 200)
}
}
return ""
}
func extractContext(message string) string {
if len(message) < 50 {
return ""
}
if len(message) > 500 {
return truncate(message, 500)
}
return message
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

215
internal/memory/recall.go Normal file
View File

@@ -0,0 +1,215 @@
package memory
import (
"database/sql"
"strings"
"time"
)
type SearchResult struct {
Memory
Score float64 `json:"score"`
}
func (s *Store) Search(query string, limit int) ([]SearchResult, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
normalizedQuery := normalizeQuery(query)
rows, err := s.db.Query(`
SELECT m.id, m.type, m.key, m.content, m.tags, m.source, m.confidence,
m.access_count, m.created_at, m.updated_at,
bm25(memories_fts) as score
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?
ORDER BY score
LIMIT ?
`, normalizedQuery, limit)
if err != nil {
return fallbackSearch(s.db, query, limit)
}
defer rows.Close()
return scanSearchResults(rows)
}
func (s *Store) Recall(query string, limit int) ([]Memory, error) {
results, err := s.Search(query, limit)
if err != nil {
return nil, err
}
memories := make([]Memory, len(results))
for i, r := range results {
memories[i] = r.Memory
}
return memories, nil
}
func (s *Store) RecallByType(memType MemoryType, limit int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE type = ?
ORDER BY access_count DESC, updated_at DESC
LIMIT ?
`, string(memType), limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) RecallRecent(since time.Time, limit int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE updated_at >= ?
ORDER BY updated_at DESC
LIMIT ?
`, since, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) RecallPreferences() ([]Memory, error) {
return s.RecallByType(TypePreference, 50)
}
func (s *Store) RecallFacts() ([]Memory, error) {
return s.RecallByType(TypeFact, 50)
}
func (s *Store) StorePreference(key, content string) error {
return s.Store(&Memory{
Type: TypePreference,
Key: key,
Content: content,
Source: "user",
Confidence: 0.9,
})
}
func (s *Store) StoreContext(key, content string) error {
return s.Store(&Memory{
Type: TypeContext,
Key: key,
Content: content,
Source: "conversation",
Confidence: 0.7,
})
}
func (s *Store) StoreSummary(sessionID, summary string) error {
return s.Store(&Memory{
Type: TypeSummary,
Key: "session:" + sessionID,
Content: summary,
Source: "auto",
Confidence: 0.8,
})
}
func (s *Store) StoreFact(key, content string) error {
return s.Store(&Memory{
Type: TypeFact,
Key: key,
Content: content,
Source: "auto",
Confidence: 0.85,
})
}
func normalizeQuery(query string) string {
words := strings.Fields(strings.ToLower(query))
var escaped []string
for _, w := range words {
if len(w) > 0 {
escaped = append(escaped, w+"*")
}
}
return strings.Join(escaped, " OR ")
}
func fallbackSearch(db *sql.DB, query string, limit int) ([]SearchResult, error) {
likePattern := "%" + strings.ToLower(query) + "%"
rows, err := db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories
WHERE LOWER(key) LIKE ? OR LOWER(content) LIKE ? OR LOWER(tags) LIKE ?
ORDER BY updated_at DESC
LIMIT ?
`, likePattern, likePattern, likePattern, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var m Memory
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return results, err
}
score := computeFallbackScore(m, query)
results = append(results, SearchResult{Memory: m, Score: score})
}
return results, nil
}
func computeFallbackScore(m Memory, query string) float64 {
score := m.Confidence * 0.5
lower := strings.ToLower(query)
if strings.Contains(strings.ToLower(m.Key), lower) {
score += 0.3
}
if strings.Contains(strings.ToLower(m.Content), lower) {
score += 0.2
}
score += float64(m.AccessCount) * 0.01
return score
}
func scanSearchResults(rows *sql.Rows) ([]SearchResult, error) {
var results []SearchResult
for rows.Next() {
var m Memory
var score float64
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source,
&m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt, &score)
if err != nil {
return results, err
}
results = append(results, SearchResult{Memory: m, Score: score})
}
return results, nil
}

276
internal/memory/store.go Normal file
View File

@@ -0,0 +1,276 @@
package memory
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
type MemoryType string
const (
TypePreference MemoryType = "preference"
TypeContext MemoryType = "context"
TypeSummary MemoryType = "summary"
TypeFact MemoryType = "fact"
TypePattern MemoryType = "pattern"
)
type Memory struct {
ID string `json:"id"`
Type MemoryType `json:"type"`
Key string `json:"key"`
Content string `json:"content"`
Tags string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
AccessCount int `json:"access_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct {
db *sql.DB
path string
mu sync.RWMutex
}
func NewStore() (*Store, error) {
dbPath, err := dbPath()
if err != nil {
return nil, fmt.Errorf("get db path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("create memory dir: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open memory db: %w", err)
}
db.SetMaxOpenConns(1)
s := &Store{db: db, path: dbPath}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Store(m *Memory) error {
s.mu.Lock()
defer s.mu.Unlock()
if m.ID == "" {
m.ID = generateID()
}
now := time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
m.UpdatedAt = now
_, err := s.db.Exec(`
INSERT INTO memories (id, type, key, content, tags, source, confidence, access_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
key = excluded.key,
content = excluded.content,
tags = excluded.tags,
source = excluded.source,
confidence = excluded.confidence,
access_count = excluded.access_count,
updated_at = excluded.updated_at
`, m.ID, string(m.Type), m.Key, m.Content, m.Tags, m.Source, m.Confidence, m.AccessCount, m.CreatedAt, m.UpdatedAt)
return err
}
func (s *Store) Get(id string) (*Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
m := &Memory{}
err := s.db.QueryRow(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE id = ?
`, id).Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err == nil {
s.incrementAccess(id)
}
return m, err
}
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM memories WHERE id = ?`, id)
return err
}
func (s *Store) List(memType MemoryType, limit, offset int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
var rows *sql.Rows
var err error
if memType != "" {
rows, err = s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE type = ?
ORDER BY updated_at DESC LIMIT ? OFFSET ?
`, string(memType), limit, offset)
} else {
rows, err = s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories ORDER BY updated_at DESC LIMIT ? OFFSET ?
`, limit, offset)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) Count() (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM memories`).Scan(&count)
return count, err
}
func (s *Store) incrementAccess(id string) {
go func() {
s.db.Exec(`UPDATE memories SET access_count = access_count + 1 WHERE id = ?`, id)
}()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT DEFAULT '',
source TEXT DEFAULT '',
confidence REAL DEFAULT 0.5,
access_count INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
key, content, tags,
content=memories,
content_rowid=rowid
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
END
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END
`)
return err
}
func scanMemories(rows *sql.Rows) ([]Memory, error) {
var memories []Memory
for rows.Next() {
var m Memory
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return memories, err
}
memories = append(memories, m)
}
return memories, nil
}
func dbPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".muyue", "memory", "memories.db"), nil
}
func generateID() string {
return fmt.Sprintf("mem_%d", time.Now().UnixNano())
}

View File

@@ -0,0 +1,189 @@
package memory
import (
"database/sql"
"os"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
func testDBPath(t *testing.T) string {
dir := t.TempDir()
return filepath.Join(dir, "test_memory.db")
}
func newTestStore(t *testing.T) *Store {
t.Helper()
dbPath := testDBPath(t)
db, err := openDB(dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
s := &Store{db: db, path: dbPath}
if err := s.migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
return s
}
func openDB(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return sql.Open("sqlite", path)
}
func TestStoreAndRetrieve(t *testing.T) {
s := newTestStore(t)
m := &Memory{
Type: TypeFact,
Key: "golang_version",
Content: "User uses Go 1.24",
Source: "conversation",
}
if err := s.Store(m); err != nil {
t.Fatalf("store: %v", err)
}
if m.ID == "" {
t.Fatal("expected ID to be set")
}
got, err := s.Get(m.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Key != m.Key {
t.Errorf("expected key %s, got %s", m.Key, got.Key)
}
if got.Content != m.Content {
t.Errorf("expected content %s, got %s", m.Content, got.Content)
}
}
func TestDelete(t *testing.T) {
s := newTestStore(t)
m := &Memory{
Type: TypePreference,
Key: "editor",
Content: "vim",
}
s.Store(m)
if err := s.Delete(m.ID); err != nil {
t.Fatalf("delete: %v", err)
}
_, err := s.Get(m.ID)
if err == nil {
t.Error("expected error after delete")
}
}
func TestList(t *testing.T) {
s := newTestStore(t)
for i := 0; i < 5; i++ {
s.Store(&Memory{
Type: TypeFact,
Key: "fact_" + string(rune('a'+i)),
Content: "content",
})
}
memories, err := s.List(TypeFact, 10, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(memories) != 5 {
t.Errorf("expected 5 memories, got %d", len(memories))
}
}
func TestSearch(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "language", Content: "Go is the primary language"})
s.Store(&Memory{Type: TypeFact, Key: "editor", Content: "VSCode is the editor"})
s.Store(&Memory{Type: TypeContext, Key: "project", Content: "Muyue is a Go project"})
results, err := s.Search("Go language", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) == 0 {
t.Error("expected search results")
}
}
func TestRecallPreferences(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypePreference, Key: "theme", Content: "dark"})
s.Store(&Memory{Type: TypePreference, Key: "lang", Content: "fr"})
s.Store(&Memory{Type: TypeFact, Key: "tool", Content: "go"})
prefs, err := s.RecallPreferences()
if err != nil {
t.Fatalf("recall preferences: %v", err)
}
if len(prefs) != 2 {
t.Errorf("expected 2 preferences, got %d", len(prefs))
}
}
func TestRecallRecent(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "old", Content: "old fact"})
recent, err := s.RecallRecent(time.Now().Add(-1*time.Hour), 10)
if err != nil {
t.Fatalf("recall recent: %v", err)
}
if len(recent) == 0 {
t.Error("expected recent memories")
}
}
func TestStorePreference(t *testing.T) {
s := newTestStore(t)
if err := s.StorePreference("editor", "vim"); err != nil {
t.Fatalf("store preference: %v", err)
}
prefs, _ := s.RecallPreferences()
if len(prefs) != 1 {
t.Errorf("expected 1 preference, got %d", len(prefs))
}
}
func TestCount(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "a", Content: "a"})
s.Store(&Memory{Type: TypeFact, Key: "b", Content: "b"})
count, err := s.Count()
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
}

94
internal/plugins/hooks.go Normal file
View File

@@ -0,0 +1,94 @@
package plugins
import (
"context"
"encoding/json"
"sync"
"github.com/muyue/muyue/internal/agent"
)
type HookType string
const (
BeforeToolCall HookType = "before_tool_call"
AfterToolCall HookType = "after_tool_call"
OnConversationStart HookType = "on_conversation_start"
OnToolError HookType = "on_tool_error"
)
type HookFunc func(ctx context.Context, payload HookPayload) error
type HookPayload struct {
ToolName string `json:"tool_name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Response *agent.ToolResponse `json:"response,omitempty"`
Error string `json:"error,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type Hook struct {
Type HookType
Plugin string
Priority int
Fn HookFunc
}
type HookRegistry struct {
mu sync.RWMutex
hooks map[HookType][]Hook
}
func NewHookRegistry() *HookRegistry {
return &HookRegistry{
hooks: make(map[HookType][]Hook),
}
}
func (hr *HookRegistry) Register(hookType HookType, pluginName string, priority int, fn HookFunc) {
hr.mu.Lock()
defer hr.mu.Unlock()
h := Hook{
Type: hookType,
Plugin: pluginName,
Priority: priority,
Fn: fn,
}
hr.hooks[hookType] = append(hr.hooks[hookType], h)
for i := len(hr.hooks[hookType]) - 1; i > 0; i-- {
if hr.hooks[hookType][i].Priority < hr.hooks[hookType][i-1].Priority {
hr.hooks[hookType][i], hr.hooks[hookType][i-1] = hr.hooks[hookType][i-1], hr.hooks[hookType][i]
}
}
}
func (hr *HookRegistry) Fire(ctx context.Context, hookType HookType, payload HookPayload) error {
hr.mu.RLock()
hooks := make([]Hook, len(hr.hooks[hookType]))
copy(hooks, hr.hooks[hookType])
hr.mu.RUnlock()
for _, h := range hooks {
if err := h.Fn(ctx, payload); err != nil {
return err
}
}
return nil
}
func (hr *HookRegistry) RemoveByPlugin(pluginName string) {
hr.mu.Lock()
defer hr.mu.Unlock()
for hookType := range hr.hooks {
filtered := make([]Hook, 0, len(hr.hooks[hookType]))
for _, h := range hr.hooks[hookType] {
if h.Plugin != pluginName {
filtered = append(filtered, h)
}
}
hr.hooks[hookType] = filtered
}
}

334
internal/plugins/loader.go Normal file
View File

@@ -0,0 +1,334 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"github.com/muyue/muyue/internal/agent"
)
func DiscoverPlugins(paths []string) []*DiscoveredPlugin {
var plugins []*DiscoveredPlugin
for _, p := range paths {
expanded := expandPath(p)
info, err := os.Stat(expanded)
if err != nil {
continue
}
if info.IsDir() {
entries, err := os.ReadDir(expanded)
if err != nil {
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(expanded, entry.Name())
if dp := scanPluginDir(pluginDir); dp != nil {
plugins = append(plugins, dp)
}
}
}
}
return plugins
}
type DiscoveredPlugin struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}
func scanPluginDir(dir string) *DiscoveredPlugin {
name := filepath.Base(dir)
dp := &DiscoveredPlugin{
Name: name,
Path: dir,
}
initPy := filepath.Join(dir, "__init__.py")
mainGo := filepath.Join(dir, "main.go")
manifest := filepath.Join(dir, "plugin.json")
if _, err := os.Stat(manifest); err == nil {
dp.Type = "manifest"
dp.Valid = true
return dp
}
if _, err := os.Stat(mainGo); err == nil {
dp.Type = "go"
dp.Valid = true
return dp
}
if _, err := os.Stat(initPy); err == nil {
dp.Type = "python"
dp.Valid = true
return dp
}
executables := []string{name, name + ".sh"}
for _, exe := range executables {
fullPath := filepath.Join(dir, exe)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
dp.Type = "executable"
dp.Valid = true
dp.Path = fullPath
return dp
}
}
return dp
}
type PluginManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Tools []ManifestTool `json:"tools,omitempty"`
Hooks []ManifestHook `json:"hooks,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
type ManifestTool struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
}
type ManifestHook struct {
Type string `json:"type"`
}
func LoadManifest(path string) (*PluginManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
var manifest PluginManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
return &manifest, nil
}
func LoadExecutablePlugin(discovered *DiscoveredPlugin) (*Plugin, error) {
if !discovered.Valid {
return nil, fmt.Errorf("invalid plugin: %s", discovered.Name)
}
switch discovered.Type {
case "manifest":
return loadManifestPlugin(discovered)
case "executable":
return loadExecutableAsPlugin(discovered)
default:
return nil, fmt.Errorf("unsupported plugin type: %s", discovered.Type)
}
}
func loadManifestPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
manifestPath := filepath.Join(dp.Path, "plugin.json")
manifest, err := LoadManifest(manifestPath)
if err != nil {
return nil, err
}
p := NewPlugin(manifest.Name, manifest.Version, manifest.Description)
for _, mt := range manifest.Tools {
handler := createExternalHandler(dp.Path, manifest)
td := &ToolDefinition{
Name: mt.Name,
Description: mt.Description,
Params: mt.Params,
Handler: handler,
}
p.AddTool(td)
}
return p, nil
}
func loadExecutableAsPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
p := NewPlugin(dp.Name, "0.0.1", "Executable plugin: "+dp.Name)
paramsSchema, _ := json.Marshal(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]string{"type": "string", "description": "Action to execute"},
"args": map[string]string{"type": "object", "description": "Arguments for the action"},
},
"required": []string{"action"},
})
td := &ToolDefinition{
Name: dp.Name,
Description: "External plugin tool: " + dp.Name,
Params: paramsSchema,
Handler: createScriptHandler(dp.Path),
}
p.AddTool(td)
return p, nil
}
func createExternalHandler(pluginDir string, manifest *PluginManifest) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
if manifest.Command == "" {
return agent.TextErrorResponse(fmt.Sprintf("no command configured for plugin %s", manifest.Name)), nil
}
cmd := exec.CommandContext(ctx, manifest.Command, manifest.Args...)
cmd.Dir = pluginDir
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("plugin execution failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func createScriptHandler(scriptPath string) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
cmd := exec.CommandContext(ctx, scriptPath)
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("script failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func DefaultPluginPaths() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
configDir, err := configDir()
if err != nil {
return []string{filepath.Join(home, ".muyue", "plugins")}
}
return []string{
filepath.Join(configDir, "plugins"),
filepath.Join(home, ".muyue", "plugins"),
}
}
func expandPath(p string) string {
if strings.HasPrefix(p, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, p[2:])
}
return p
}
func configDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "muyue"), nil
}
func generatePluginSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{"type": goTypeToJSON(field.Type)}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
return json.Marshal(schema)
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}

224
internal/plugins/plugin.go Normal file
View File

@@ -0,0 +1,224 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/muyue/muyue/internal/agent"
)
type PluginStatus string
const (
StatusEnabled PluginStatus = "enabled"
StatusDisabled PluginStatus = "disabled"
StatusError PluginStatus = "error"
)
type Plugin struct {
name string
version string
description string
status PluginStatus
tools []*agent.ToolDefinition
hooks map[HookType]HookFunc
init func(ctx context.Context, registry *agent.Registry) error
}
func NewPlugin(name, version, description string) *Plugin {
return &Plugin{
name: name,
version: version,
description: description,
status: StatusDisabled,
tools: make([]*agent.ToolDefinition, 0),
hooks: make(map[HookType]HookFunc),
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Version() string { return p.version }
func (p *Plugin) Description() string { return p.description }
func (p *Plugin) Status() PluginStatus { return p.status }
func (p *Plugin) AddTool(tool *ToolDefinition) *Plugin {
td := &agent.ToolDefinition{
Name: tool.Name,
Description: tool.Description,
Params: tool.Params,
Handler: tool.Handler,
}
p.tools = append(p.tools, td)
return p
}
func (p *Plugin) AddToolGeneric(params interface{}, name, description string, handler func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error)) *Plugin {
paramsSchema, err := generatePluginSchema(params)
if err == nil {
td := &agent.ToolDefinition{
Name: name,
Description: description,
Params: paramsSchema,
Handler: handler,
}
p.tools = append(p.tools, td)
}
return p
}
func (p *Plugin) AddHook(hookType HookType, fn HookFunc) *Plugin {
p.hooks[hookType] = fn
return p
}
func (p *Plugin) SetInit(fn func(ctx context.Context, registry *agent.Registry) error) *Plugin {
p.init = fn
return p
}
type ToolDefinition struct {
Name string
Description string
Params json.RawMessage
Handler func(ctx context.Context, args json.RawMessage) (agent.ToolResponse, error)
}
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Status PluginStatus `json:"status"`
ToolCount int `json:"tool_count"`
HookTypes []string `json:"hook_types,omitempty"`
Error string `json:"error,omitempty"`
}
type Manager struct {
mu sync.RWMutex
plugins map[string]*Plugin
hooks *HookRegistry
enabled map[string]bool
}
func NewManager(hooks *HookRegistry) *Manager {
return &Manager{
plugins: make(map[string]*Plugin),
hooks: hooks,
enabled: make(map[string]bool),
}
}
func (m *Manager) Register(p *Plugin) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.plugins[p.name]; exists {
return fmt.Errorf("plugin %q already registered", p.name)
}
m.plugins[p.name] = p
return nil
}
func (m *Manager) Enable(ctx context.Context, name string, registry *agent.Registry) error {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.plugins[name]
if !ok {
return fmt.Errorf("plugin %q not found", name)
}
if p.status == StatusEnabled {
return nil
}
if p.init != nil {
if err := p.init(ctx, registry); err != nil {
p.status = StatusError
return fmt.Errorf("plugin %q init failed: %w", name, err)
}
}
for _, tool := range p.tools {
if err := registry.Register(tool); err != nil {
p.status = StatusError
return fmt.Errorf("plugin %q register tool %q: %w", name, tool.Name, err)
}
}
for hookType, fn := range p.hooks {
m.hooks.Register(hookType, name, 10, fn)
}
p.status = StatusEnabled
m.enabled[name] = true
return nil
}
func (m *Manager) Disable(name string) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.plugins[name]
if !ok {
return
}
if p.status != StatusEnabled {
return
}
m.hooks.RemoveByPlugin(name)
p.status = StatusDisabled
delete(m.enabled, name)
}
func (m *Manager) Get(name string) (*Plugin, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.plugins[name]
return p, ok
}
func (m *Manager) List() []PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]PluginInfo, 0, len(m.plugins))
for _, p := range m.plugins {
info := PluginInfo{
Name: p.name,
Version: p.version,
Description: p.description,
Status: p.status,
ToolCount: len(p.tools),
}
for ht := range p.hooks {
info.HookTypes = append(info.HookTypes, string(ht))
}
result = append(result, info)
}
return result
}
func (m *Manager) EnabledNames() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.enabled))
for name := range m.enabled {
names = append(names, name)
}
return names
}
func (m *Manager) EnableFromConfig(ctx context.Context, enabledList []string, registry *agent.Registry) {
for _, name := range enabledList {
if err := m.Enable(ctx, name, registry); err != nil {
_ = err
}
}
}

174
internal/rag/chunker.go Normal file
View File

@@ -0,0 +1,174 @@
package rag
import (
"strings"
"unicode/utf8"
)
type Chunk struct {
ID int `json:"id"`
Content string `json:"content"`
StartPos int `json:"start_pos"`
EndPos int `json:"end_pos"`
Metadata string `json:"metadata,omitempty"`
}
func ChunkText(text string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 500
}
maxChars := maxTokens * 4
if maxChars < 200 {
maxChars = 200
}
lines := strings.Split(text, "\n")
var chunks []Chunk
var current strings.Builder
chunkID := 0
startPos := 0
currentPos := 0
for _, line := range lines {
lineLen := utf8.RuneCountInString(line) + 1
if current.Len() > 0 && utf8.RuneCountInString(current.String())+lineLen > maxChars {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: startPos,
EndPos: currentPos,
})
chunkID++
startPos = currentPos
current.Reset()
}
current.WriteString(line)
current.WriteString("\n")
currentPos += lineLen
}
if current.Len() > 0 {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: startPos,
EndPos: currentPos,
})
}
return chunks
}
func ChunkMarkdown(text string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 500
}
maxChars := maxTokens * 4
sections := splitMarkdownSections(text)
var chunks []Chunk
chunkID := 0
pos := 0
for _, section := range sections {
if utf8.RuneCountInString(section) > maxChars {
subChunks := ChunkText(section, maxTokens)
for i := range subChunks {
subChunks[i].ID = chunkID
subChunks[i].StartPos += pos
subChunks[i].EndPos += pos
chunkID++
}
chunks = append(chunks, subChunks...)
} else {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(section),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(section),
})
chunkID++
}
pos += utf8.RuneCountInString(section)
}
return chunks
}
func splitMarkdownSections(text string) []string {
var sections []string
var current strings.Builder
lines := strings.Split(text, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "##") || strings.HasPrefix(line, "###") {
if current.Len() > 0 {
sections = append(sections, current.String())
current.Reset()
}
}
current.WriteString(line)
current.WriteString("\n")
}
if current.Len() > 0 {
sections = append(sections, current.String())
}
if len(sections) == 0 && text != "" {
sections = []string{text}
}
return sections
}
func ChunkCode(code string, lang string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 300
}
maxChars := maxTokens * 4
var chunks []Chunk
chunkID := 0
pos := 0
lines := strings.Split(code, "\n")
var current strings.Builder
currentLines := 0
for _, line := range lines {
lineLen := utf8.RuneCountInString(line) + 1
if current.Len() > 0 && (utf8.RuneCountInString(current.String())+lineLen > maxChars || currentLines > 50) {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(current.String()),
Metadata: lang,
})
chunkID++
pos += utf8.RuneCountInString(current.String())
current.Reset()
currentLines = 0
}
current.WriteString(line)
current.WriteString("\n")
currentLines++
}
if current.Len() > 0 {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(current.String()),
Metadata: lang,
})
}
return chunks
}

113
internal/rag/embed.go Normal file
View File

@@ -0,0 +1,113 @@
package rag
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type EmbeddingClient struct {
apiKey string
baseURL string
client *http.Client
}
func NewEmbeddingClient(apiKey, baseURL string) *EmbeddingClient {
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return &EmbeddingClient{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{Timeout: 30 * time.Second},
}
}
type embeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type embeddingResponse struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
Usage struct {
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
func (c *EmbeddingClient) Embed(texts []string, model string) ([][]float64, error) {
if len(texts) == 0 {
return nil, nil
}
if model == "" {
model = "text-embedding-3-small"
}
body := embeddingRequest{
Model: model,
Input: texts,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal embedding request: %w", err)
}
url := c.baseURL + "/embeddings"
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("create embedding request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send embedding request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read embedding response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("embedding API error (%d): %s", resp.StatusCode, string(respBody))
}
var embResp embeddingResponse
if err := json.Unmarshal(respBody, &embResp); err != nil {
return nil, fmt.Errorf("parse embedding response: %w", err)
}
result := make([][]float64, len(texts))
for _, data := range embResp.Data {
if data.Index < len(result) {
result[data.Index] = data.Embedding
}
}
return result, nil
}
func (c *EmbeddingClient) EmbedSingle(text, model string) ([]float64, error) {
results, err := c.Embed([]string{text}, model)
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, fmt.Errorf("no embedding returned")
}
return results[0], nil
}

79
internal/rag/inject.go Normal file
View File

@@ -0,0 +1,79 @@
package rag
import (
"fmt"
"strings"
)
func BuildContextBlock(results []SearchResult, maxTokens int) string {
if len(results) == 0 {
return ""
}
if maxTokens <= 0 {
maxTokens = 4000
}
maxChars := maxTokens * 4
var b strings.Builder
b.WriteString("<rag_context>\n")
b.WriteString("The following context was retrieved from indexed documents to help answer the user's question.\n\n")
for i, r := range results {
entry := fmt.Sprintf("--- Source: %s (relevance: %.2f) ---\n%s\n\n", r.DocumentName, r.Score, r.Content)
if b.Len()+len(entry) > maxChars {
break
}
b.WriteString(entry)
_ = i
}
b.WriteString("</rag_context>\n")
return b.String()
}
func ExtractRAGQueries(message string) (queries []string, cleaned string) {
cleaned = message
parts := strings.Split(message, "#")
if len(parts) <= 1 {
return nil, message
}
var queryParts []string
var textParts []string
for i, part := range parts {
if i == 0 {
textParts = append(textParts, part)
continue
}
part = strings.TrimSpace(part)
if part == "" {
continue
}
firstSpace := strings.IndexByte(part, ' ')
newline := strings.IndexByte(part, '\n')
end := len(part)
if firstSpace > 0 && (newline < 0 || firstSpace < newline) {
end = firstSpace
} else if newline > 0 {
end = newline
}
query := strings.TrimSpace(part[:end])
if query != "" {
queryParts = append(queryParts, query)
}
if end < len(part) {
textParts = append(textParts, part[end:])
}
}
if len(queryParts) > 0 {
cleaned = strings.Join(textParts, " ")
cleaned = strings.TrimSpace(cleaned)
}
return queryParts, cleaned
}

343
internal/rag/store.go Normal file
View File

@@ -0,0 +1,343 @@
package rag
import (
"database/sql"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type Document struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Chunks int `json:"chunks"`
IndexedAt time.Time `json:"indexed_at"`
Size int64 `json:"size"`
}
type ChunkRecord struct {
ID int64 `json:"id"`
DocumentID string `json:"document_id"`
Content string `json:"content"`
Embedding []float64 `json:"embedding,omitempty"`
StartPos int `json:"start_pos"`
EndPos int `json:"end_pos"`
Metadata string `json:"metadata,omitempty"`
}
type Store struct {
mu sync.RWMutex
db *sql.DB
dir string
}
func NewStore(configDir string) (*Store, error) {
ragDir := filepath.Join(configDir, "rag")
if err := os.MkdirAll(ragDir, 0755); err != nil {
return nil, fmt.Errorf("creating rag dir: %w", err)
}
dbPath := filepath.Join(ragDir, "rag.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("opening rag db: %w", err)
}
s := &Store{db: db, dir: ragDir}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrating rag db: %w", err)
}
return s, nil
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'text',
chunks INTEGER NOT NULL DEFAULT 0,
indexed_at DATETIME NOT NULL,
size INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding BLOB,
start_pos INTEGER NOT NULL DEFAULT 0,
end_pos INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_chunks_document ON chunks(document_id);
`)
return err
}
func (s *Store) StoreDocument(doc Document, chunks []ChunkRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`INSERT OR REPLACE INTO documents (id, name, path, type, chunks, indexed_at, size) VALUES (?, ?, ?, ?, ?, ?, ?)`,
doc.ID, doc.Name, doc.Path, doc.Type, doc.Chunks, doc.IndexedAt, doc.Size)
if err != nil {
return fmt.Errorf("insert document: %w", err)
}
stmt, err := tx.Prepare(`INSERT INTO chunks (document_id, content, embedding, start_pos, end_pos, metadata) VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("prepare chunk insert: %w", err)
}
defer stmt.Close()
for _, chunk := range chunks {
var embBytes []byte
if len(chunk.Embedding) > 0 {
embBytes, err = json.Marshal(chunk.Embedding)
if err != nil {
return fmt.Errorf("marshal embedding: %w", err)
}
}
_, err = stmt.Exec(chunk.DocumentID, chunk.Content, embBytes, chunk.StartPos, chunk.EndPos, chunk.Metadata)
if err != nil {
return fmt.Errorf("insert chunk: %w", err)
}
}
return tx.Commit()
}
func (s *Store) ListDocuments() ([]Document, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, name, path, type, chunks, indexed_at, size FROM documents ORDER BY indexed_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []Document
for rows.Next() {
var doc Document
if err := rows.Scan(&doc.ID, &doc.Name, &doc.Path, &doc.Type, &doc.Chunks, &doc.IndexedAt, &doc.Size); err != nil {
return nil, err
}
docs = append(docs, doc)
}
return docs, nil
}
func (s *Store) DeleteDocument(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ?`, id)
return err
}
type SearchResult struct {
ChunkID int64 `json:"chunk_id"`
DocumentID string `json:"document_id"`
DocumentName string `json:"document_name"`
Content string `json:"content"`
Score float64 `json:"score"`
Metadata string `json:"metadata,omitempty"`
}
func (s *Store) Search(queryEmbedding []float64, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 5
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.embedding, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id WHERE c.embedding IS NOT NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
type scored struct {
result SearchResult
score float64
}
var results []scored
for rows.Next() {
var id int64
var docID, content, metadata, docName string
var embBytes []byte
if err := rows.Scan(&id, &docID, &content, &embBytes, &metadata, &docName); err != nil {
continue
}
var embedding []float64
if err := json.Unmarshal(embBytes, &embedding); err != nil {
continue
}
score := cosineSimilarity(queryEmbedding, embedding)
results = append(results, scored{
result: SearchResult{
ChunkID: id,
DocumentID: docID,
DocumentName: docName,
Content: content,
Metadata: metadata,
},
score: score,
})
}
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].score > results[i].score {
results[i], results[j] = results[j], results[i]
}
}
}
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
r.result.Score = r.score
out[i] = r.result
}
return out, nil
}
func (s *Store) SearchKeyword(query string, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 5
}
s.mu.RLock()
defer s.mu.RUnlock()
words := strings.Fields(strings.ToLower(query))
if len(words) == 0 {
return nil, nil
}
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id`)
if err != nil {
return nil, err
}
defer rows.Close()
type scored struct {
result SearchResult
score float64
}
var results []scored
for rows.Next() {
var id int64
var docID, content, metadata, docName string
if err := rows.Scan(&id, &docID, &content, &metadata, &docName); err != nil {
continue
}
lower := strings.ToLower(content)
var score float64
for _, word := range words {
count := strings.Count(lower, word)
if count > 0 {
score += float64(count) / float64(len(strings.Fields(lower)))
}
}
if score > 0 {
results = append(results, scored{
result: SearchResult{
ChunkID: id,
DocumentID: docID,
DocumentName: docName,
Content: content,
Metadata: metadata,
},
score: score,
})
}
}
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].score > results[i].score {
results[i], results[j] = results[j], results[i]
}
}
}
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
r.result.Score = r.score
out[i] = r.result
}
return out, nil
}
func (s *Store) Status() (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var docCount, chunkCount int
s.db.QueryRow(`SELECT COUNT(*) FROM documents`).Scan(&docCount)
s.db.QueryRow(`SELECT COUNT(*) FROM chunks`).Scan(&chunkCount)
var withEmb int
s.db.QueryRow(`SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL`).Scan(&withEmb)
return map[string]interface{}{
"documents": docCount,
"chunks": chunkCount,
"chunks_embedded": withEmb,
"storage_path": s.dir,
}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dot, normA, normB float64
for i := range a {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}

View File

@@ -0,0 +1,177 @@
package skills
import (
"testing"
"time"
)
func TestCheckActivationNoConditions(t *testing.T) {
skill := &Skill{
Name: "test-skill",
Description: "A test skill",
}
result := CheckActivation(skill, []string{"terminal"})
if !result.Active {
t.Error("expected skill with no conditions to be active")
}
}
func TestCheckActivationRequiresTools(t *testing.T) {
skill := &Skill{
Name: "docker-setup",
RequiresTools: []string{"terminal", "docker"},
}
result := CheckActivation(skill, []string{"terminal", "docker"})
if !result.Active {
t.Error("expected skill to be active when all required tools present")
}
result = CheckActivation(skill, []string{"terminal"})
if result.Active {
t.Error("expected skill to be inactive when required tool missing")
}
}
func TestCheckActivationFallbackForTools(t *testing.T) {
skill := &Skill{
Name: "basic-review",
FallbackForTools: []string{"crush_run", "claude_run"},
}
result := CheckActivation(skill, []string{"terminal"})
if !result.Active {
t.Error("expected fallback skill to activate when primary tools absent")
}
result = CheckActivation(skill, []string{"crush_run", "claude_run"})
if result.Active {
t.Error("expected fallback skill to stay inactive when primary tools present")
}
}
func TestFilterActiveSkills(t *testing.T) {
skills := []Skill{
{Name: "basic", Description: "basic"},
{Name: "needs-docker", RequiresTools: []string{"docker"}},
{Name: "fallback-review", FallbackForTools: []string{"crush_run"}},
}
active := FilterActiveSkills(skills, []string{"terminal"})
if len(active) != 2 {
t.Errorf("expected 2 active skills, got %d", len(active))
}
}
func TestGroupByReadiness(t *testing.T) {
skills := []Skill{
{Name: "basic", Description: "basic"},
{Name: "needs-docker", RequiresTools: []string{"docker"}},
}
available, needsSetup, unsupported := GroupByReadiness(skills, []string{})
if len(available) != 1 {
t.Errorf("expected 1 available, got %d", len(available))
}
if len(unsupported) != 1 {
t.Errorf("expected 1 unsupported, got %d", len(unsupported))
}
_ = needsSetup
}
func TestAnalyzeConversation(t *testing.T) {
snippets := []ConversationSnippet{
{Role: "assistant", Content: "go test ./... -race", Timestamp: time.Now()},
{Role: "assistant", Content: "go test ./... -race -cover", Timestamp: time.Now()},
{Role: "assistant", Content: "go test ./internal/... -v", Timestamp: time.Now()},
}
proposals := AnalyzeConversation(snippets)
if len(proposals) == 0 {
t.Error("expected at least one proposal from recurring patterns")
}
for _, p := range proposals {
if p.Confidence <= 0 {
t.Error("expected positive confidence")
}
if p.CreatedFrom != "conversation" {
t.Errorf("expected created_from=conversation, got %s", p.CreatedFrom)
}
}
}
func TestCategorize(t *testing.T) {
tests := []struct {
pattern string
want string
}{
{"go test", "testing"},
{"docker build", "devops"},
{"git commit", "workflow"},
{"npm test", "testing"},
{"make", "build"},
{"unknown", "general"},
}
for _, tt := range tests {
got := categorize(tt.pattern)
if got != tt.want {
t.Errorf("categorize(%q) = %q, want %q", tt.pattern, got, tt.want)
}
}
}
func TestImproverAnalyze(t *testing.T) {
improver, err := NewSkillImprover()
if err != nil {
t.Fatalf("new improver: %v", err)
}
skill := &Skill{
Name: "test-skill",
Description: "A test skill",
Content: "# Test\n\nSome basic content without structure.",
}
suggestions, err := improver.Analyze(skill, "")
if err != nil {
t.Fatalf("analyze: %v", err)
}
if len(suggestions) == 0 {
t.Error("expected improvement suggestions for minimal skill")
}
}
func TestImproverAnalyzeComplete(t *testing.T) {
improver, _ := NewSkillImprover()
skill := &Skill{
Name: "complete-skill",
Description: "A well-structured skill",
Content: `# Complete Skill
## Steps
1. Do step one
2. Do step two
## Error Handling
- Handle error A
- Handle error B
## When to use
Use this skill when doing X.
`,
Tags: []string{"testing", "go"},
}
suggestions, _ := improver.Analyze(skill, "testing go code")
if len(suggestions) > 2 {
t.Errorf("expected few suggestions for complete skill, got %d", len(suggestions))
}
}

View File

@@ -0,0 +1,282 @@
package skills
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type PatternMatch struct {
Pattern string
Count int
LastSeen time.Time
ExampleText string
}
type AutoCreateProposal struct {
Name string
Description string
SuggestedTags []string
Category string
Patterns []PatternMatch
Confidence float64
CreatedFrom string
}
type ConversationSnippet struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
func AnalyzeConversation(snippets []ConversationSnippet) []AutoCreateProposal {
patterns := detectPatterns(snippets)
var proposals []AutoCreateProposal
for _, p := range patterns {
if p.Count < 3 {
continue
}
name := generateSkillName(p.Pattern)
proposal := AutoCreateProposal{
Name: name,
Description: fmt.Sprintf("Auto-detected skill for recurring pattern: %s", p.Pattern),
SuggestedTags: extractTags(p.Pattern),
Category: categorize(p.Pattern),
Patterns: []PatternMatch{p},
Confidence: computeConfidence(p),
CreatedFrom: "conversation",
}
proposals = append(proposals, proposal)
}
return proposals
}
func CreateFromProposal(proposal *AutoCreateProposal) (*Skill, error) {
skill := &Skill{
Name: proposal.Name,
Description: proposal.Description,
Author: "muyue-auto",
Version: "0.1.0",
Tags: proposal.SuggestedTags,
Category: proposal.Category,
Target: "both",
CreatedFrom: proposal.CreatedFrom,
AutoImprove: true,
Content: buildAutoSkillContent(proposal),
}
return skill, Create(skill)
}
func LoadProposals() ([]AutoCreateProposal, error) {
dir, err := proposalsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var proposals []AutoCreateProposal
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var p AutoCreateProposal
if err := json.Unmarshal(data, &p); err != nil {
continue
}
proposals = append(proposals, p)
}
return proposals, nil
}
func SaveProposal(proposal *AutoCreateProposal) error {
dir, err := proposalsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(proposal, "", " ")
if err != nil {
return err
}
path := filepath.Join(dir, proposal.Name+".json")
return os.WriteFile(path, data, 0644)
}
func DeleteProposal(name string) error {
dir, err := proposalsDir()
if err != nil {
return err
}
path := filepath.Join(dir, name+".json")
return os.Remove(path)
}
func proposalsDir() (string, error) {
dir, err := SkillsDir()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(dir), ".muyue", "proposals"), nil
}
func detectPatterns(snippets []ConversationSnippet) []PatternMatch {
commandPatterns := make(map[string]*PatternMatch)
for _, s := range snippets {
if s.Role != "assistant" {
continue
}
lines := strings.Split(s.Content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if isCommandPattern(line) {
key := extractPatternKey(line)
if key == "" {
continue
}
if existing, ok := commandPatterns[key]; ok {
existing.Count++
if s.Timestamp.After(existing.LastSeen) {
existing.LastSeen = s.Timestamp
existing.ExampleText = truncate(line, 200)
}
} else {
commandPatterns[key] = &PatternMatch{
Pattern: key,
Count: 1,
LastSeen: s.Timestamp,
ExampleText: truncate(line, 200),
}
}
}
}
}
var patterns []PatternMatch
for _, p := range commandPatterns {
patterns = append(patterns, *p)
}
return patterns
}
func isCommandPattern(line string) bool {
toolPrefixes := []string{"go test", "go build", "go run", "npm test", "npm run",
"docker build", "docker run", "git commit", "git push", "kubectl",
"cargo test", "cargo build", "pytest", "make "}
for _, prefix := range toolPrefixes {
if strings.HasPrefix(line, prefix) {
return true
}
}
return false
}
func extractPatternKey(line string) string {
parts := strings.Fields(line)
if len(parts) < 2 {
return ""
}
if len(parts) >= 3 && (parts[0] == "go" || parts[0] == "npm" || parts[0] == "cargo" || parts[0] == "git" || parts[0] == "docker") {
return parts[0] + " " + parts[1]
}
return parts[0]
}
func generateSkillName(pattern string) string {
name := strings.ReplaceAll(pattern, " ", "-")
name = strings.ToLower(name)
if len(name) > 30 {
name = name[:30]
}
h := sha256.Sum256([]byte(pattern))
return fmt.Sprintf("auto-%s-%x", name, h[:4])
}
func extractTags(pattern string) []string {
var tags []string
parts := strings.Fields(pattern)
for _, p := range parts {
if len(p) > 2 {
tags = append(tags, strings.ToLower(p))
}
}
return tags
}
func categorize(pattern string) string {
categories := map[string]string{
"go test": "testing", "go build": "build", "go run": "build",
"npm test": "testing", "npm run": "build",
"docker build": "devops", "docker run": "devops",
"git commit": "workflow", "git push": "workflow",
"kubectl": "devops", "cargo test": "testing",
"cargo build": "build", "pytest": "testing",
"make": "build",
}
for prefix, cat := range categories {
if strings.HasPrefix(pattern, prefix) {
return cat
}
}
return "general"
}
func computeConfidence(p PatternMatch) float64 {
confidence := 0.3
confidence += float64(p.Count) * 0.1
if confidence > 0.95 {
confidence = 0.95
}
return confidence
}
func buildAutoSkillContent(proposal *AutoCreateProposal) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("# %s\n\n", strings.Title(proposal.Name)))
b.WriteString("Auto-generated skill based on recurring patterns detected in conversations.\n\n")
b.WriteString("## Activation\n\n")
b.WriteString("This skill activates when the following patterns are detected:\n\n")
for _, p := range proposal.Patterns {
b.WriteString(fmt.Sprintf("- `%s` (seen %d times)\n", p.Pattern, p.Count))
}
b.WriteString("\n## Instructions\n\n")
b.WriteString("1. Detect the pattern context from the user request\n")
b.WriteString("2. Apply the standard workflow for this pattern\n")
b.WriteString("3. Handle common errors and edge cases\n")
b.WriteString("4. Verify the result\n\n")
b.WriteString("## Error Handling\n\n")
b.WriteString("- If a command fails, check for missing dependencies\n")
b.WriteString("- Suggest alternative approaches when the standard pattern doesn't fit\n")
return b.String()
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}

View File

@@ -0,0 +1,125 @@
package skills
import (
"strings"
)
type ActivationResult struct {
Active bool
Reason string
Skill *Skill
}
func CheckActivation(skill *Skill, availableTools []string) ActivationResult {
if len(skill.RequiresTools) == 0 && len(skill.FallbackForTools) == 0 {
return ActivationResult{
Active: true,
Reason: "no activation conditions",
Skill: skill,
}
}
toolSet := make(map[string]bool, len(availableTools))
for _, t := range availableTools {
toolSet[strings.ToLower(t)] = true
}
if len(skill.RequiresTools) > 0 {
for _, req := range skill.RequiresTools {
if !toolSet[strings.ToLower(req)] {
return ActivationResult{
Active: false,
Reason: "missing required tool: " + req,
Skill: skill,
}
}
}
return ActivationResult{
Active: true,
Reason: "all required tools available",
Skill: skill,
}
}
if len(skill.FallbackForTools) > 0 {
allPresent := true
for _, fb := range skill.FallbackForTools {
if !toolSet[strings.ToLower(fb)] {
allPresent = false
break
}
}
if allPresent {
return ActivationResult{
Active: false,
Reason: "primary tools available, fallback not needed",
Skill: skill,
}
}
return ActivationResult{
Active: true,
Reason: "primary tools absent, activating as fallback",
Skill: skill,
}
}
return ActivationResult{Active: true, Skill: skill}
}
func FilterActiveSkills(skillsList []Skill, availableTools []string) []Skill {
var active []Skill
for i := range skillsList {
result := CheckActivation(&skillsList[i], availableTools)
if result.Active {
active = append(active, skillsList[i])
}
}
return active
}
func GroupByReadiness(skillsList []Skill, availableTools []string) (available, needsSetup, unsupported []Skill) {
toolSet := make(map[string]bool, len(availableTools))
for _, t := range availableTools {
toolSet[strings.ToLower(t)] = true
}
for i := range skillsList {
s := &skillsList[i]
if len(s.RequiresTools) == 0 && len(s.FallbackForTools) == 0 {
missing := CheckDependencies(s)
if len(missing) == 0 {
available = append(available, *s)
} else {
needsSetup = append(needsSetup, *s)
}
continue
}
allReqMet := true
for _, req := range s.RequiresTools {
if !toolSet[strings.ToLower(req)] {
allReqMet = false
break
}
}
if allReqMet && len(s.RequiresTools) > 0 {
available = append(available, *s)
} else if !allReqMet && len(s.RequiresTools) > 0 {
unsupported = append(unsupported, *s)
}
if len(s.FallbackForTools) > 0 {
anyMissing := false
for _, fb := range s.FallbackForTools {
if !toolSet[strings.ToLower(fb)] {
anyMissing = true
break
}
}
if anyMissing {
available = append(available, *s)
}
}
}
return
}

267
internal/skills/improver.go Normal file
View File

@@ -0,0 +1,267 @@
package skills
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type ImprovementSuggestion struct {
Type string `json:"type"`
Section string `json:"section"`
Current string `json:"current"`
Suggested string `json:"suggested"`
Reason string `json:"reason"`
Confidence float64 `json:"confidence"`
CreatedAt time.Time `json:"created_at"`
}
type ImprovementHistory struct {
SkillName string `json:"skill_name"`
Version string `json:"version"`
Improvements []ImprovementSuggestion `json:"improvements"`
AppliedAt time.Time `json:"applied_at"`
Result string `json:"result"`
}
type SkillImprover struct {
historyDir string
}
func NewSkillImprover() (*SkillImprover, error) {
dir, err := improvementHistoryDir()
if err != nil {
return nil, err
}
return &SkillImprover{historyDir: dir}, nil
}
func (si *SkillImprover) Analyze(skill *Skill, conversationContext string) ([]ImprovementSuggestion, error) {
var suggestions []ImprovementSuggestion
if skill.Content == "" {
return nil, fmt.Errorf("skill has no content to analyze")
}
suggestions = append(suggestions, si.checkMissingSections(skill)...)
suggestions = append(suggestions, si.checkErrorHandling(skill)...)
suggestions = append(suggestions, si.checkStepCompleteness(skill)...)
suggestions = append(suggestions, si.analyzeContextRelevance(skill, conversationContext)...)
return suggestions, nil
}
func (si *SkillImprover) ApplyImprovement(skillName string, suggestion ImprovementSuggestion) error {
skill, err := Get(skillName)
if err != nil {
return fmt.Errorf("get skill: %w", err)
}
switch suggestion.Section {
case "content":
skill.Content = applyContentSuggestion(skill.Content, suggestion)
case "description":
skill.Description = suggestion.Suggested
default:
skill.Content = applyContentSuggestion(skill.Content, suggestion)
}
now := time.Now()
skill.LastImprovedAt = &now
skill.ImprovementCount++
if err := Update(skill); err != nil {
return fmt.Errorf("update skill: %w", err)
}
history := ImprovementHistory{
SkillName: skillName,
Version: skill.Version,
Improvements: []ImprovementSuggestion{suggestion},
AppliedAt: now,
Result: "applied",
}
return si.saveHistory(&history)
}
func (si *SkillImprover) GetHistory(skillName string) ([]ImprovementHistory, error) {
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
return nil, err
}
entries, err := os.ReadDir(si.historyDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var histories []ImprovementHistory
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
if skillName != "" && !strings.HasPrefix(e.Name(), skillName+"_") {
continue
}
data, err := os.ReadFile(filepath.Join(si.historyDir, e.Name()))
if err != nil {
continue
}
var h ImprovementHistory
if err := json.Unmarshal(data, &h); err != nil {
continue
}
histories = append(histories, h)
}
return histories, nil
}
func (si *SkillImprover) checkMissingSections(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
content := strings.ToLower(skill.Content)
requiredSections := []struct {
keyword string
label string
}{
{"error handling", "Error Handling"},
{"steps", "Steps"},
{"when to", "Activation"},
}
for _, req := range requiredSections {
if !strings.Contains(content, req.keyword) {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "missing_section",
Section: "content",
Current: "",
Suggested: fmt.Sprintf("Add a '%s' section", req.label),
Reason: fmt.Sprintf("Skill is missing a '%s' section which is important for completeness", req.label),
Confidence: 0.8,
CreatedAt: time.Now(),
})
}
}
return suggestions
}
func (si *SkillImprover) checkErrorHandling(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
content := strings.ToLower(skill.Content)
if !strings.Contains(content, "error") && !strings.Contains(content, "fail") {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "missing_error_handling",
Section: "content",
Current: "",
Suggested: "Add error handling guidance covering common failure modes",
Reason: "Skill lacks error handling guidance, which may lead to poor user experience when things go wrong",
Confidence: 0.85,
CreatedAt: time.Now(),
})
}
return suggestions
}
func (si *SkillImprover) checkStepCompleteness(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
lines := strings.Split(skill.Content, "\n")
stepCount := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "1.") || strings.HasPrefix(trimmed, "Step 1") {
stepCount++
}
}
if stepCount == 0 && len(lines) > 10 {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "no_clear_steps",
Section: "content",
Current: "",
Suggested: "Add numbered step-by-step instructions for clarity",
Reason: "Long skill content without clear step-by-step structure can be hard to follow",
Confidence: 0.7,
CreatedAt: time.Now(),
})
}
return suggestions
}
func (si *SkillImprover) analyzeContextRelevance(skill *Skill, context string) []ImprovementSuggestion {
if context == "" {
return nil
}
var suggestions []ImprovementSuggestion
contextLower := strings.ToLower(context)
tags := skill.Tags
relevance := 0
for _, tag := range tags {
if strings.Contains(contextLower, strings.ToLower(tag)) {
relevance++
}
}
if len(tags) > 0 && relevance == 0 && skill.Category != "" && !strings.Contains(contextLower, strings.ToLower(skill.Category)) {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "tag_relevance",
Section: "tags",
Current: strings.Join(tags, ", "),
Suggested: "Review tags for better context matching",
Reason: "Current tags do not match recent conversation context, suggesting tags may need updating",
Confidence: 0.5,
CreatedAt: time.Now(),
})
}
return suggestions
}
func applyContentSuggestion(content string, suggestion ImprovementSuggestion) string {
switch suggestion.Type {
case "missing_section":
return content + "\n\n## " + strings.Title(suggestion.Type) + "\n\n" + suggestion.Suggested + ".\n"
case "missing_error_handling":
return content + "\n\n## Error Handling\n\n- Handle common failure modes gracefully\n- Provide clear error messages\n- Suggest alternative approaches\n"
case "no_clear_steps":
return "## Steps\n\n1. Review the skill context\n2. Apply the appropriate pattern\n3. Verify the result\n\n" + content
default:
return content
}
}
func (si *SkillImprover) saveHistory(history *ImprovementHistory) error {
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
return err
}
filename := fmt.Sprintf("%s_%s.json", history.SkillName, history.AppliedAt.Format("20060102-150405"))
data, err := json.MarshalIndent(history, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(si.historyDir, filename), data, 0644)
}
func improvementHistoryDir() (string, error) {
dir, err := SkillsDir()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(dir), ".muyue", "improvements"), nil
}

View File

@@ -20,20 +20,26 @@ type SkillDependency struct {
} }
type Skill struct { type Skill struct {
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"` Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"` Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"` Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"` Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"` CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"` UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"` Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"` Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"` FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"` Category string `yaml:"category,omitempty" json:"category,omitempty"`
Deployed bool `yaml:"-" json:"deployed,omitempty"` Deployed bool `yaml:"-" json:"deployed,omitempty"`
RequiresTools []string `yaml:"requires_tools,omitempty" json:"requires_tools,omitempty"`
FallbackForTools []string `yaml:"fallback_for_tools,omitempty" json:"fallback_for_tools,omitempty"`
AutoImprove bool `yaml:"auto_improve,omitempty" json:"auto_improve,omitempty"`
CreatedFrom string `yaml:"created_from,omitempty" json:"created_from,omitempty"`
ImprovementCount int `yaml:"improvement_count,omitempty" json:"improvement_count,omitempty"`
LastImprovedAt *time.Time `yaml:"last_improved_at,omitempty" json:"last_improved_at,omitempty"`
} }
type ValidationError struct { type ValidationError struct {
@@ -516,6 +522,24 @@ func renderSkill(skill *Skill) string {
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req)) b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
} }
} }
if len(skill.RequiresTools) > 0 {
b.WriteString(fmt.Sprintf("requires_tools: [%s]\n", strings.Join(skill.RequiresTools, ", ")))
}
if len(skill.FallbackForTools) > 0 {
b.WriteString(fmt.Sprintf("fallback_for_tools: [%s]\n", strings.Join(skill.FallbackForTools, ", ")))
}
if skill.AutoImprove {
b.WriteString("auto_improve: true\n")
}
if skill.CreatedFrom != "" {
b.WriteString(fmt.Sprintf("created_from: %s\n", skill.CreatedFrom))
}
if skill.ImprovementCount > 0 {
b.WriteString(fmt.Sprintf("improvement_count: %d\n", skill.ImprovementCount))
}
if skill.LastImprovedAt != nil {
b.WriteString(fmt.Sprintf("last_improved_at: %s\n", skill.LastImprovedAt.Format(time.RFC3339)))
}
b.WriteString("---\n\n") b.WriteString("---\n\n")
b.WriteString(skill.Content) b.WriteString(skill.Content)
b.WriteString("\n") b.WriteString("\n")

View File

@@ -2,16 +2,28 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0A0A0C" /> <meta name="theme-color" content="#0A0A0C" />
<title>Muyue</title> <meta name="description" content="Muyue - AI-powered development environment" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Muyue" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
<link rel="shortcut icon" href="/muyue.png" /> <link rel="shortcut icon" href="/muyue.png" />
<title>Muyue</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body> </body>
</html> </html>

2180
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.1",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.10.0-beta.203", "@xterm/addon-image": "^0.10.0-beta.203",
"@xterm/addon-search": "^0.17.0-beta.203", "@xterm/addon-search": "^0.17.0-beta.203",
@@ -15,10 +27,17 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.20.0-beta.202", "@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.1.0-beta.203", "@xterm/xterm": "^6.1.0-beta.203",
"highlight.js": "^11.11.1",
"katex": "^0.16.45",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"mermaid": "^11.14.0", "mermaid": "^11.14.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",

28
web/public/manifest.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "Muyue",
"short_name": "Muyue",
"description": "AI-powered development environment",
"start_url": "/",
"display": "standalone",
"background_color": "#0A0A0C",
"theme_color": "#FF0033",
"orientation": "any",
"icons": [
{
"src": "/muyue-64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/muyue.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/favicon-32.png",
"sizes": "32x32",
"type": "image/png"
}
],
"categories": ["developer tools", "productivity"]
}

42
web/public/sw.js Normal file
View File

@@ -0,0 +1,42 @@
const CACHE_NAME = 'muyue-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
if (request.url.includes('/api/')) return;
event.respondWith(
caches.match(request).then((cached) => {
const fetchPromise = fetch(request).then((response) => {
if (response && response.status === 200 && response.type === 'basic') {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
}).catch(() => cached);
return cached || fetchPromise;
})
);
});

View File

@@ -162,6 +162,33 @@ const api = {
}).catch(reject) }).catch(reject)
}) })
}, },
ragIndex: (text, name, type) => request('/rag/index', { method: 'POST', body: JSON.stringify({ text, name, type: type || 'text' }) }),
ragIndexFile: (file) => {
const formData = new FormData()
formData.append('file', file)
return fetch(`${API_BASE}/rag/index`, { method: 'POST', body: formData }).then(r => {
if (!r.ok) return r.json().then(e => { throw new Error(e.error || r.statusText) })
return r.json()
})
},
ragSearch: (query, limit) => request('/rag/search', { method: 'POST', body: JSON.stringify({ query, limit: limit || 5 }) }),
ragStatus: () => request('/rag/status'),
ragDocuments: () => request('/rag/documents'),
ragDelete: (id) => fetch(`${API_BASE}/rag/index/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }).then(r => r.json()),
pipelineFilters: () => request('/pipeline/filters'),
pipelineToggle: (name, enabled) => request(`/pipeline/filters/${name}`, { method: 'POST', body: JSON.stringify({ enabled }) }),
generateImage: (prompt, size, style) => request('/images/generate', { method: 'POST', body: JSON.stringify({ prompt, size: size || '1024x1024', style: style || 'vivid' }) }),
fileRead: (path) => request(`/files/content?path=${encodeURIComponent(path)}`),
fileWrite: (path, content) => request('/files/content', { method: 'PUT', body: JSON.stringify({ path, content }) }),
mcpServerStatus: () => request('/mcp-server/status'),
mcpServerStart: () => request('/mcp-server/start', { method: 'POST' }),
mcpServerStop: () => request('/mcp-server/stop', { method: 'POST' }),
getAgentSessions: () => request('/agent-sessions'),
getAgentSessionOutput: (id) => request(`/agent-sessions/${encodeURIComponent(id)}`),
getWorkspaces: () => request('/workspaces'),
saveWorkspace: (name, layout, tabs) => request('/workspace', { method: 'POST', body: JSON.stringify({ name, layout, tabs }) }),
getWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`),
deleteWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`, { method: 'DELETE' }),
} }
export default api export default api

View File

@@ -0,0 +1,262 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightActiveLine, drawSelection, rectangularSelection, highlightSpecialChars } from '@codemirror/view'
import { EditorState, Compartment } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { python } from '@codemirror/lang-python'
import { go } from '@codemirror/lang-go'
import { json } from '@codemirror/lang-json'
import { yaml } from '@codemirror/lang-yaml'
import { markdown } from '@codemirror/lang-markdown'
import { oneDark } from '@codemirror/theme-one-dark'
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete'
import { X, Save, RotateCcw } from 'lucide-react'
const langExtensions = {
javascript: () => javascript({ jsx: true }),
typescript: () => javascript({ jsx: true, typescript: true }),
python: () => python(),
go: () => go(),
json: () => json(),
yaml: () => yaml(),
markdown: () => markdown(),
}
function getLangExtension(lang) {
const factory = langExtensions[lang]
if (factory) return factory()
return []
}
function createEditorTheme() {
return EditorView.theme({
'&': {
fontSize: '13px',
backgroundColor: 'var(--bg-base, #0F0D10)',
color: 'var(--text-primary, #EAE0E2)',
height: '100%',
},
'.cm-content': {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
caretColor: 'var(--accent, #FF0033)',
padding: '4px 0',
},
'.cm-cursor': {
borderLeftColor: 'var(--accent, #FF0033)',
borderLeftWidth: '2px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'var(--accent-dim, #6B2033) !important',
},
'.cm-gutters': {
backgroundColor: 'var(--bg-surface, #161218)',
color: 'var(--text-tertiary, #8A7A7E)',
border: 'none',
borderRight: '1px solid var(--border, #2A1F22)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--bg-elevated, #1C1719)',
color: 'var(--text-secondary, #D4C4C8)',
},
'.cm-activeLine': {
backgroundColor: 'rgba(255, 0, 51, 0.05)',
},
'.cm-matchingBracket': {
backgroundColor: 'var(--accent-dim, #6B2033)',
outline: '1px solid var(--accent, #FF0033)',
color: '#fff !important',
},
'.cm-selectionMatch': {
backgroundColor: 'var(--accent-dim, #6B2033)',
},
'.cm-foldGutter': {
color: 'var(--text-tertiary, #8A7A7E)',
},
'.cm-scroller': {
overflow: 'auto',
},
}, { dark: true })
}
export default function FileEditor({ api, filePath, onClose }) {
const editorRef = useRef(null)
const viewRef = useRef(null)
const langCompartment = useRef(new Compartment())
const [content, setContent] = useState('')
const [originalContent, setOriginalContent] = useState('')
const [lang, setLang] = useState('text')
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [fileName, setFileName] = useState('')
useEffect(() => {
if (!filePath) return
const name = filePath.split('/').pop()
setFileName(name)
api.fileRead(filePath).then(data => {
setContent(data.content || '')
setOriginalContent(data.content || '')
setLang(data.lang || 'text')
setLoading(false)
}).catch(err => {
setError(err.message || 'Failed to read file')
setLoading(false)
})
}, [filePath])
useEffect(() => {
if (!editorRef.current || loading || viewRef.current) return
const customTheme = createEditorTheme()
const state = EditorState.create({
doc: content,
extensions: [
customTheme,
oneDark,
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
highlightActiveLine(),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
indentWithTab,
{
key: 'Mod-s',
run: () => { handleSave() ; return true },
},
{
key: 'Escape',
run: () => { if (onClose) onClose() ; return true },
},
]),
langCompartment.current.of(getLangExtension(lang)),
EditorView.updateListener.of(update => {
if (update.docChanged) {
const newContent = update.state.doc.toString()
setDirty(newContent !== originalContent)
}
}),
EditorView.lineWrapping,
],
})
const view = new EditorView({
state,
parent: editorRef.current,
})
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
}, [loading])
useEffect(() => {
if (!viewRef.current || !lang) return
try {
viewRef.current.dispatch({
effects: langCompartment.current.reconfigure(getLangExtension(lang)),
})
} catch {}
}, [lang])
const handleSave = useCallback(async () => {
if (!viewRef.current || !filePath || saving) return
const newContent = viewRef.current.state.doc.toString()
setSaving(true)
try {
await api.fileWrite(filePath, newContent)
setOriginalContent(newContent)
setDirty(false)
setContent(newContent)
} catch (err) {
setError(err.message)
}
setSaving(false)
}, [filePath, saving, api])
const handleReload = useCallback(() => {
if (!viewRef.current) return
api.fileRead(filePath).then(data => {
const doc = data.content || ''
viewRef.current.dispatch({
changes: { from: 0, to: viewRef.current.state.doc.length, insert: doc },
})
setOriginalContent(doc)
setDirty(false)
setContent(doc)
setLang(data.lang || 'text')
}).catch(err => setError(err.message))
}, [filePath, api])
if (loading) {
return (
<div className="file-editor-panel">
<div className="file-editor-header">
<span className="file-editor-title">Loading...</span>
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
</div>
</div>
)
}
if (error && !content) {
return (
<div className="file-editor-panel">
<div className="file-editor-header">
<span className="file-editor-title" style={{ color: 'var(--error)' }}>{error}</span>
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
</div>
</div>
)
}
return (
<div className="file-editor-panel">
<div className="file-editor-header">
<span className="file-editor-title">
{fileName}
{dirty && <span className="file-editor-dirty"></span>}
</span>
<div className="file-editor-actions">
<span className="file-editor-lang-badge">{lang}</span>
<button className="ghost sm" onClick={handleReload} title="Reload">
<RotateCcw size={13} />
</button>
<button
className="sm primary"
onClick={handleSave}
disabled={!dirty || saving}
>
<Save size={13} />
{saving ? '...' : 'Save'}
</button>
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
</div>
</div>
<div className="file-editor-body" ref={editorRef} />
</div>
)
}

View File

@@ -6,18 +6,21 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search' import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11' import { Unicode11Addon } from '@xterm/addon-unicode11'
import { ImageAddon } from '@xterm/addon-image' import { ImageAddon } from '@xterm/addon-image'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react' import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot, Columns, Rows, Maximize2 } from 'lucide-react'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import FileEditor from './FileEditor'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' }) mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
const AI_TAB_ID = 0 const AI_TAB_ID = 0
const MAX_TABS = 7 const MAX_TABS = 7
const MAX_PANES = 4
const SHELL_MAX_TOKENS = 100000 const SHELL_MAX_TOKENS = 100000
const SHELL_AI_COMMANDS = ['/clear', '/help', '/model', '/model change'] const SHELL_AI_COMMANDS = ['/clear', '/help', '/model', '/model change']
const TABS_STORAGE_KEY = 'muyue_shell_tabs' const TABS_STORAGE_KEY = 'muyue_shell_tabs'
const LAYOUT_STORAGE_KEY = 'muyue_shell_layout'
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers' const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
function renderContent(text) { function renderContent(text) {
@@ -477,6 +480,107 @@ export default function Shell({ api, isSudo }) {
const _streamRafRef = useRef(null) const _streamRafRef = useRef(null)
const _streamPendingRef = useRef(null) const _streamPendingRef = useRef(null)
const [splitLayout, setSplitLayout] = useState(() => {
try {
const raw = localStorage.getItem(LAYOUT_STORAGE_KEY)
if (raw) return JSON.parse(raw)
} catch {}
return null
})
const [editingFile, setEditingFile] = useState(null)
const [agentSessions, setAgentSessions] = useState([])
const paneCount = useMemo(() => {
const count = (node) => {
if (!node) return 1
if (node.type === 'leaf') return 1
return count(node.children?.[0]) + count(node.children?.[1])
}
return count(splitLayout)
}, [splitLayout])
const splitPane = useCallback((direction) => {
if (paneCount >= MAX_PANES) return
const activeId = activeTabRef.current
setSplitLayout(prev => {
if (!prev) {
return { type: 'split', direction, ratio: 0.5, activePane: activeId, children: [
{ type: 'leaf', tabId: activeId },
{ type: 'leaf', tabId: null },
]}
}
const clone = JSON.parse(JSON.stringify(prev))
const findAndSplit = (node) => {
if (node.type === 'leaf' && node.tabId === activeId) {
return { type: 'split', direction, ratio: 0.5, activePane: activeId, children: [
{ type: 'leaf', tabId: activeId },
{ type: 'leaf', tabId: null },
]}
}
if (node.children) {
return { ...node, children: node.children.map(findAndSplit) }
}
return node
}
return findAndSplit(clone)
})
}, [paneCount])
const removePane = useCallback((tabId) => {
setSplitLayout(prev => {
if (!prev) return null
if (prev.type === 'leaf') return null
const clone = JSON.parse(JSON.stringify(prev))
const removeFromTree = (node) => {
if (node.type !== 'split') return node
const left = node.children[0]
const right = node.children[1]
const leftIsTarget = left.type === 'leaf' && left.tabId === tabId
const rightIsTarget = right.type === 'leaf' && right.tabId === tabId
if (leftIsTarget) return removeFromTree(right)
if (rightIsTarget) return removeFromTree(left)
return { ...node, children: [removeFromTree(left), removeFromTree(right)] }
}
const result = removeFromTree(clone)
if (result.type === 'leaf' && result.tabId === null) return null
if (result.type === 'split' && (!result.children || result.children.length < 2)) return result.children?.[0] || null
return result
})
}, [])
const assignPaneTab = useCallback((paneLeaf, tabId) => {
setSplitLayout(prev => {
if (!prev) return prev
const clone = JSON.parse(JSON.stringify(prev))
const assign = (node) => {
if (node === paneLeaf) return { ...node, tabId }
if (node.children) return { ...node, children: node.children.map(assign) }
return node
}
return assign(clone)
})
}, [])
useEffect(() => {
if (splitLayout) {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(splitLayout))
} else {
localStorage.removeItem(LAYOUT_STORAGE_KEY)
}
}, [splitLayout])
useEffect(() => {
api.getAgentSessions?.().then(d => {
setAgentSessions(d?.sessions || [])
}).catch(() => {})
const iv = setInterval(() => {
api.getAgentSessions?.().then(d => {
setAgentSessions(d?.sessions || [])
}).catch(() => {})
}, 5000)
return () => clearInterval(iv)
}, [])
const _flushStreamUpdate = useCallback(() => { const _flushStreamUpdate = useCallback(() => {
_streamRafRef.current = null _streamRafRef.current = null
const pending = _streamPendingRef.current const pending = _streamPendingRef.current
@@ -818,6 +922,22 @@ export default function Shell({ api, isSudo }) {
return return
} }
if (ctrl && e.shiftKey && e.key === 'D') {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
splitPane('vertical')
return
}
if (ctrl && e.shiftKey && e.key === 'H') {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
splitPane('horizontal')
return
}
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
@@ -1318,6 +1438,27 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
{zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px {zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px
</span> </span>
)} )}
{paneCount < MAX_PANES && (
<>
<button className="shell-split-btn" onClick={() => splitPane('vertical')} title="Split Vertical (Ctrl+Shift+D)">
<Columns size={14} />
</button>
<button className="shell-split-btn" onClick={() => splitPane('horizontal')} title="Split Horizontal (Ctrl+Shift+H)">
<Rows size={14} />
</button>
</>
)}
{splitLayout && (
<button className="shell-split-btn" onClick={() => setSplitLayout(null)} title="Unsplit">
<Maximize2 size={14} />
</button>
)}
{agentSessions.length > 0 && (
<span className="shell-agent-indicator" title={`${agentSessions.length} agent(s) actif(s)`}>
<Bot size={12} />
<span className="shell-agent-count">{agentSessions.length}</span>
</span>
)}
{tabs.length < MAX_TABS && ( {tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper"> <div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}> <button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
@@ -1384,34 +1525,59 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
</div> </div>
</div> </div>
<div className="shell-xterm-wrapper"> <div className={`shell-xterm-wrapper ${splitLayout ? 'has-splits' : ''}`}>
{showSearch && ( {editingFile && (
<div className="shell-search-bar"> <FileEditor api={api} filePath={editingFile} onClose={() => setEditingFile(null)} />
<Search size={14} className="shell-search-icon" /> )}
<input {!editingFile && (
ref={searchInputRef} splitLayout ? (
className="shell-search-input" <SplitPaneRenderer
value={searchText} node={splitLayout}
onChange={e => handleSearchChange(e.target.value)} tabs={tabs}
onKeyDown={e => { activeTab={activeTab}
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() } setActiveTab={setActiveTab}
if (e.key === 'Escape') handleCloseSearch() showSearch={showSearch}
e.stopPropagation() searchText={searchText}
}} searchInputRef={searchInputRef}
placeholder="Rechercher..." handleSearchChange={handleSearchChange}
/> handleSearchNext={handleSearchNext}
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)"></button> handleSearchPrev={handleSearchPrev}
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)"></button> handleCloseSearch={handleCloseSearch}
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button> removePane={removePane}
</div> onLayoutChange={setSplitLayout}
/>
) : (
<>
{showSearch && (
<div className="shell-search-bar">
<Search size={14} className="shell-search-icon" />
<input
ref={searchInputRef}
className="shell-search-input"
value={searchText}
onChange={e => handleSearchChange(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
if (e.key === 'Escape') handleCloseSearch()
e.stopPropagation()
}}
placeholder="Rechercher..."
/>
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)"></button>
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)"></button>
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
</div>
)}
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
/>
))}
</>
)
)} )}
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
/>
))}
</div> </div>
</div> </div>
@@ -1775,3 +1941,158 @@ const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, termi
</div> </div>
) )
}) })
function SplitPaneRenderer({ node, tabs, activeTab, setActiveTab, showSearch, searchText, searchInputRef, handleSearchChange, handleSearchNext, handleSearchPrev, handleCloseSearch, removePane, onLayoutChange }) {
if (!node) return null
if (node.type === 'leaf') {
const tabId = node.tabId
const tab = tabId ? tabs.find(t => t.id === tabId) : null
const isActive = activeTab === tabId
if (!tab && tabId !== null) {
const fallbackTab = tabs[0]
if (fallbackTab) {
return (
<div className="split-pane-leaf" onClick={() => setActiveTab(fallbackTab.id)}>
<div id={`terminal-${fallbackTab.id}`} className={`shell-xterm-instance${activeTab === fallbackTab.id ? ' active' : ''}`} />
</div>
)
}
}
if (!tab) {
return (
<div className="split-pane-leaf empty">
<div className="split-pane-empty">
<Monitor size={24} style={{ opacity: 0.3 }} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
Select a tab for this pane
</span>
<div style={{ display: 'flex', gap: 4, marginTop: 8 }}>
{tabs.slice(0, 4).map(t => (
<button key={t.id} className="sm ghost" onClick={() => { onLayoutChange(prev => assignLeafTab(prev, node, t.id)) }}>
{t.name}
</button>
))}
</div>
</div>
</div>
)
}
return (
<div className={`split-pane-leaf ${isActive ? 'active' : ''}`} onClick={() => setActiveTab(tabId)}>
<div className="split-pane-header">
<span className="split-pane-title">{tab.name}</span>
<button className="split-pane-close" onClick={(e) => { e.stopPropagation(); removePane(tabId) }}>
<X size={10} />
</button>
</div>
<div className="split-pane-content">
<div id={`terminal-${tabId}`} className="shell-xterm-instance active" />
</div>
</div>
)
}
if (node.type === 'split') {
const dir = node.direction === 'horizontal' ? 'row' : 'column'
return (
<div className={`split-pane-split ${dir}`} style={{ flex: 1 }}>
<div className="split-pane-child" style={{ flex: node.ratio || 0.5, overflow: 'hidden' }}>
<SplitPaneRenderer
node={node.children?.[0]}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
showSearch={showSearch}
searchText={searchText}
searchInputRef={searchInputRef}
handleSearchChange={handleSearchChange}
handleSearchNext={handleSearchNext}
handleSearchPrev={handleSearchPrev}
handleCloseSearch={handleCloseSearch}
removePane={removePane}
onLayoutChange={onLayoutChange}
/>
</div>
<div
className="split-pane-resizer"
onMouseDown={(e) => {
e.preventDefault()
const parent = e.target.parentElement
const startX = e.clientX
const startY = e.clientY
const startRatio = node.ratio || 0.5
const isVertical = node.direction === 'vertical'
const parentSize = isVertical ? parent.offsetWidth : parent.offsetHeight
const onMouseMove = (me) => {
const delta = isVertical ? (me.clientX - startX) : (me.clientY - startY)
const newRatio = Math.max(0.15, Math.min(0.85, startRatio + delta / parentSize))
onLayoutChange(prev => updateSplitRatio(prev, node, newRatio))
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = isVertical ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}}
/>
<div className="split-pane-child" style={{ flex: 1 - (node.ratio || 0.5), overflow: 'hidden' }}>
<SplitPaneRenderer
node={node.children?.[1]}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
showSearch={showSearch}
searchText={searchText}
searchInputRef={searchInputRef}
handleSearchChange={handleSearchChange}
handleSearchNext={handleSearchNext}
handleSearchPrev={handleSearchPrev}
handleCloseSearch={handleCloseSearch}
removePane={removePane}
onLayoutChange={onLayoutChange}
/>
</div>
</div>
)
}
return null
}
function assignLeafTab(layout, leaf, tabId) {
if (!layout) return layout
if (layout === leaf) return { ...layout, tabId }
if (layout.children) {
return {
...layout,
children: layout.children.map(c => assignLeafTab(c, leaf, tabId)),
}
}
return layout
}
function updateSplitRatio(layout, targetNode, ratio) {
if (layout === targetNode) {
return { ...layout, ratio }
}
if (layout.children) {
return {
...layout,
children: layout.children.map(c => updateSplitRatio(c, targetNode, ratio)),
}
}
return layout
}

View File

@@ -1,6 +1,13 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
import mermaid from 'mermaid' import mermaid from 'mermaid'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeHighlight from 'rehype-highlight'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github-dark.css'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' }) mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
@@ -279,6 +286,14 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
const [showRawMarkdown, setShowRawMarkdown] = useState(() => {
try { return localStorage.getItem('muyue.showRawMarkdown') === 'true' } catch { return false }
})
const renderMarkdown = useCallback((content) => {
return <MarkdownContent content={content} raw={showRawMarkdown} />
}, [showRawMarkdown])
let parsedToolCalls = null let parsedToolCalls = null
let parsedToolResults = null let parsedToolResults = null
let parsedSegments = null let parsedSegments = null
@@ -322,7 +337,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
<span className="feed-role">{rank.label}</span> <span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>} {timeStr && <span className="feed-time">{timeStr}</span>}
</div> </div>
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />} {msg.thinking && <ThinkingBlock content={msg.thinking} done raw />}
{msg.images && msg.images.length > 0 && ( {msg.images && msg.images.length > 0 && (
<div className="feed-images"> <div className="feed-images">
{msg.images.map((imgId, i) => ( {msg.images.map((imgId, i) => (
@@ -354,13 +369,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
if (!c) return null if (!c) return null
return ( return (
<div key={`t${i}`} className="feed-content"> <div key={`t${i}`} className="feed-content">
{renderContent(c).map((part, j) => {renderMarkdown(c)}
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div> </div>
) )
} }
@@ -408,13 +417,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
})()} })()}
{cleanContent && ( {cleanContent && (
<div className="feed-content"> <div className="feed-content">
{renderContent(cleanContent).map((part, i) => {renderMarkdown(cleanContent)}
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div> </div>
)} )}
</> </>
@@ -432,13 +435,13 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
const [forceExpand, setForceExpand] = useState(false) const [forceExpand, setForceExpand] = useState(false)
const renderedContent = useMemo(() => { const renderedContent = useMemo(() => {
if (!cleanContent) return [] if (!cleanContent) return null
return renderContent(cleanContent) return null
}, [cleanContent]) }, [cleanContent])
const formattedThinking = useMemo(() => { const formattedThinking = useMemo(() => {
if (!thinking) return '' if (!thinking) return ''
return formatText(thinking) return thinking
}, [thinking]) }, [thinking])
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool') const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
@@ -457,7 +460,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
</span> </span>
<span className="feed-role">{rank.label}</span> <span className="feed-role">{rank.label}</span>
</div> </div>
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />} {thinking && <ThinkingBlock content={thinking} raw done={false} />}
{hasOrderedSegments ? ( {hasOrderedSegments ? (
<> <>
{compress && ( {compress && (
@@ -475,16 +478,9 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
return segments.map((seg, i) => { return segments.map((seg, i) => {
if (seg.type === 'text') { if (seg.type === 'text') {
if (!seg.content) return null if (!seg.content) return null
const parts = renderContent(seg.content)
return ( return (
<div key={`t${i}`} className="feed-content"> <div key={`t${i}`} className="feed-content">
{parts.map((part, j) => <MarkdownContent content={seg.content} raw={false} />
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div> </div>
) )
} }
@@ -506,13 +502,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
)} )}
{cleanContent && ( {cleanContent && (
<div className="feed-content"> <div className="feed-content">
{renderedContent.map((part, i) => <MarkdownContent content={cleanContent} raw={false} />
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" /> <span className="studio-cursor" />
</div> </div>
)} )}
@@ -561,6 +551,8 @@ export default function Studio({ api }) {
const textareaRef = useRef(null) const textareaRef = useRef(null)
const abortRef = useRef(null) const abortRef = useRef(null)
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const ragFileRef = useRef(null)
const [ragStatus, setRagStatus] = useState(null)
useEffect(() => { useEffect(() => {
api.getChatHistory().then(data => { api.getChatHistory().then(data => {
@@ -589,6 +581,10 @@ export default function Studio({ api }) {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls]) }, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
api.ragStatus().then(setRagStatus).catch(() => {})
}, [api])
useEffect(() => { useEffect(() => {
const onTab = (e) => { const onTab = (e) => {
if (e.key !== 'Tab') return if (e.key !== 'Tab') return
@@ -671,6 +667,20 @@ export default function Studio({ api }) {
setAttachedImages(prev => prev.filter((_, i) => i !== index)) setAttachedImages(prev => prev.filter((_, i) => i !== index))
}, []) }, [])
const handleRAGFileSelect = useCallback(async (e) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
for (const file of files) {
try {
await api.ragIndexFile(file)
} catch (err) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `RAG: erreur d'indexation de ${file.name}: ${err.message}`, time: new Date().toISOString() }])
}
}
api.ragStatus().then(setRagStatus).catch(() => {})
e.target.value = ''
}, [api])
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (!input.trim() || loading) return if (!input.trim() || loading) return
const text = input.trim() const text = input.trim()
@@ -1054,6 +1064,14 @@ export default function Studio({ api }) {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleImageSelect} onChange={handleImageSelect}
/> />
<input
type="file"
ref={ragFileRef}
accept=".txt,.md,.go,.js,.ts,.py,.java,.rs,.jsx,.tsx,.json,.yaml,.yml,.csv,.html,.css,.sh,.bash,.zsh,.fish"
multiple
style={{ display: 'none' }}
onChange={handleRAGFileSelect}
/>
<button <button
className="studio-attach-btn" className="studio-attach-btn"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
@@ -1064,6 +1082,17 @@ export default function Studio({ api }) {
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg> </svg>
</button> </button>
<button
className="studio-attach-btn"
onClick={() => ragFileRef.current?.click()}
disabled={loading}
title={ragStatus ? `RAG: ${ragStatus.documents || 0} docs, ${ragStatus.chunks || 0} chunks` : 'Ajouter un contexte RAG'}
style={ragStatus && ragStatus.documents > 0 ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</button>
<button <button
className="studio-attach-btn" className="studio-attach-btn"
onClick={() => { onClick={() => {
@@ -1079,6 +1108,21 @@ export default function Studio({ api }) {
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/> <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg> </svg>
</button> </button>
<button
className="studio-attach-btn"
onClick={() => {
const next = !showRawMarkdown
setShowRawMarkdown(next)
try { localStorage.setItem('muyue.showRawMarkdown', String(next)) } catch {}
}}
disabled={loading}
title={showRawMarkdown ? "Markdown brut: ON" : "Markdown rendu"}
style={showRawMarkdown ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
</button>
<button <button
className="studio-attach-btn" className="studio-attach-btn"
onClick={() => { onClick={() => {

View File

@@ -1313,6 +1313,90 @@ input::placeholder { color: var(--text-disabled); }
color: var(--accent-muted) !important; color: var(--accent-muted) !important;
} }
.config-ai-tools-grid {
/* ── KaTeX overrides ── */
.katex { font-size: 1em; color: var(--text-primary); }
.katex-display { margin: 12px 0; overflow-x: auto; }
/* ── Raw Markdown Toggle ── */
.studio-raw-markdown {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
padding: 12px 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
/* ── ReactMarkdown prose styles ── */
.feed-content > div:not(.studio-code-block):not(.studio-mermaid-container) {
line-height: 1.6;
}
.feed-content h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; }
.feed-content h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; }
.feed-content h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; }
.feed-content h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; }
.feed-content h5 { font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; }
.feed-content h6 { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; text-transform: uppercase; }
.feed-content p { margin: 4px 0; }
.feed-content ul { padding-left: 20px; margin: 4px 0; }
.feed-content ol { padding-left: 20px; margin: 4px 0; }
.feed-content li { margin: 2px 0; }
.feed-content blockquote {
border-left: 3px solid var(--accent-dim);
padding: 4px 12px;
margin: 8px 0;
color: var(--text-tertiary);
background: var(--bg-surface);
border-radius: 0 var(--radius) var(--radius) 0;
}
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.feed-content strong { color: var(--accent-light); font-weight: 700; }
.feed-content em { color: var(--text-secondary); }
.feed-content a { color: var(--accent); text-decoration: underline; }
.feed-content img { max-width: 100%; border-radius: var(--radius); }
.feed-content input[type="checkbox"] {
margin-right: 6px;
accent-color: var(--accent);
}
.feed-content del { color: var(--text-disabled); text-decoration: line-through; }
.feed-content sup { font-size: 0.75em; color: var(--text-tertiary); vertical-align: super; }
/* ── highlight.js overrides for dark theme ── */
.hljs { background: var(--bg) !important; color: var(--text-primary) !important; }
.hljs-keyword { color: var(--accent-muted) !important; }
.hljs-string { color: var(--success) !important; }
.hljs-comment { color: var(--text-disabled) !important; font-style: italic; }
.hljs-function { color: var(--accent-light) !important; }
.hljs-number { color: var(--warning) !important; }
/* ── Responsive / Mobile ── */
@media (max-width: 768px) {
:root { --sidebar-w: 100%; --header-h: 46px; }
.header { padding: 0 12px; gap: 8px; }
.header-nav { margin-left: 12px; gap: 2px; }
.nav-tab { padding: 6px 10px; font-size: 12px; }
.header-brand { gap: 6px; }
.header-logo { font-size: 15px; letter-spacing: 2px; }
.studio-feed { padding: 12px 8px; }
.studio-input-area { padding: 8px 8px 4px; }
.feed-item { padding: 6px 8px; }
.feed-avatar { width: 24px; height: 24px; }
.dash-grid { grid-template-columns: 1fr; grid-template-rows: auto; height: auto; overflow: auto; }
.dash-span-2 { grid-column: span 1; }
.grid-2 { grid-template-columns: 1fr; }
.split-horizontal { flex-direction: column; }
.split-right { width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 300px; }
.shell-ai-col { width: 100%; max-width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 50vh; }
.config-card-row { flex-wrap: wrap; gap: 8px; }
.config-card-label { width: 100%; }
}
.config-ai-tools-grid { .config-ai-tools-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -1360,3 +1444,228 @@ input::placeholder { color: var(--text-disabled); }
margin-bottom: 10px; margin-bottom: 10px;
flex: 1; flex: 1;
} }
/* === Split Panes === */
.shell-split-btn {
background: transparent;
border: 1px solid var(--border);
padding: 4px 8px;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
transition: all 0.15s;
}
.shell-split-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-dim);
color: var(--accent);
}
.shell-agent-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-dim);
color: var(--accent);
font-size: 11px;
font-weight: 600;
animation: agent-pulse 2s ease-in-out infinite;
}
.shell-agent-count {
min-width: 12px;
text-align: center;
}
@keyframes agent-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.shell-xterm-wrapper.has-splits {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.split-pane-split {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
}
.split-pane-split.row {
flex-direction: row;
}
.split-pane-split.column {
flex-direction: column;
}
.split-pane-child {
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.split-pane-resizer {
flex-shrink: 0;
background: var(--border);
transition: background 0.15s;
z-index: 10;
}
.split-pane-split.row > .split-pane-resizer {
width: 4px;
cursor: col-resize;
}
.split-pane-split.column > .split-pane-resizer {
height: 4px;
cursor: row-resize;
}
.split-pane-resizer:hover,
.split-pane-resizer:active {
background: var(--accent);
}
.split-pane-leaf {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
border: 1px solid transparent;
}
.split-pane-leaf.active {
border-color: var(--accent-dim);
}
.split-pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 8px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.split-pane-title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.split-pane-close {
background: transparent;
border: none;
color: var(--text-disabled);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
font-size: 10px;
}
.split-pane-close:hover {
color: var(--error);
}
.split-pane-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.split-pane-leaf.empty {
display: flex;
align-items: center;
justify-content: center;
}
.split-pane-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--text-disabled);
}
/* === File Editor === */
.file-editor-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.file-editor-title {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 6px;
}
.file-editor-dirty {
color: var(--accent);
font-size: 14px;
}
.file-editor-actions {
display: flex;
align-items: center;
gap: 6px;
}
.file-editor-lang-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: var(--bg-card);
color: var(--text-tertiary);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.file-editor-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.file-editor-body .cm-editor {
height: 100%;
}

View File

@@ -3,9 +3,13 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
css: {
transformer: 'postcss',
},
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, emptyOutDir: true,
cssMinify: false,
}, },
server: { server: {
port: 5173, port: 5173,