Compare commits

...

8 Commits

Author SHA1 Message Date
CI Bot
346c464ed5 chore: update CHANGELOG for v0.9.1 2026-04-28 09:50:30 +00:00
Augustin
3445726b67 fix(ui): remove Tests tab, remove raw-md/collapse toggles, add Copy MD button, bump v0.9.1
All checks were successful
Stable Release / stable (push) Successful in 1m46s
- Remove Tests tab from navigation (browsertest still works via snippet/extension)
- Remove showRawMarkdown and collapseHistory toggles from Studio input bar
- Add "Copy MD" button on each assistant message header to copy raw markdown
- Bump version to 0.9.1

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-28 11:48:37 +02:00
CI Bot
5875dab17f chore: update CHANGELOG for v0.9.0 2026-04-27 19:07:38 +00:00
Augustin
4523bbd42c feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Stable Release / stable (push) Successful in 1m34s
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>
2026-04-27 21:04:11 +02:00
CI Bot
62c20eb174 chore: update CHANGELOG for v0.9.0 2026-04-27 16:51:31 +00:00
Augustin
31c99e7479 Merge develop into main (v0.9.0)
All checks were successful
Stable Release / stable (push) Successful in 1m17s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 18:48:35 +02:00
Augustin
f4af63afec feat(extension): Chrome/Edge only + side panel chat tabs (v0.9.0)
All checks were successful
Beta Release / beta (push) Successful in 1m25s
- Remove Firefox build support (CI, Makefile, wxt config)
- Fix chrome.alarms undefined error (add 'alarms' permission)
- Add Chat tab to side panel connected to Studio API (/api/chat)
- Streaming SSE, tool calls, code blocks, thinking display
- Shared chat history with desktop Studio
- New lib/api.js client for extension chat endpoints

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 18:48:04 +02:00
Augustin
31b1de1b0d fix(extension): Firefox corrupt zip + duplicate uploads in CI
All checks were successful
Beta Release / beta (push) Successful in 1m23s
- Remove 'sidePanel' permission from Firefox build (Chrome-only MV3)
- Fix CI upload loop matching extension zips twice via dist/*.zip

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 18:25:13 +02:00
60 changed files with 11945 additions and 304 deletions

View File

@@ -40,14 +40,6 @@ jobs:
restore-keys: |
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download Go dependencies
run: go mod download
@@ -62,7 +54,6 @@ jobs:
cd extension
npm ci
npx wxt zip
npx wxt zip --browser firefox
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/

View File

@@ -40,14 +40,6 @@ jobs:
restore-keys: |
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download dependencies
run: go mod download
@@ -62,7 +54,6 @@ jobs:
cd extension
npm ci
npx wxt zip
npx wxt zip --browser firefox
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/

View File

@@ -4,6 +4,158 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.9.1
### Changes since v0.9.0
- fix(ui): remove Tests tab, remove raw-md/collapse toggles, add Copy MD button, bump v0.9.1 (3445726)
- chore: update CHANGELOG for v0.9.0 (5875dab)
- feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul (4523bbd)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.1/muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
& "$dest\muyue-windows-amd64.exe" install-shortcuts
$env:Path += ";$dest"
```
Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le `.exe` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire.
## v0.9.0
### Changes since v0.9.0
- feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul (4523bbd)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
& "$dest\muyue-windows-amd64.exe" install-shortcuts
$env:Path += ";$dest"
```
Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le `.exe` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire.
## v0.9.0
### Changes since v0.8.0
- feat(extension): Chrome/Edge only + side panel chat tabs (v0.9.0) (f4af63a)
- chore: update CHANGELOG for v0.8.0 (b5e5b30)
- fix(extension): Firefox corrupt zip + duplicate uploads in CI (872e8bf)
- fix(extension): Firefox corrupt zip + duplicate uploads in CI (31b1de1)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer + commande `muyue` dans la session courante :
```powershell
Get-Process muyue, muyue-windows-amd64 -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep -Milliseconds 500
$dest = "$env:LOCALAPPDATA\Muyue"; New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.9.0/muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
& "$dest\muyue-windows-amd64.exe" install-shortcuts
$env:Path += ";$dest"
```
Le 1ʳᵉ ligne tue toute instance Muyue déjà lancée (sinon Windows refuse d'écraser le `.exe` verrouillé et l'install échoue silencieusement). Si vous mettez à jour depuis une version précédente, c'est obligatoire.
## v0.8.0
### Changes since v0.8.0

View File

@@ -9,7 +9,7 @@ WEB_DIR = web
EXT_DIR = extension
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-chrome ext-firefox ext-zip
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-zip
frontend:
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
@@ -66,16 +66,10 @@ build-all: frontend
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
ext:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build && $(NPM) run build:firefox
ext-chrome:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build
ext-firefox:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build:firefox
ext-zip:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip && $(NPM) run zip:firefox
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip
deps:
$(GO) mod tidy

View File

@@ -1,14 +1,12 @@
{
"name": "muyue-extension",
"version": "0.8.0",
"version": "0.9.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wxt",
"build": "wxt build",
"build:firefox": "wxt build --browser firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip --browser firefox"
"zip": "wxt zip"
},
"dependencies": {
"wxt": "^0.20"

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=320" />
</head>
<body>
<div class="panel">
<div class="popup">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue</h1>
@@ -33,7 +33,7 @@
Open Dashboard
</a>
<button id="btn-sidepanel" class="btn">
Open Side Panel
Open Chat Panel
</button>
</div>
@@ -46,7 +46,7 @@
</div>
<div class="footer">
<span>Muyue</span> browser extension v0.1.0
<span>Muyue</span> extension v0.9.0
</div>
</div>

View File

@@ -2,50 +2,85 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=100%" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div class="panel">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue Side Panel</h1>
<h1>Muyue</h1>
</header>
<div class="status-card">
<div class="status-row">
<span class="status-label">Server</span>
<span class="status-value" id="server-status">
<span class="dot dot-yellow"></span>Checking…
</span>
</div>
<div class="status-row">
<span class="status-label">Active sessions</span>
<span class="status-value" id="session-count"></span>
</div>
<div class="status-row">
<span class="status-label">Console errors</span>
<span class="status-value" id="error-count">0</span>
</div>
</div>
<nav class="tabs">
<button class="tab active" data-tab="config">Configuration</button>
<button class="tab" data-tab="chat">Chat</button>
</nav>
<div id="sessions-list"></div>
<div class="actions">
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
Open Dashboard
</a>
</div>
<div class="settings-section">
<label>Server URL</label>
<div class="input-row">
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
<button id="btn-save-url">Save</button>
<section id="tab-config" class="tab-content active">
<div class="status-card">
<div class="status-row">
<span class="status-label">Server</span>
<span class="status-value" id="server-status">
<span class="dot dot-yellow"></span>Checking…
</span>
</div>
<div class="status-row">
<span class="status-label">Active sessions</span>
<span class="status-value" id="session-count"></span>
</div>
<div class="status-row">
<span class="status-label">Console errors</span>
<span class="status-value" id="error-count">0</span>
</div>
</div>
</div>
<div id="sessions-list"></div>
<div class="actions">
<a id="btn-dashboard" href="#" class="btn btn-primary" target="_blank">
Open Dashboard
</a>
</div>
<div class="settings-section">
<label>Server URL</label>
<div class="input-row">
<input type="text" id="server-url" placeholder="http://127.0.0.1:8080" />
<button id="btn-save-url">Save</button>
</div>
</div>
</section>
<section id="tab-chat" class="tab-content">
<div id="chat-offline" class="chat-offline">
<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"/>
</svg>
<span>Server offline</span>
</div>
<div id="chat-area" class="studio-feed-layout" style="display:none">
<div id="chat-feed" class="studio-feed"></div>
<div class="studio-input-area">
<div class="studio-input-row">
<textarea id="chat-input" placeholder="Envoyer un message…" rows="1"></textarea>
<button id="chat-send" class="studio-send-btn">
<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"/>
</svg>
</button>
<button id="chat-stop" class="studio-stop-btn" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
</div>
<div class="studio-input-hint">/clear /help</div>
</div>
</div>
</section>
<div class="footer">
<span>Muyue</span> browser extension v0.1.0
<span>Muyue</span> extension v0.9.0
</div>
</div>

View File

@@ -1,13 +1,30 @@
import '../../styles/panel.css';
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
import { getServerUrl, setServerUrl, fetchSessions, checkServerHealth } from '../../lib/config';
import { getChatHistory, sendChat, clearChat } from '../../lib/api';
const $serverStatus = document.getElementById('server-status');
const $sessionCount = document.getElementById('session-count');
const $errorCount = document.getElementById('error-count');
const $sessionsList = document.getElementById('sessions-list');
const $btnDashboard = document.getElementById('btn-dashboard');
const $serverUrl = document.getElementById('server-url');
const $btnSaveUrl = document.getElementById('btn-save-url');
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
const $serverStatus = $('#server-status');
const $sessionCount = $('#session-count');
const $errorCount = $('#error-count');
const $sessionsList = $('#sessions-list');
const $btnDashboard = $('#btn-dashboard');
const $serverUrl = $('#server-url');
const $btnSaveUrl = $('#btn-save-url');
const $chatOffline = $('#chat-offline');
const $chatArea = $('#chat-area');
const $chatFeed = $('#chat-feed');
const $chatStreaming = $('#chat-streaming');
const $chatInput = $('#chat-input');
const $chatSend = $('#chat-send');
const $chatStop = $('#chat-stop');
let serverOnline = false;
let messages = [];
let loading = false;
let abortController = null;
let currentStreamingEl = null;
function dot(color) {
return `<span class="dot dot-${color}"></span>`;
@@ -18,7 +35,6 @@ function renderSessions(sessions) {
$sessionsList.innerHTML = '';
return;
}
$sessionsList.innerHTML = `
<div class="status-card" style="margin-top:12px">
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
@@ -38,28 +54,360 @@ function renderSessions(sessions) {
`;
}
async function refresh() {
const url = await getServerUrl();
$serverUrl.value = url;
$btnDashboard.href = url;
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="chat-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="chat-step"><span class="chat-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>');
html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="chat-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1');
return html;
}
try {
const sessions = await fetchSessions();
$serverStatus.innerHTML = `${dot('green')} Online`;
$sessionCount.textContent = sessions.length;
renderSessions(sessions);
} catch {
$serverStatus.innerHTML = `${dot('red')} Offline`;
$sessionCount.textContent = '—';
$sessionsList.innerHTML = '';
function renderContent(text) {
const parts = [];
const codeBlockRegex = /(```[\s\S]*?```)/g;
let match;
let lastIndex = 0;
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
}
const full = match[1];
const firstNewline = full.indexOf('\n');
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : '';
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3);
parts.push({ type: 'code', lang, content: code });
lastIndex = match.index + full.length;
}
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) });
}
return parts;
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function createMessageEl(msg) {
const el = document.createElement('div');
el.className = `chat-msg ${msg.role}`;
if (msg.role === 'system') {
el.innerHTML = `<div class="chat-system-dot"></div><div class="chat-system-text">${escapeHtml(msg.content)}</div>`;
return el;
}
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
if (chrome.runtime.lastError || !state) return;
$errorCount.textContent = state.errorCount || 0;
const isUser = msg.role === 'user';
const avatar = isUser ? '★' : '◆';
const label = isUser ? 'CDT' : 'GEN';
let displayContent = msg.content;
let parsedToolCalls = null;
let parsedSegments = null;
try {
const parsed = JSON.parse(msg.content);
if (parsed && Array.isArray(parsed.segments)) {
parsedSegments = parsed.segments;
displayContent = parsed.content || '';
} else if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls;
displayContent = parsed.content || '';
}
} catch {}
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
let bodyHtml = '';
if (parsedSegments) {
bodyHtml = parsedSegments.map((seg) => {
if (seg.type === 'text' && seg.content) {
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
if (!c) return '';
return renderContent(c).map((p) => {
if (p.type === 'code') {
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
}
return `<span>${formatText(p.content)}</span>`;
}).join('');
}
if (seg.type === 'tool') {
const name = seg.call?.name || 'tool';
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', list_files: '📁', search_files: '🔍', web_fetch: '🌐' }[name] || '🔧';
const done = seg.result;
const isErr = done && done.is_error;
const preview = (() => {
try {
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
return args.command || args.task || args.path || args.url || JSON.stringify(args).slice(0, 60);
} catch { return ''; }
})();
const resultText = done ? (done.content || '').slice(0, 500) : '';
return `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}${resultText ? `<pre class="chat-tool-result">${escapeHtml(resultText)}</pre>` : ''}</div>`;
}
return '';
}).join('');
} else {
if (cleanContent) {
bodyHtml = renderContent(cleanContent).map((p) => {
if (p.type === 'code') {
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
}
return `<span>${formatText(p.content)}</span>`;
}).join('');
}
if (parsedToolCalls && parsedToolCalls.length > 0) {
bodyHtml = parsedToolCalls.map((tc) => {
const name = tc.name || 'tool';
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
return `<div class="chat-tool done"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span><span class="chat-tool-status ok">✓</span></div></div>`;
}).join('') + bodyHtml;
}
}
if (!bodyHtml) bodyHtml = '<span class="chat-dots"><span></span><span></span><span></span></span>';
el.innerHTML = `
<div class="chat-avatar ${isUser ? 'user' : 'ai'}">${avatar}</div>
<div class="chat-body">
<div class="chat-header"><span class="chat-badge" style="color:${isUser ? '#FFD740' : '#FF9100'};border-color:${isUser ? '#FFD740' : '#FF9100'}">${label}</span></div>
<div class="chat-content">${bodyHtml}</div>
</div>
`;
return el;
}
function renderMessages() {
$chatFeed.innerHTML = '';
messages.forEach((msg) => {
$chatFeed.appendChild(createMessageEl(msg));
});
scrollToBottom();
}
function scrollToBottom() {
requestAnimationFrame(() => {
$chatFeed.scrollTop = $chatFeed.scrollHeight;
});
}
function switchTab(tabName) {
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tabName));
$$('.tab-content').forEach((s) => s.classList.toggle('active', s.id === `tab-${tabName}`));
}
function updateChatVisibility() {
if (serverOnline) {
$chatOffline.style.display = 'none';
$chatArea.style.display = 'flex';
} else {
$chatOffline.style.display = 'flex';
$chatArea.style.display = 'none';
}
}
async function loadChatHistory() {
try {
const data = await getChatHistory();
if (data.messages && data.messages.length > 0) {
messages = data.messages;
} else {
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
}
renderMessages();
} catch {
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
renderMessages();
}
}
async function handleSend() {
const text = $chatInput.value.trim();
if (!text || loading) return;
if (text === '/clear') {
try { await clearChat(); } catch {}
messages = [{ id: 'clear-' + Date.now(), role: 'system', content: 'Conversation cleared.' }];
renderMessages();
$chatInput.value = '';
return;
}
$chatInput.value = '';
$chatInput.style.height = 'auto';
const userMsg = { id: Date.now().toString(), role: 'user', content: text };
messages.push(userMsg);
$chatFeed.appendChild(createMessageEl(userMsg));
scrollToBottom();
loading = true;
$chatSend.style.display = 'none';
$chatStop.style.display = 'flex';
const controller = new AbortController();
abortController = controller;
let segments = [];
let thinking = '';
let textStartIdx = 0;
let streamText = '';
const updateLastText = (text) => {
if (!text) return;
const last = segments.length > 0 ? segments[segments.length - 1] : null;
if (last && last.type === 'text') {
last.content = text;
} else {
segments.push({ type: 'text', content: text });
}
};
currentStreamingEl = document.createElement('div');
currentStreamingEl.className = 'chat-msg assistant streaming';
$chatFeed.appendChild(currentStreamingEl);
scrollToBottom();
try {
const finalContent = await sendChat(text, true, (partial, event) => {
if (event && (event.thinking !== undefined || event.thinking_start || event.thinking_end)) {
if (event.thinking !== undefined) thinking += event.thinking;
return;
}
if (event && event.tool_call) {
updateLastText(partial.slice(textStartIdx));
textStartIdx = partial.length;
segments.push({ type: 'tool', call: event.tool_call, result: null });
} else if (event && event.tool_result) {
const segIdx = segments.findIndex((s) => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id);
if (segIdx >= 0) segments[segIdx].result = event.tool_result;
} else {
updateLastText(partial.slice(textStartIdx));
}
streamText = partial;
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
const toolSegs = segments.filter((s) => s.type === 'tool');
let html = '';
if (thinking) {
html += `<div class="chat-thinking"><span class="chat-thinking-icon">⏱</span> Thinking…</div>`;
}
segments.forEach((seg) => {
if (seg.type === 'text' && seg.content) {
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
if (c) html += `<div class="chat-content">${formatText(c)}</div>`;
}
if (seg.type === 'tool') {
const name = seg.call?.name || 'tool';
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
const done = seg.result;
const isErr = done && done.is_error;
const preview = (() => {
try {
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
return args.command || args.task || args.path || JSON.stringify(args).slice(0, 60);
} catch { return ''; }
})();
html += `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}</div>`;
}
});
if (!html) {
html = '<span class="chat-dots"><span></span><span></span><span></span></span>';
}
currentStreamingEl.innerHTML = `
<div class="chat-avatar ai">◆</div>
<div class="chat-body">
<div class="chat-header"><span class="chat-badge" style="color:#FF9100;border-color:#FF9100">GEN</span></div>
${html}
<span class="chat-cursor"></span>
</div>
`;
scrollToBottom();
}, controller.signal);
if (currentStreamingEl && currentStreamingEl.parentNode) {
currentStreamingEl.remove();
}
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
const toolSegs = segments.filter((s) => s.type === 'tool');
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: toolSegs.length > 0 ? JSON.stringify({
segments: segments.map((s) => s.type === 'text'
? { type: 'text', content: s.content }
: { type: 'tool', call: s.call, result: s.result ? { content: s.result.content || '', is_error: s.result.is_error || false, tool_call_id: s.call?.tool_call_id } : null }),
content: allText,
}) : (allText || finalContent),
};
messages.push(aiMsg);
$chatFeed.appendChild(createMessageEl(aiMsg));
scrollToBottom();
} catch (err) {
if (currentStreamingEl && currentStreamingEl.parentNode) {
currentStreamingEl.remove();
}
if (err.name !== 'AbortError') {
const errMsg = { id: (Date.now() + 1).toString(), role: 'system', content: `Error: ${err.message}` };
messages.push(errMsg);
$chatFeed.appendChild(createMessageEl(errMsg));
scrollToBottom();
}
} finally {
loading = false;
abortController = null;
currentStreamingEl = null;
$chatSend.style.display = 'flex';
$chatStop.style.display = 'none';
}
}
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
$chatInput.addEventListener('input', () => {
$chatInput.style.height = 'auto';
$chatInput.style.height = Math.min($chatInput.scrollHeight, 100) + 'px';
});
$chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
});
$chatSend.addEventListener('click', handleSend);
$chatStop.addEventListener('click', () => {
if (abortController) abortController.abort();
});
$chatFeed.addEventListener('click', (e) => {
const btn = e.target.closest('.chat-copy-btn');
if (btn) {
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.code));
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 1200);
}
});
$btnSaveUrl.addEventListener('click', async () => {
const url = $serverUrl.value.trim().replace(/\/$/, '');
if (url) {
@@ -68,5 +416,32 @@ $btnSaveUrl.addEventListener('click', async () => {
}
});
async function refresh() {
const url = await getServerUrl();
$serverUrl.value = url;
$btnDashboard.href = url;
try {
const sessions = await fetchSessions();
serverOnline = true;
$serverStatus.innerHTML = `${dot('green')} Online`;
$sessionCount.textContent = sessions.length;
renderSessions(sessions);
} catch {
serverOnline = false;
$serverStatus.innerHTML = `${dot('red')} Offline`;
$sessionCount.textContent = '—';
$sessionsList.innerHTML = '';
}
updateChatVisibility();
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
if (chrome.runtime.lastError || !state) return;
$errorCount.textContent = state.errorCount || 0;
});
}
refresh();
setInterval(refresh, 5000);
loadChatHistory();
setInterval(refresh, 10000);

77
extension/src/lib/api.js Normal file
View File

@@ -0,0 +1,77 @@
import { getServerUrl } from './config';
async function request(path, options = {}) {
const base = await getServerUrl();
const res = await fetch(`${base}/api${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export async function getChatHistory() {
return request('/chat/history');
}
export async function clearChat() {
return request('/chat/clear', { method: 'POST' });
}
export async function summarizeChat() {
return request('/chat/summarize', { method: 'POST' });
}
export async function sendChat(message, stream = true, onChunk, signal) {
const base = await getServerUrl();
if (!stream) {
return request('/chat', {
method: 'POST',
body: JSON.stringify({ message, stream: false }),
});
}
return new Promise((resolve, reject) => {
fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }),
signal,
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
reject(new Error(err.error || res.statusText));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.error) { reject(new Error(data.error)); return; }
if (data.done) { resolve(full); return; }
if (data.content) {
full += data.content;
if (onChunk) onChunk(full, data);
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data);
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data);
}
} catch {}
}
}
resolve(full);
}).catch(reject);
});
}

View File

@@ -1,40 +1,75 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--border: rgba(255, 255, 255, 0.1);
--text-primary: #e8e8f0;
--text-secondary: #9999aa;
--accent: #ff4757;
--accent-dim: rgba(255, 71, 87, 0.15);
--accent-glow: rgba(255, 71, 87, 0.4);
--green: #3aaa61;
--yellow: #f5a623;
--red: #ff6b6b;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--bg: #0A0A0C;
--bg-base: #0F0D10;
--bg-surface: #161218;
--bg-elevated: #1C1719;
--bg-card: #221B1E;
--bg-input: #2A2225;
--bg-hover: #332528;
--accent: #FF0033;
--accent-dark: #8B0020;
--accent-deep: #5C0015;
--accent-light: #FF1A5E;
--accent-muted: #FF4D6D;
--accent-bright: #FF1744;
--accent-soft: #FF5252;
--accent-dim: #6B2033;
--accent-bg: #4A1525;
--text-primary: #EAE0E2;
--text-secondary: #D4C4C8;
--text-tertiary: #8A7A7E;
--text-disabled: #5A4F52;
--success: #00E676;
--warning: #FFD740;
--error: #FF1744;
--info: #448AFF;
--border: #2A1F22;
--border-accent: #FF003344;
--border-accent-full: #FF0033;
--radius-sm: 6px;
--radius: 8px;
--radius-lg: 12px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace;
--green: #00E676;
--yellow: #FFD740;
--red: #FF1744;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
line-height: 1.4;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.panel {
::selection { background: var(--accent); color: #fff; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--accent-dim); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
a { color: var(--accent); text-decoration: none; cursor: pointer; }
/* ── Popup (icon click) ── */
.popup {
width: 320px;
padding: 16px;
}
header {
.popup header {
display: flex;
align-items: center;
gap: 10px;
@@ -43,21 +78,81 @@ header {
border-bottom: 1px solid var(--border);
}
header img {
width: 28px;
height: 28px;
.popup .footer {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-tertiary);
font-size: 10px;
}
header h1 {
font-size: 16px;
/* ── Panel (side panel) ── */
.panel {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.panel > header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
header img { width: 28px; height: 28px; }
header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.3px; color: var(--text-primary); }
/* ── Tabs ── */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
padding: 0 8px;
}
.tab {
flex: 1;
padding: 10px 8px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 600;
letter-spacing: -0.3px;
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-sans);
}
.tab:hover { color: var(--text-primary); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content {
display: none;
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
/* ── Config tab ── */
.status-card {
background: var(--bg-secondary);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
padding: 12px;
margin-bottom: 12px;
}
@@ -75,15 +170,8 @@ header h1 {
padding-top: 10px;
}
.status-label {
color: var(--text-secondary);
font-size: 12px;
}
.status-value {
font-weight: 500;
font-size: 12px;
}
.status-label { color: var(--text-tertiary); font-size: 12px; }
.status-value { font-weight: 500; font-size: 12px; color: var(--text-primary); }
.dot {
display: inline-block;
@@ -93,16 +181,11 @@ header h1 {
margin-right: 6px;
}
.dot-green { background: var(--green); box-shadow: 0 0 6px var(--green); }
.dot-red { background: var(--red); box-shadow: 0 0 6px var(--red); }
.dot-yellow { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
.dot-green { background: var(--success); box-shadow: 0 0 6px var(--success); }
.dot-red { background: var(--error); box-shadow: 0 0 6px var(--error); }
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
.actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.btn {
display: flex;
@@ -110,21 +193,19 @@ header h1 {
justify-content: center;
gap: 6px;
padding: 9px 14px;
border-radius: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
background: var(--bg-card);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
font-family: var(--font-sans);
}
.btn:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.btn:hover { background: var(--accent-bg); border-color: var(--accent-dark); color: var(--text-primary); }
.btn-primary {
background: var(--accent);
@@ -133,8 +214,9 @@ header h1 {
}
.btn-primary:hover {
background: #e8414f;
box-shadow: 0 0 12px var(--accent-glow);
background: var(--accent-bright);
border-color: var(--accent-bright);
box-shadow: 0 0 12px var(--border-accent);
}
.settings-section {
@@ -145,67 +227,480 @@ header h1 {
.settings-section label {
display: block;
color: var(--text-secondary);
color: var(--text-tertiary);
font-size: 11px;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-row {
display: flex;
gap: 6px;
}
.input-row { display: flex; gap: 6px; }
.input-row input {
flex: 1;
background: var(--bg-secondary);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 6px 8px;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono);
outline: none;
transition: border-color 0.2s;
}
.input-row input:focus {
border-color: var(--accent);
}
.input-row input:focus { border-color: var(--accent); }
.input-row button {
padding: 6px 10px;
border-radius: 4px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
font-size: 11px;
font-family: var(--font-sans);
}
.input-row button:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.input-row button:hover { background: var(--accent-bg); border-color: var(--accent-dark); }
.footer {
margin-top: 12px;
padding-top: 10px;
margin-top: auto;
padding: 10px 16px;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-secondary);
color: var(--text-tertiary);
font-size: 10px;
flex-shrink: 0;
}
.footer span {
.footer span { color: var(--accent); }
/* ── Chat offline ── */
.chat-offline {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 13px;
}
/* ── Studio Feed (same classes as Studio.jsx) ── */
.studio-feed-layout {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
flex: 1;
}
.studio-feed {
flex: 1;
overflow-y: auto;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.feed-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 60px 0;
}
.feed-item {
display: flex;
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius);
animation: fadeIn 0.15s ease-out;
}
.feed-item:hover { background: var(--bg-card); }
.feed-item.user {
background: var(--bg-card);
border-left: 3px solid #FFD740;
}
.feed-item.assistant {
border-left: 3px solid transparent;
}
.feed-item.assistant:hover {
border-left-color: var(--accent-dark);
}
.feed-item.system {
align-items: center;
gap: 8px;
padding: 6px 12px;
}
.feed-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
font-size: 14px;
}
.feed-avatar.user-rank {
background: rgba(255, 215, 64, 0.15);
}
.feed-avatar.ai-rank {
background: var(--accent-bg);
}
.feed-body {
flex: 1;
min-width: 0;
}
.feed-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.feed-rank-badge {
font-size: 9px;
font-weight: 800;
font-family: var(--font-mono);
padding: 1px 6px;
border-radius: 3px;
border: 1px solid;
letter-spacing: 0.5px;
text-transform: uppercase;
background: rgba(255, 215, 64, 0.08);
}
.feed-role {
font-size: 11px;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
.feed-time {
font-size: 10px;
color: var(--text-disabled);
font-family: var(--font-mono);
}
.loading {
animation: pulse 1.5s ease-in-out infinite;
.feed-content {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
word-break: break-word;
}
.feed-system-badge {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-dim);
flex-shrink: 0;
}
.feed-system-text {
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
flex: 1;
}
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
/* ── Studio Code Blocks ── */
.studio-code-block {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin: 8px 0;
}
.studio-code-header {
display: flex;
align-items: center;
justify-content: flex-end;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.studio-code-block pre {
padding: 12px 16px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
color: var(--text-primary);
margin: 0;
}
.studio-code-lang {
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
background: var(--bg-surface);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.studio-copy-btn {
padding: 3px 10px;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
background: transparent;
border: none;
border-left: 1px solid var(--border);
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-sans);
white-space: nowrap;
}
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
/* ── Studio Thinking ── */
.feed-thinking-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 2px solid var(--accent-dim);
border-radius: var(--radius);
margin: 6px 0 8px;
overflow: hidden;
max-height: 200px;
overflow-y: auto;
}
.feed-thinking-block.done { border-left-color: var(--text-disabled); opacity: 0.7; }
.feed-thinking-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 10px;
font-weight: 700;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.feed-thinking-header svg { color: var(--warning); }
.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; }
.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; }
.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; }
.feed-thinking-content {
padding: 8px 10px;
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
line-height: 1.5;
max-height: 80px;
overflow-y: auto;
}
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 3px solid var(--accent-dim);
border-radius: var(--radius);
margin: 6px 0;
overflow: hidden;
transition: all 0.3s ease;
}
.studio-tool-block.running { border-left-color: var(--warning); }
.studio-tool-block.error { border-left-color: var(--error); background: rgba(255, 23, 68, 0.05); }
.studio-tool-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.studio-tool-icon { font-size: 14px; flex-shrink: 0; }
.studio-tool-name {
color: var(--text-tertiary);
font-weight: 600;
font-family: var(--font-mono);
font-size: 12px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.studio-tool-spinner { display: inline-flex; gap: 2px; margin-left: 4px; }
.studio-tool-spinner span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; }
.studio-tool-spinner span:nth-child(2) { animation-delay: 0.15s; }
.studio-tool-spinner span:nth-child(3) { animation-delay: 0.3s; }
.studio-tool-status { font-weight: 700; font-size: 14px; flex-shrink: 0; }
.studio-tool-status.ok { color: var(--success); }
.studio-tool-status.error { color: var(--error); }
.studio-tool-args {
padding: 6px 10px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-tertiary);
white-space: pre-wrap;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
.studio-tool-result { max-height: 200px; overflow-y: auto; }
.studio-tool-result pre {
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
}
/* ── Studio Cursor & Thinking Dots ── */
.studio-cursor {
display: inline-block;
width: 8px;
height: 16px;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes blink { 50% { opacity: 0; } }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Studio Input Area ── */
.studio-input-area {
padding: 12px 16px 8px;
border-top: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.studio-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.studio-input-row textarea {
flex: 1;
resize: none;
min-height: 42px;
max-height: 120px;
padding: 10px 14px;
font-size: 14px;
line-height: 1.5;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
font-family: var(--font-sans);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
.studio-send-btn {
width: 42px;
height: 42px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
border: 1px solid var(--accent);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.studio-send-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.studio-stop-btn {
width: 42px;
height: 42px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
background: var(--error);
color: #fff;
border: 1px solid var(--error);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.studio-stop-btn:hover { opacity: 0.8; }
.studio-input-hint {
font-size: 11px;
color: var(--text-disabled);
text-align: center;
margin-top: 6px;
}

View File

@@ -2,19 +2,17 @@ import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
suppressWarnings: {
firefoxDataCollection: true,
},
manifest: ({ browser }) => ({
manifest: {
name: 'Muyue',
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
permissions: [
'storage',
'activeTab',
'tabs',
...(browser === 'chrome' ? ['sidePanel'] : []),
'sidePanel',
'scripting',
'notifications',
'alarms',
],
host_permissions: ['http://127.0.0.1:*/*', 'http://localhost:*/*'],
action: {
@@ -26,5 +24,5 @@ export default defineConfig({
side_panel: {
default_path: 'sidepanel.html',
},
}),
},
});

12
go.mod
View File

@@ -1,8 +1,6 @@
module github.com/muyue/muyue
go 1.24.2
toolchain go1.24.3
go 1.25.0
require (
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/cancelreader v0.2.2 // 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/spf13/pflag v1.0.9 // 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
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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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(NewManageSSHTool()),
must(NewWebFetchTool()),
must(NewDelegateTool(r)),
must(NewDelegateMultiTool(r)),
}
if bt, err := NewBrowserTool(); err == nil {
tools = append(tools, bt)
}
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.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:
// the user is doing AI-driven UI testing, where having a second model
// 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
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -11,6 +12,11 @@ import (
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"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/workflow"
)
@@ -27,9 +33,16 @@ type Server struct {
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
pluginManager *plugins.Manager
hookRegistry *plugins.HookRegistry
browserTestStore *BrowserTestStore
memoryStore *memory.Store
ragStore *rag.Store
pipeline *Pipeline
activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32
mcpServer *mcpserver.MCPServer
agentTracker *AgentSessionTracker
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -76,6 +89,33 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
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.routes()
return s
@@ -108,6 +148,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
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/chat", s.handleChat)
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/console/", s.handleBrowserTestConsole)
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) {
@@ -227,3 +303,16 @@ func (s *Server) initStarship() {
}
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"`
}
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 {
Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"`
@@ -71,6 +81,8 @@ type MuyueConfig struct {
FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"`
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
Lessons LessonsConfig `yaml:"lessons" json:"lessons"`
}
type TerminalTheme struct {
@@ -322,5 +334,11 @@ func Default() *MuyueConfig {
cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
cfg.Plugins.Enabled = []string{}
cfg.Plugins.Paths = []string{}
cfg.Lessons.Enabled = true
cfg.Lessons.Dirs = []string{}
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 {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
Deployed bool `yaml:"-" json:"deployed,omitempty"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,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 {
@@ -516,6 +522,24 @@ func renderSkill(skill *Skill) string {
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(skill.Content)
b.WriteString("\n")

View File

@@ -7,7 +7,7 @@ import (
const (
Name = "muyue"
Version = "0.8.0"
Version = "0.9.1"
Author = "La Légion de Muyue"
)

View File

@@ -2,16 +2,28 @@
<html lang="fr">
<head>
<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" />
<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="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/muyue.png" />
<link rel="shortcut icon" href="/muyue.png" />
<title>Muyue</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</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"
},
"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-image": "^0.10.0-beta.203",
"@xterm/addon-search": "^0.17.0-beta.203",
@@ -15,10 +27,17 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.1.0-beta.203",
"highlight.js": "^11.11.1",
"katex": "^0.16.45",
"lucide-react": "^1.8.0",
"mermaid": "^11.14.0",
"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": {
"@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)
})
},
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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings, TestTube2 } from 'lucide-react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
@@ -7,7 +7,6 @@ import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
import Tests from './Tests'
import OnboardingWizard from './OnboardingWizard'
export default function App() {
@@ -25,7 +24,6 @@ export default function App() {
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'tests', label: 'Tests', icon: <TestTube2 size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t])
@@ -56,8 +54,7 @@ export default function App() {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'tests',
Digit5: 'config',
Digit4: 'config',
}
if (map[e.code]) {
e.preventDefault()
@@ -134,7 +131,6 @@ export default function App() {
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
<div className={activeTab === 'tests' ? '' : 'tab-hidden'}><Tests api={api} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main>

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 { Unicode11Addon } from '@xterm/addon-unicode11'
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 { useI18n } from '../i18n'
import mermaid from 'mermaid'
import FileEditor from './FileEditor'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
const AI_TAB_ID = 0
const MAX_TABS = 7
const MAX_PANES = 4
const SHELL_MAX_TOKENS = 100000
const SHELL_AI_COMMANDS = ['/clear', '/help', '/model', '/model change']
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
const LAYOUT_STORAGE_KEY = 'muyue_shell_layout'
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
function renderContent(text) {
@@ -477,6 +480,107 @@ export default function Shell({ api, isSudo }) {
const _streamRafRef = 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(() => {
_streamRafRef.current = null
const pending = _streamPendingRef.current
@@ -818,6 +922,22 @@ export default function Shell({ api, isSudo }) {
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.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
</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 && (
<div className="shell-new-tab-wrapper">
<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 className="shell-xterm-wrapper">
{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>
<div className={`shell-xterm-wrapper ${splitLayout ? 'has-splits' : ''}`}>
{editingFile && (
<FileEditor api={api} filePath={editingFile} onClose={() => setEditingFile(null)} />
)}
{!editingFile && (
splitLayout ? (
<SplitPaneRenderer
node={splitLayout}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
showSearch={showSearch}
searchText={searchText}
searchInputRef={searchInputRef}
handleSearchChange={handleSearchChange}
handleSearchNext={handleSearchNext}
handleSearchPrev={handleSearchPrev}
handleCloseSearch={handleCloseSearch}
removePane={removePane}
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>
@@ -1775,3 +1941,158 @@ const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, termi
</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 { useI18n } from '../i18n'
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)' })
@@ -270,15 +277,16 @@ function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
)
}
function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
function FeedItem({ msg, activeAgents, onModeChange }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const [copiedIdx, setCopiedIdx] = useState(null)
const [forceExpand, setForceExpand] = useState(false)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
let parsedToolCalls = null
let parsedToolResults = null
let parsedSegments = null
@@ -321,8 +329,21 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
</span>
<span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
{!isUser && !isSystem && (
<button
className="studio-copy-btn"
onClick={() => {
navigator.clipboard.writeText(displayContent)
setCopiedMsg(true)
setTimeout(() => setCopiedMsg(false), 1500)
}}
style={{ marginLeft: 'auto', fontSize: '0.7em', opacity: copiedMsg ? 1 : 0.5, transition: 'opacity 0.15s' }}
>
{copiedMsg ? '✓' : 'Copy MD'}
</button>
)}
</div>
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
{msg.thinking && <ThinkingBlock content={msg.thinking} done raw />}
{msg.images && msg.images.length > 0 && (
<div className="feed-images">
{msg.images.map((imgId, i) => (
@@ -354,18 +375,11 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
if (!c) return null
return (
<div key={`t${i}`} className="feed-content">
{renderContent(c).map((part, j) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
{renderMarkdown(c)}
</div>
)
}
if (seg.type === 'tool') {
if (compress && seg !== lastTool) return null
const r = seg.result
const result = r && (r.content !== undefined || r.is_error !== undefined)
? { content: r.content, is_error: r.is_error }
@@ -408,13 +422,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
})()}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
{renderMarkdown(cleanContent)}
</div>
)}
</>
@@ -424,26 +432,23 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
)
}
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange, collapseHistory }) {
function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, onModeChange }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
const [copiedIdx, setCopiedIdx] = useState(null)
const [forceExpand, setForceExpand] = useState(false)
const renderedContent = useMemo(() => {
if (!cleanContent) return []
return renderContent(cleanContent)
if (!cleanContent) return null
return null
}, [cleanContent])
const formattedThinking = useMemo(() => {
if (!thinking) return ''
return formatText(thinking)
return thinking
}, [thinking])
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
const toolSegments = (segments || []).filter(s => s.type === 'tool')
const compress = collapseHistory && !forceExpand && toolSegments.length > 1
return (
<div className="feed-item assistant">
@@ -457,7 +462,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
</span>
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
{thinking && <ThinkingBlock content={thinking} raw done={false} />}
{hasOrderedSegments ? (
<>
{compress && (
@@ -475,16 +480,9 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
return segments.map((seg, i) => {
if (seg.type === 'text') {
if (!seg.content) return null
const parts = renderContent(seg.content)
return (
<div key={`t${i}`} className="feed-content">
{parts.map((part, j) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<MarkdownContent content={seg.content} raw={false} />
</div>
)
}
@@ -506,13 +504,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
)}
{cleanContent && (
<div className="feed-content">
{renderedContent.map((part, i) =>
part.type === 'code' ? (
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<MarkdownContent content={cleanContent} raw={false} />
<span className="studio-cursor" />
</div>
)}
@@ -551,9 +543,6 @@ export default function Studio({ api }) {
const [advancedReflection, setAdvancedReflection] = useState(() => {
try { return localStorage.getItem('muyue.advancedReflection') === 'true' } catch { return false }
})
const [collapseHistory, setCollapseHistory] = useState(() => {
try { return localStorage.getItem('muyue.collapseHistory') !== 'false' } catch { return true }
})
const MAX_CRUSH_AGENTS = 2
const MAX_CLAUDE_AGENTS = 2
const messagesEnd = useRef(null)
@@ -561,6 +550,8 @@ export default function Studio({ api }) {
const textareaRef = useRef(null)
const abortRef = useRef(null)
const fileInputRef = useRef(null)
const ragFileRef = useRef(null)
const [ragStatus, setRagStatus] = useState(null)
useEffect(() => {
api.getChatHistory().then(data => {
@@ -589,6 +580,10 @@ export default function Studio({ api }) {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
api.ragStatus().then(setRagStatus).catch(() => {})
}, [api])
useEffect(() => {
const onTab = (e) => {
if (e.key !== 'Tab') return
@@ -671,6 +666,20 @@ export default function Studio({ api }) {
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 () => {
if (!input.trim() || loading) return
const text = input.trim()
@@ -949,7 +958,7 @@ export default function Studio({ api }) {
<span className="feed-summary-toggle">{summarizedExpanded ? 'masquer' : 'voir'}</span>
</div>
{summarizedExpanded && summarizedMsgs.map(msg => (
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
))}
</div>
)
@@ -961,7 +970,7 @@ export default function Studio({ api }) {
<>
{renderSummaryBlock()}
{activeMsgs.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -978,7 +987,7 @@ export default function Studio({ api }) {
<>
{renderSummaryBlock()}
{activeMsgs.map(msg => (
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
<FeedItem key={msg.id} msg={msg} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
))}
</>
)
@@ -1002,7 +1011,7 @@ export default function Studio({ api }) {
<div className="studio-feed" ref={feedRef}>
{renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} collapseHistory={collapseHistory} />
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} segments={streamSegments} activeAgents={activeAgents} onModeChange={handleToolModeChange} />
)}
<div ref={messagesEnd} style={{ height: '24px' }} />
</div>
@@ -1054,6 +1063,14 @@ export default function Studio({ api }) {
style={{ display: 'none' }}
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
className="studio-attach-btn"
onClick={() => fileInputRef.current?.click()}
@@ -1064,6 +1081,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"/>
</svg>
</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
className="studio-attach-btn"
onClick={() => {
@@ -1079,21 +1107,6 @@ export default function Studio({ api }) {
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
</button>
<button
className="studio-attach-btn"
onClick={() => {
const next = !collapseHistory
setCollapseHistory(next)
try { localStorage.setItem('muyue.collapseHistory', String(next)) } catch {}
}}
disabled={loading}
title={collapseHistory ? "Historique compressé (dernière action visible)" : "Historique complet (tout visible)"}
style={collapseHistory ? { 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">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<textarea
ref={textareaRef}
value={input}

View File

@@ -1313,6 +1313,90 @@ input::placeholder { color: var(--text-disabled); }
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -1360,3 +1444,228 @@ input::placeholder { color: var(--text-disabled); }
margin-bottom: 10px;
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({
plugins: [react()],
css: {
transformer: 'postcss',
},
build: {
outDir: 'dist',
emptyOutDir: true,
cssMinify: false,
},
server: {
port: 5173,