From 5bbac499a7abbc2cc5d0d49b52224c0b4bac9c4e Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:36:36 +0200 Subject: [PATCH 01/15] feat(config): add system panel with reset and starship theme, add onboarding wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default) - Add OnboardingWizard that activates when profile is empty on first run - Fix tag parsing in Shell AI messages (wait for before rendering) - Add /api/config/reset and /api/starship/apply-theme endpoints - Wire wizard trigger in App.jsx based on profile completeness 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/api/handlers_config.go | 203 +++++++++++++++++++++ web/src/components/App.jsx | 7 + web/src/components/Config.jsx | 73 +++++++- web/src/components/OnboardingWizard.jsx | 224 ++++++++++++++++++++++++ web/src/i18n/en.js | 7 + web/src/i18n/fr.js | 19 +- 6 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 web/src/components/OnboardingWizard.jsx diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index 0232a18..0d7aafd 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -3,8 +3,11 @@ package api import ( "bytes" "encoding/json" + "fmt" "io" "net/http" + "os" + "path/filepath" "strings" "time" @@ -281,3 +284,203 @@ func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) } writeJSON(w, map[string]interface{}{"themes": themes}) } + +func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + dir, err := config.ConfigDir() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + path := filepath.Join(dir, "config.yaml") + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + s.config = config.Default() + if err := config.Save(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Theme string `json:"theme"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Theme == "" { + body.Theme = s.config.Terminal.PromptTheme + } + + cfgDir, err := config.ConfigDir() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + starshipDir := filepath.Join(cfgDir, "starship") + if err := os.MkdirAll(starshipDir, 0755); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + themeFile := filepath.Join(starshipDir, "starship.toml") + + themeContent := getStarshipThemeConfig(body.Theme) + if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + home, _ := os.UserHomeDir() + shellRCs := []string{ + filepath.Join(home, ".bashrc"), + filepath.Join(home, ".zshrc"), + } + for _, rc := range shellRCs { + if _, err := os.Stat(rc); err != nil { + continue + } + content, _ := os.ReadFile(rc) + if strings.Contains(string(content), "STARSHIP_CONFIG") { + continue + } + exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile) + f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + continue + } + f.WriteString(exportLine) + f.Close() + } + + s.config.Terminal.PromptTheme = body.Theme + config.Save(s.config) + + writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile}) +} + +func getStarshipThemeConfig(theme string) string { + switch theme { + case "charm": + return `[format] +before_format = "$" +format = """ +$username$directory$git_branch$git_status$cmd_duration$line_break$character""" + +[character] +success_symbol = "[➜](bold #00E676)" +error_symbol = "[✗](bold #FF0033)" + +[directory] +truncation_length = 3 +truncation_symbol = "
/" +style = "bold #00BCD4" + +[username] +show_on_left = false +style_user = "bold #FF0033" +style_root = "bold #FF0033" + +[git_branch] +symbol = " " +format = "on [$symbol$branch]($style)" +style = "bold #FFD740" + +[git_status] +format = "[$all_status$ahead_behind]($style) " +style = "bold #FF1A5E" +conflicted = "!" +untracked = "?" +modified = "~" +staged = "[+]" +renamed = "»" +deleted = "-" + +[cmd_duration] +min_time = 500 +format = "took [$duration]($style)" +style = "bold #75715E" +` + case "zerotwo": + return `[format] +before_format = "$" +format = """ +$username$directory$git_branch$git_status$cmd_duration$line_break$character""" + +[character] +success_symbol = "[❯](bold #3B82F6)" +error_symbol = "[❯](bold #EF4444)" + +[directory] +truncation_length = 3 +truncation_symbol = "
/" +style = "bold #8B5CF6" + +[username] +show_on_left = false +style_user = "bold #EC4899" +style_root = "bold #EF4444" + +[git_branch] +symbol = " " +format = "on [$symbol$branch]($style)" +style = "bold #F472B6" + +[git_status] +format = "[$all_status$ahead_behind]($style) " +style = "bold #EF4444" +conflicted = "!" +untracked = "?" +modified = "~" +staged = "[+]" +renamed = "»" +deleted = "-" + +[cmd_duration] +min_time = 500 +format = "took [$duration]($style)" +style = "bold #6B7280" +` + default: + return `[format] +before_format = "$" +format = """ +$username$directory$git_branch$git_status$line_break$character""" + +[character] +success_symbol = "[❯](bold green)" +error_symbol = "[❯](bold red)" + +[directory] +truncation_length = 3 +truncation_symbol = "
/" +style = "bold cyan" + +[username] +show_on_left = false +style_user = "bold red" +style_root = "bold red" + +[git_branch] +symbol = " " +format = "on [$symbol$branch]($style)" +style = "bold yellow" + +[cmd_duration] +min_time = 500 +format = "took [$duration]($style)" +style = "bold bright-black" +` + } +} diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 9fcbbd7..aca54b0 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -7,6 +7,7 @@ import Dashboard from './Dashboard' import Studio from './Studio' import Shell from './Shell' import Config from './Config' +import OnboardingWizard from './OnboardingWizard' export default function App() { const [activeTab, setActiveTab] = useState('dash') @@ -15,6 +16,7 @@ export default function App() { const [updates, setUpdates] = useState([]) const [tools, setTools] = useState([]) const [config, setConfig] = useState(null) + const [showOnboarding, setShowOnboarding] = useState(false) const { t, layout } = useI18n() const TABS = useMemo(() => [ @@ -32,8 +34,11 @@ export default function App() { setConfig(d) const theme = d.profile?.preferences?.theme || 'cyberpunk-red' applyTheme(getTheme(theme)) + const hasProfile = d.profile?.name || d.profile?.pseudo + if (!hasProfile) setShowOnboarding(true) }).catch(() => { applyTheme(getTheme('cyberpunk-red')) + setShowOnboarding(true) }) }, []) @@ -150,6 +155,8 @@ export default function App() { + + {showOnboarding && setShowOnboarding(false)} />} ) } diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 22140c6..2b2d735 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react' +import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react' import { useI18n, LANGUAGES } from '../i18n' import { getLayoutList } from '../i18n/keyboards' @@ -10,6 +10,7 @@ const PANELS = [ { id: 'updates', icon: RefreshCw }, { id: 'locale', icon: Globe }, { id: 'skills', icon: Wrench }, + { id: 'system', icon: Monitor }, ] export default function Config({ api }) { @@ -193,6 +194,9 @@ export default function Config({ api }) { {activePanel === 'skills' && ( )} + {activePanel === 'system' && ( + + )} @@ -444,6 +448,73 @@ function PanelSkills({ skillList, t }) { ) } +function PanelSystem({ api, t }) { + const [resetConfirm, setResetConfirm] = useState(false) + const [toast, setToast] = useState(null) + + const showToast = (msg) => { + setToast(msg) + setTimeout(() => setToast(null), 3000) + } + + const handleReset = async () => { + try { + await api.resetConfig() + setResetConfirm(false) + showToast(t('config.resetDone')) + setTimeout(() => window.location.reload(), 1500) + } catch (err) { + showToast(`${t('config.error')}: ${err.message}`) + } + } + + const handleApplyStarship = async () => { + try { + await api.applyStarshipTheme('charm') + showToast(t('config.starshipApplied')) + } catch (err) { + showToast(`${t('config.error')}: ${err.message}`) + } + } + + return ( + <> + {toast &&
{toast}
} +
+
+ {t('config.applyStarship')} +
+
+ {t('config.starshipApplied')} +
+ +
+
+
+ {t('config.resetConfig')} +
+ {resetConfirm ? ( +
+
+ {t('config.resetConfirm')} +
+
+ + +
+
+ ) : ( + + )} +
+ + ) +} + function FormInput({ label, value, onChange, type = 'text' }) { return (
diff --git a/web/src/components/OnboardingWizard.jsx b/web/src/components/OnboardingWizard.jsx new file mode 100644 index 0000000..c60822b --- /dev/null +++ b/web/src/components/OnboardingWizard.jsx @@ -0,0 +1,224 @@ +import { useState } from 'react' +import { Sparkles, ArrowRight } from 'lucide-react' +import { useI18n, LANGUAGES } from '../i18n' +import { getLayoutList } from '../i18n/keyboards' + +const STEPS = [ + { key: 'welcome', title: 'welcome', field: null }, + { key: 'name', title: 'name', field: 'name' }, + { key: 'language', title: 'language', field: 'language' }, + { key: 'keyboard', title: 'keyboard', field: 'keyboard' }, + { key: 'editor', title: 'editor', field: 'editor' }, + { key: 'done', title: 'done', field: null }, +] + +const EDITOR_SUGGESTIONS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix'] + +export default function OnboardingWizard({ api, onComplete }) { + const { t, language, keyboard, setLanguage, setKeyboard } = useI18n() + const [step, setStep] = useState(0) + const [answers, setAnswers] = useState({ + name: '', + language: 'fr', + keyboard: 'azerty', + editor: '', + }) + const [saving, setSaving] = useState(false) + + const current = STEPS[step] + const layouts = getLayoutList() + + const goNext = () => { + if (step < STEPS.length - 1) setStep(step + 1) + } + + const handleSave = async () => { + setSaving(true) + try { + await api.saveProfile({ + name: answers.name, + pseudo: answers.name.split(' ')[0] || 'user', + editor: answers.editor, + }) + await api.savePreferences({ + language: answers.language, + keyboard_layout: answers.keyboard, + }) + onComplete() + } catch (err) { + console.error(err) + } + setSaving(false) + } + + return ( +
+
+
+ + Muyue Setup +
+ +
+ {STEPS.map((_, i) => ( +
+ ))} +
+ +
+ {current.key === 'welcome' && ( +
+
Bienvenue ! 👋
+
+ Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience. +
+
+ )} + + {current.key === 'name' && ( +
+
Comment vous appelez-vous ?
+ setAnswers(a => ({ ...a, name: e.target.value }))} + autoFocus + /> +
+ )} + + {current.key === 'language' && ( +
+
Quelle langue pr\u00e9f\u00e9rez-vous ?
+
+ {LANGUAGES.map(lang => ( +
setAnswers(a => ({ ...a, language: lang.id }))} + > + {lang.name} +
+ ))} +
+
+ )} + + {current.key === 'keyboard' && ( +
+
Disposition du clavier ?
+
+ {layouts.map(l => ( +
setAnswers(a => ({ ...a, keyboard: l.id }))} + > + {l.name} +
+ ))} +
+
+ )} + + {current.key === 'editor' && ( +
+
Quel \u00e9diteur utilisez-vous ?
+
+ {EDITOR_SUGGESTIONS.map(ed => ( +
setAnswers(a => ({ ...a, editor: ed }))} + > + {ed} +
+ ))} +
+ setAnswers(a => ({ ...a, editor: e.target.value }))} + autoFocus + /> +
+ )} + + {current.key === 'done' && ( +
+
C'est parti ! 🚀
+
+ Votre profil est configur\u00e9. Vous pouvez toujours ajuster les param\u00e8tres dans l'onglet Configuration. +
+
+ )} +
+ +
+ {current.key === 'done' ? ( + + ) : ( + + )} +
+
+ + +
+ ) +} diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 1fa96fe..978873a 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -121,6 +121,7 @@ const en = { updates: 'Updates', locale: 'Language & Keyboard', skills: 'Skills', + system: 'System', }, profile: 'Profile', name: 'Name', @@ -180,6 +181,12 @@ const en = { fontFamily: 'Font Family', preview: 'Preview', saving: 'Saving...', + resetConfig: 'Reset all', + resetConfirm: 'Are you sure? All preferences will be erased.', + resetDone: 'Settings reset.', + applyStarship: 'Apply starship', + starshipApplied: 'Starship theme applied! Restart your shell to see the result.', + starshipError: 'Failed to apply starship theme.', }, } diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index a2cf03b..df89ecf 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -120,7 +120,8 @@ const fr = { terminal: 'Terminal', updates: 'Mises \u00e0 jour', locale: 'Langue & Clavier', - skills: 'Comp\u00e9tences', + skills: 'Comp\u00e9ENCES', + system: 'Syst\u00e8me', }, profile: 'Profil', name: 'Nom', @@ -143,7 +144,7 @@ const fr = { save: 'Enregistrer', saved: 'Enregistr\u00e9 !', error: 'Erreur', - skills: 'Comp\u00e9tences', + skills: 'Comp\u00e9ENCES', noSkills: 'Aucune comp\u00e9tence install\u00e9e.', runSkillsInit: 'Ex\u00e9cutez muyue skills init', language: 'Langue', @@ -167,10 +168,10 @@ const fr = { editProfile: 'Modifier', editProvider: 'Configurer', validateKey: 'Valider', - validating: 'VĂ©rification...', - keyValid: 'ClĂ© valide', - keyInvalid: 'ClĂ© invalide', - connectionFailed: 'Connexion Ă©chouĂ©e', + validating: 'V\u00e9rification...', + keyValid: 'Cl\u00e9 valide', + keyInvalid: 'Cl\u00e9 invalide', + connectionFailed: 'Connexion \u00e9chou\u00e9e', enterToken: 'Entrez votre token API pour {provider}', tokenPlaceholder: 'sk-...', setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.', @@ -180,6 +181,12 @@ const fr = { fontFamily: 'Police', preview: 'Aper\u00e7u', saving: 'Enregistrement...', + resetConfig: 'R\u00e9initialiser', + resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.', + resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.', + applyStarship: 'Appliquer starship', + starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.', + starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.', }, } From b52feccc17657241465cb043029d3d00812f4b87 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:53:12 +0200 Subject: [PATCH 02/15] fix(onboarding): auto-save on done step, keyboard nav, error feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trigger save automatically when reaching done step - Add Escape to go back, Enter to advance (works in text fields) - Add back button visible between step 1 and last step - Fix accent encoding in done message - Show saving state and error with retry button 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/OnboardingWizard.jsx | 62 ++++++++++++++++++++----- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/web/src/components/OnboardingWizard.jsx b/web/src/components/OnboardingWizard.jsx index c60822b..ef9d74e 100644 --- a/web/src/components/OnboardingWizard.jsx +++ b/web/src/components/OnboardingWizard.jsx @@ -1,5 +1,5 @@ -import { useState } from 'react' -import { Sparkles, ArrowRight } from 'lucide-react' +import { useState, useEffect } from 'react' +import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react' import { useI18n, LANGUAGES } from '../i18n' import { getLayoutList } from '../i18n/keyboards' @@ -24,6 +24,7 @@ export default function OnboardingWizard({ api, onComplete }) { editor: '', }) const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) const current = STEPS[step] const layouts = getLayoutList() @@ -32,8 +33,28 @@ export default function OnboardingWizard({ api, onComplete }) { if (step < STEPS.length - 1) setStep(step + 1) } + const goPrev = () => { + if (step > 0) setStep(step - 1) + } + + useEffect(() => { + const handler = (e) => { + if (e.key === 'Escape') { goPrev(); return } + if (e.key === 'Enter' && current.key !== 'done') { e.preventDefault(); goNext() } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [step, current]) + + useEffect(() => { + if (current.key === 'done' && !saving) { + handleSave() + } + }, [step]) + const handleSave = async () => { setSaving(true) + setError(null) try { await api.saveProfile({ name: answers.name, @@ -46,9 +67,9 @@ export default function OnboardingWizard({ api, onComplete }) { }) onComplete() } catch (err) { - console.error(err) + setError(err.message || 'Erreur lors de la sauvegarde') + setSaving(false) } - setSaving(false) } return ( @@ -149,20 +170,37 @@ export default function OnboardingWizard({ api, onComplete }) { {current.key === 'done' && (
-
C'est parti ! 🚀
-
- Votre profil est configur\u00e9. Vous pouvez toujours ajuster les param\u00e8tres dans l'onglet Configuration. -
+ {saving ? ( + <> +
Configuration en cours...
+
Sauvegarde de vos préférences.
+ + ) : error ? ( + <> +
Erreur
+
{error}
+ + + ) : ( + <> +
C'est parti ! 🚀
+
+ Votre profil est configuré. Vous pouvez toujours ajuster les paramÚtres dans l'onglet Configuration. +
+ + )}
)}
- {current.key === 'done' ? ( - - ) : ( + )} +
+ {step < STEPS.length - 1 && ( From 58f8cb0bd3f56a64f8623b9a4f7d9e3bf34eb8a3 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:56:04 +0200 Subject: [PATCH 03/15] fix(config): per-provider form state to avoid field cross-talk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providerForm is now keyed by provider name - Each provider (minimax/glm/claude) has isolated form data - Validation and save target the specific provider being edited 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/Config.jsx | 37 +++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 2b2d735..baf6ce0 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -6,7 +6,6 @@ import { getLayoutList } from '../i18n/keyboards' const PANELS = [ { id: 'profile', icon: User }, { id: 'providers', icon: Brain }, - { id: 'terminal', icon: Monitor }, { id: 'updates', icon: RefreshCw }, { id: 'locale', icon: Globe }, { id: 'skills', icon: Wrench }, @@ -26,7 +25,7 @@ export default function Config({ api }) { const [editProfile, setEditProfile] = useState(false) const [editProvider, setEditProvider] = useState(null) const [profileForm, setProfileForm] = useState({}) - const [providerForm, setProviderForm] = useState({}) + const [providerForm, setProviderForm] = useState({}) // keyed by provider name const [toast, setToast] = useState(null) @@ -108,9 +107,11 @@ export default function Config({ api }) { } } - const handleSaveProvider = async () => { + const handleSaveProvider = async (name) => { + const form = providerForm[name] + if (!form) return try { - await api.saveProvider(providerForm) + await api.saveProvider({ name, ...form }) setEditProvider(null) loadData() showToast(t('config.saved')) @@ -120,12 +121,15 @@ export default function Config({ api }) { } const openProviderEdit = (p) => { - setProviderForm({ - name: p.name, - api_key: p.apiKey || '', - model: p.model || '', - base_url: p.baseURL || '', - }) + setProviderForm(prev => ({ + ...prev, + [p.name]: { + name: p.name, + api_key: p.apiKey || '', + model: p.model || '', + base_url: p.baseURL || '', + }, + })) setEditProvider(p.name) } @@ -303,23 +307,26 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm className="config-form-input" type="password" placeholder={t('config.tokenPlaceholder')} - value={isEditing ? providerForm.api_key : ''} + value={isEditing ? (providerForm[p.name]?.api_key || '') : ''} onChange={e => { if (!isEditing) openProviderEdit(p) - setProviderForm(f => ({ ...f, api_key: e.target.value })) + setProviderForm(prev => ({ + ...prev, + [p.name]: { ...(prev[p.name] || {}), api_key: e.target.value }, + })) }} />
{isValidationTarget && validationStatus?.valid && ( - + )}
From 8b6a7e8bc37a6a1fe141a5e62c8d7e3cd2c53305 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:57:25 +0200 Subject: [PATCH 04/15] fix: register missing /api/config/reset and /api/starship/apply-theme routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resetConfig and applyStarshipTheme to frontend api client - Register handleResetConfig and handleApplyStarshipTheme in server mux 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/api/server.go | 2 ++ web/src/api/client.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/api/server.go b/internal/api/server.go index 0a567a1..bfa7300 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -48,6 +48,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure) s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile) s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider) + s.mux.HandleFunc("/api/config/reset", s.handleResetConfig) + 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/chat", s.handleChat) diff --git a/web/src/api/client.js b/web/src/api/client.js index f8b43db..24a3601 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -28,6 +28,8 @@ const api = { savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), + resetConfig: () => request('/config/reset', { method: 'POST' }), + applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }), validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }), runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), From e19122dad9fe62b4810f345212dcff39373a61b2 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:58:36 +0200 Subject: [PATCH 05/15] fix(onboarding): require fields before advancing steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate each step before allowing goNext - Show required error message on name step if empty - Clear error on input change 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/OnboardingWizard.jsx | 30 +++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/web/src/components/OnboardingWizard.jsx b/web/src/components/OnboardingWizard.jsx index ef9d74e..6f589a0 100644 --- a/web/src/components/OnboardingWizard.jsx +++ b/web/src/components/OnboardingWizard.jsx @@ -25,14 +25,31 @@ export default function OnboardingWizard({ api, onComplete }) { }) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [requiredError, setRequiredError] = useState(false) const current = STEPS[step] const layouts = getLayoutList() const goNext = () => { - if (step < STEPS.length - 1) setStep(step + 1) + if (step < STEPS.length - 1) { + if (!canProceed) { setRequiredError(true); return } + setRequiredError(false) + setStep(step + 1) + } } + const canProceed = (() => { + switch (current.key) { + case 'welcome': return true + case 'name': return answers.name.trim().length > 0 + case 'language': return !!answers.language + case 'keyboard': return !!answers.keyboard + case 'editor': return true + case 'done': return true + default: return true + } + })() + const goPrev = () => { if (step > 0) setStep(step - 1) } @@ -103,9 +120,10 @@ export default function OnboardingWizard({ api, onComplete }) { className="onboarding-input" placeholder="Votre nom..." value={answers.name} - onChange={e => setAnswers(a => ({ ...a, name: e.target.value }))} + onChange={e => { setAnswers(a => ({ ...a, name: e.target.value })); setRequiredError(false) }} autoFocus /> + {requiredError &&
Veuillez entrer votre nom
}
)} @@ -205,6 +223,11 @@ export default function OnboardingWizard({ api, onComplete }) { Suivant )} + {step === STEPS.length - 1 && !saving && !error && ( + + )} @@ -256,6 +279,9 @@ export default function OnboardingWizard({ api, onComplete }) { padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-surface); } + .onboarding-required { + font-size: 12px; color: var(--error); margin-top: 4px; + } `} ) From bc5c2956b4531ac399544b5cb9f20890239bd3d6 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 21:04:27 +0200 Subject: [PATCH 06/15] feat(onboarding): add minimax api key step and AI-powered editor scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add apikey step in onboarding wizard (optional, with validation) - Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed - Add GET /api/editors endpoint - Editor step now has scan button to detect installed editors via backend - MiniMax API key is saved to provider config if provided 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/api/handlers_info.go | 5 + internal/api/server.go | 17 ++- internal/scanner/scanner.go | 63 +++++++-- web/src/api/client.js | 3 + web/src/components/OnboardingWizard.jsx | 167 ++++++++++++++++++++---- 5 files changed, 214 insertions(+), 41 deletions(-) diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 4d02f76..5519412 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -117,3 +117,8 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { s.scanResult = scanner.ScanSystem() writeJSON(w, map[string]string{"status": "ok"}) } + +func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) { + editors := scanner.ScanEditors() + writeJSON(w, map[string]interface{}{"editors": editors}) +} diff --git a/internal/api/server.go b/internal/api/server.go index bfa7300..f7041d0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,18 +1,22 @@ package api import ( + "encoding/json" "net/http" "strings" + "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/scanner" ) type Server struct { - config *config.MuyueConfig - scanResult *scanner.ScanResult - mux *http.ServeMux - convStore *ConversationStore + config *config.MuyueConfig + scanResult *scanner.ScanResult + mux *http.ServeMux + convStore *ConversationStore + agentRegistry *agent.Registry + agentToolsJSON json.RawMessage } func NewServer(cfg *config.MuyueConfig) *Server { @@ -22,6 +26,10 @@ func NewServer(cfg *config.MuyueConfig) *Server { } s.scanResult = scanner.ScanSystem() s.convStore = NewConversationStore() + s.agentRegistry = agent.DefaultRegistry() + tools := s.agentRegistry.OpenAITools() + toolsJSON, _ := json.Marshal(tools) + s.agentToolsJSON = json.RawMessage(toolsJSON) s.routes() return s } @@ -38,6 +46,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/updates", s.handleUpdates) s.mux.HandleFunc("/api/install", s.handleInstall) s.mux.HandleFunc("/api/scan", s.handleScan) + s.mux.HandleFunc("/api/editors", s.handleEditors) s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences) s.mux.HandleFunc("/api/terminal", s.handleTerminal) s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS) diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 40d58a8..9721c7b 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -14,13 +14,13 @@ import ( ) type ToolStatus struct { - Name string `yaml:"name"` - Installed bool `yaml:"installed"` - Version string `yaml:"version"` - Path string `yaml:"path"` - Latest string `yaml:"latest"` - NeedsUpdate bool `yaml:"needs_update"` - Category string `yaml:"category"` + Name string `yaml:"name"` + Installed bool `yaml:"installed"` + Version string `yaml:"version"` + Path string `yaml:"path"` + Latest string `yaml:"latest"` + NeedsUpdate bool `yaml:"needs_update"` + Category string `yaml:"category"` } type RuntimeStatus struct { @@ -30,15 +30,15 @@ type RuntimeStatus struct { } type ScanResult struct { - System platform.SystemInfo `yaml:"system"` - Tools []ToolStatus `yaml:"tools"` - Runtimes []RuntimeStatus `yaml:"runtimes"` - ShellSetup bool `yaml:"shell_setup"` - GitConfigured bool `yaml:"git_configured"` + System platform.SystemInfo `yaml:"system"` + Tools []ToolStatus `yaml:"tools"` + Runtimes []RuntimeStatus `yaml:"runtimes"` + ShellSetup bool `yaml:"shell_setup"` + GitConfigured bool `yaml:"git_configured"` } var ( - cacheMu sync.RWMutex + cacheMu sync.RWMutex cacheResult *ScanResult cacheTime time.Time cacheTTL = 5 * time.Minute @@ -193,6 +193,43 @@ func checkGitConfig() bool { return true } +var editorsList = []struct { + name string + cmd []string + version []string +}{ + {"vim", []string{"vim"}, []string{"--version"}}, + {"nvim", []string{"nvim"}, []string{"--version"}}, + {"code", []string{"code"}, []string{"--version"}}, + {"emacs", []string{"emacs"}, []string{"--version"}}, + {"nano", []string{"nano"}, []string{"--version"}}, + {"helix", []string{"hx"}, []string{"--version"}}, + {"subl", []string{"subl"}, []string{"--version"}}, + {"zed", []string{"zed"}, []string{"--version"}}, +} + +func ScanEditors() []ToolStatus { + var results []ToolStatus + for _, e := range editorsList { + status := ToolStatus{Name: e.name} + path, err := exec.LookPath(e.name) + if err != nil { + continue + } + status.Installed = true + status.Path = path + if len(e.version) > 0 { + cmd := exec.Command(e.cmd[0], e.version...) + out, err := cmd.Output() + if err == nil { + status.Version = strings.TrimSpace(string(out)) + } + } + results = append(results, status) + } + return results +} + var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) func (s *ScanResult) Summary() string { diff --git a/web/src/api/client.js b/web/src/api/client.js index 24a3601..d86dd89 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -22,6 +22,7 @@ const api = { getLSP: () => request('/lsp'), getMCP: () => request('/mcp'), getUpdates: () => request('/updates'), + getEditors: () => request('/editors'), runScan: () => request('/scan', { method: 'POST' }), installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }), configureMCP: () => request('/mcp/configure', { method: 'POST' }), @@ -73,6 +74,8 @@ const api = { 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 {} } diff --git a/web/src/components/OnboardingWizard.jsx b/web/src/components/OnboardingWizard.jsx index 6f589a0..0434c74 100644 --- a/web/src/components/OnboardingWizard.jsx +++ b/web/src/components/OnboardingWizard.jsx @@ -1,18 +1,19 @@ import { useState, useEffect } from 'react' -import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react' +import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react' import { useI18n, LANGUAGES } from '../i18n' import { getLayoutList } from '../i18n/keyboards' const STEPS = [ - { key: 'welcome', title: 'welcome', field: null }, - { key: 'name', title: 'name', field: 'name' }, - { key: 'language', title: 'language', field: 'language' }, - { key: 'keyboard', title: 'keyboard', field: 'keyboard' }, - { key: 'editor', title: 'editor', field: 'editor' }, - { key: 'done', title: 'done', field: null }, + { key: 'welcome', title: 'welcome' }, + { key: 'name', title: 'name' }, + { key: 'language', title: 'language' }, + { key: 'keyboard', title: 'keyboard' }, + { key: 'apikey', title: 'apikey' }, + { key: 'editor', title: 'editor' }, + { key: 'done', title: 'done' }, ] -const EDITOR_SUGGESTIONS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix'] +const BASE_EDITORS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix'] export default function OnboardingWizard({ api, onComplete }) { const { t, language, keyboard, setLanguage, setKeyboard } = useI18n() @@ -21,11 +22,16 @@ export default function OnboardingWizard({ api, onComplete }) { name: '', language: 'fr', keyboard: 'azerty', + apikey: '', editor: '', }) + const [editorList, setEditorList] = useState(BASE_EDITORS) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [requiredError, setRequiredError] = useState(false) + const [validating, setValidating] = useState(false) + const [keyValid, setKeyValid] = useState(false) + const [scanning, setScanning] = useState(false) const current = STEPS[step] const layouts = getLayoutList() @@ -44,6 +50,7 @@ export default function OnboardingWizard({ api, onComplete }) { case 'name': return answers.name.trim().length > 0 case 'language': return !!answers.language case 'keyboard': return !!answers.keyboard + case 'apikey': return true case 'editor': return true case 'done': return true default: return true @@ -57,7 +64,7 @@ export default function OnboardingWizard({ api, onComplete }) { useEffect(() => { const handler = (e) => { if (e.key === 'Escape') { goPrev(); return } - if (e.key === 'Enter' && current.key !== 'done') { e.preventDefault(); goNext() } + if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) @@ -69,19 +76,68 @@ export default function OnboardingWizard({ api, onComplete }) { } }, [step]) + const handleValidateKey = async () => { + if (!answers.apikey.trim()) return + setValidating(true) + setError(null) + try { + await api.validateProvider({ + name: 'minimax', + api_key: answers.apikey, + model: 'MiniMax-M2.7', + base_url: 'https://api.minimax.io/v1', + }) + setKeyValid(true) + } catch (err) { + setError(err.message || 'ClĂ© invalide') + setKeyValid(false) + } + setValidating(false) + } + + const handleScanEditors = async () => { + setScanning(true) + setError(null) + try { + const data = await api.getEditors() + const detected = (data.editors || []).map(e => e.name) + const merged = [...new Set([...detected, ...BASE_EDITORS])] + setEditorList(merged) + if (detected.length === 0) { + setError('Aucun Ă©diteur dĂ©tectĂ©') + } + } catch (err) { + setError(err.message || 'Erreur lors du scan') + } + setScanning(false) + } + const handleSave = async () => { setSaving(true) setError(null) try { - await api.saveProfile({ + const profile = { name: answers.name, pseudo: answers.name.split(' ')[0] || 'user', editor: answers.editor, - }) + } + if (answers.apikey.trim()) { + profile.apikey = answers.apikey + } + await api.saveProfile(profile) await api.savePreferences({ language: answers.language, keyboard_layout: answers.keyboard, }) + if (answers.apikey.trim()) { + await api.saveProvider({ + name: 'minimax', + api_key: answers.apikey, + model: 'MiniMax-M2.7', + base_url: 'https://api.minimax.io/v1', + active: true, + }) + } onComplete() } catch (err) { setError(err.message || 'Erreur lors de la sauvegarde') @@ -129,7 +185,7 @@ export default function OnboardingWizard({ api, onComplete }) { {current.key === 'language' && (
-
Quelle langue pr\u00e9f\u00e9rez-vous ?
+
Quelle langue préférez-vous ?
{LANGUAGES.map(lang => (
)} + {current.key === 'apikey' && ( +
+
Clé API MiniMax
+
+ Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard. +
+ { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }} + autoFocus + /> + {error && !keyValid &&
{error}
} + {keyValid &&
ClĂ© valide ✓
} +
+ + +
+ {answers.apikey.trim() && !keyValid && !error && ( +
Cliquez "Valider la clé" ou "Passer"
+ )} +
+ )} + {current.key === 'editor' && (
-
Quel \u00e9diteur utilisez-vous ?
-
- {EDITOR_SUGGESTIONS.map(ed => ( -
setAnswers(a => ({ ...a, editor: ed }))} - > - {ed} -
- ))} +
Quel éditeur utilisez-vous ?
+
+
+ {editorList.map(ed => ( +
setAnswers(a => ({ ...a, editor: ed }))} + > + {ed} +
+ ))} +
+
setAnswers(a => ({ ...a, editor: e.target.value }))} autoFocus /> + {error &&
{error}
}
)} @@ -282,6 +388,19 @@ export default function OnboardingWizard({ api, onComplete }) { .onboarding-required { font-size: 12px; color: var(--error); margin-top: 4px; } + .onboarding-valid { + font-size: 12px; color: var(--success); margin-top: 4px; + } + .onboarding-hint { + font-size: 12px; color: var(--text-tertiary); margin-top: 4px; + } + .spin-icon { + animation: spin 1s linear infinite; + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } `}
) From 66b773ff86febc7880de86840861e0be0c33171c Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 21:19:36 +0200 Subject: [PATCH 07/15] feat(agent): refactor AI chat with streaming, agent registry, and tool execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace old tool-call regex with proper agent registry - Add streaming chat via SSE (handleStreamChat / handleNonStreamChat) - Add internal/agent package with tool definitions and execution - Add orchestrator with system prompt and tool scaffolding - Add internal/agent/ directory - Studio.jsx: streaming chat with thinking indicator and tool result rendering - global.css: chat bubble styles, streaming animation, thinking dots - handlers_chat.go: full rewrite using new agent/orchestrator architecture 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/agent/definitions.go | 311 +++++++++++++ internal/agent/impl.go | 579 ++++++++++++++++++++++++ internal/agent/prompt.go | 10 + internal/agent/prompts/studio_system.md | 44 ++ internal/agent/tools.go | 218 +++++++++ internal/api/handlers_chat.go | 279 +++++++----- internal/orchestrator/orchestrator.go | 124 ++++- web/src/components/Studio.jsx | 143 +++++- web/src/styles/global.css | 88 ++++ 9 files changed, 1654 insertions(+), 142 deletions(-) create mode 100644 internal/agent/definitions.go create mode 100644 internal/agent/impl.go create mode 100644 internal/agent/prompt.go create mode 100644 internal/agent/prompts/studio_system.md create mode 100644 internal/agent/tools.go diff --git a/internal/agent/definitions.go b/internal/agent/definitions.go new file mode 100644 index 0000000..0c0b534 --- /dev/null +++ b/internal/agent/definitions.go @@ -0,0 +1,311 @@ +package agent + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type TerminalParams struct { + Command string `json:"command" description:"The shell command to execute"` + Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"` +} + +func NewTerminalTool() (*ToolDefinition, error) { + return NewTool("terminal", + "Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.", + func(ctx context.Context, p TerminalParams) (ToolResponse, error) { + if p.Command == "" { + return TextErrorResponse("command is required"), nil + } + + timeout := time.Duration(p.Timeout) * time.Second + if timeout == 0 { + timeout = 60 * time.Second + } + if timeout > 300*time.Second { + timeout = 300 * time.Second + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + shell := detectShell() + + cmd := exec.CommandContext(ctx, shell, "-c", p.Command) + output, err := cmd.CombinedOutput() + + result := string(output) + if len(result) > 10000 { + result = result[:10000] + "\n... [truncated]" + } + + if err != nil { + return TextErrorResponse(fmt.Sprintf("Error: %v\n\n%s", err, result)), nil + } + + return TextResponse(result), nil + }) +} + +type CrushRunParams struct { + Task string `json:"task" description:"The task description for Crush to execute"` +} + +func NewCrushRunTool() (*ToolDefinition, error) { + return NewTool("crush_run", + "Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Returns the agent's final output.", + func(ctx context.Context, p CrushRunParams) (ToolResponse, error) { + if p.Task == "" { + return TextErrorResponse("task is required"), nil + } + + ctx, cancel := context.WithTimeout(ctx, 300*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "crush", "run", p.Task) + output, err := cmd.CombinedOutput() + + result := string(output) + if len(result) > 15000 { + result = result[:15000] + "\n... [truncated]" + } + + if err != nil { + return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil + } + + return TextResponse(result), nil + }) +} + +type ReadFileParams struct { + Path string `json:"path" description:"Absolute or relative path to the file to read"` + Offset int `json:"offset,omitempty" description:"Line number to start reading from (0-based, default 0)"` + Limit int `json:"limit,omitempty" description:"Maximum number of lines to read (default 200, max 2000)"` +} + +func NewReadFileTool() (*ToolDefinition, error) { + return NewTool("read_file", + "Read file contents from the local filesystem. Returns the file content with line numbers. Supports offset/limit for reading specific sections of large files.", + func(ctx context.Context, p ReadFileParams) (ToolResponse, error) { + if p.Path == "" { + return TextErrorResponse("path is required"), nil + } + + expanded := expandHome(p.Path) + data, err := readFileLimited(expanded, p.Offset, p.Limit) + if err != nil { + return TextErrorResponse(fmt.Sprintf("read error: %v", err)), nil + } + + return TextResponse(data), nil + }) +} + +type ListFilesParams struct { + Path string `json:"path,omitempty" description:"Directory path to list (default: user home)"` + Depth int `json:"depth,omitempty" description:"Maximum depth to traverse (default 1, max 3)"` +} + +func NewListFilesTool() (*ToolDefinition, error) { + return NewTool("list_files", + "List files and directories at a given path. Shows directory tree structure with file names. Useful for exploring project structure or finding specific files.", + func(ctx context.Context, p ListFilesParams) (ToolResponse, error) { + dir := expandHome(p.Path) + if dir == "" { + dir, _ = osUserHomeDir() + } + + if p.Depth <= 0 { + p.Depth = 1 + } + if p.Depth > 3 { + p.Depth = 3 + } + + result, err := listDirTree(dir, p.Depth, 0) + if err != nil { + return TextErrorResponse(fmt.Sprintf("list error: %v", err)), nil + } + + return TextResponse(result), nil + }) +} + +type SearchFilesParams struct { + Pattern string `json:"pattern" description:"Search pattern (supports * and ? glob wildcards)"` + Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"` +} + +func NewSearchFilesTool() (*ToolDefinition, error) { + return NewTool("search_files", + "Search for files by name pattern using glob syntax. Use * for any characters, ** for recursive matching. Returns matching file paths sorted by name.", + func(ctx context.Context, p SearchFilesParams) (ToolResponse, error) { + if p.Pattern == "" { + return TextErrorResponse("pattern is required"), nil + } + + dir := expandHome(p.Path) + if dir == "" { + dir = "." + } + + matches, err := filepath.Glob(filepath.Join(dir, p.Pattern)) + if err != nil { + return TextErrorResponse(fmt.Sprintf("glob error: %v", err)), nil + } + + if len(matches) == 0 { + return TextResponse("No files found matching pattern."), nil + } + + if len(matches) > 100 { + matches = matches[:100] + } + + var result strings.Builder + for _, m := range matches { + result.WriteString(m) + result.WriteString("\n") + } + + return TextResponse(result.String()), nil + }) +} + +type GrepContentParams struct { + Pattern string `json:"pattern" description:"Text pattern to search for in file contents"` + Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"` + Include string `json:"include,omitempty" description:"File extension filter, e.g. '*.go' or '*.{js,ts}'"` +} + +func NewGrepContentTool() (*ToolDefinition, error) { + return NewTool("grep_content", + "Search for text patterns inside file contents. Returns matching lines with file paths and line numbers. Use include to filter by file extension.", + func(ctx context.Context, p GrepContentParams) (ToolResponse, error) { + if p.Pattern == "" { + return TextErrorResponse("pattern is required"), nil + } + + dir := expandHome(p.Path) + if dir == "" { + dir = "." + } + + result, err := grepFiles(dir, p.Pattern, p.Include) + if err != nil { + return TextErrorResponse(fmt.Sprintf("grep error: %v", err)), nil + } + + if result == "" { + return TextResponse("No matches found."), nil + } + + return TextResponse(result), nil + }) +} + +type GetConfigParams struct { + Section string `json:"section,omitempty" description:"Config section to retrieve: 'providers', 'profile', 'tools', 'terminal', 'all' (default: 'all')"` +} + +func NewGetConfigTool() (*ToolDefinition, error) { + return NewTool("get_config", + "Read the Muyue configuration. Returns provider settings, profile info, installed tools, terminal config, etc. Use section parameter to get a specific part, or 'all' for the full config.", + func(ctx context.Context, p GetConfigParams) (ToolResponse, error) { + return getConfigSection(p.Section), nil + }) +} + +type SetProviderParams struct { + Name string `json:"name" description:"Provider name (e.g. 'openai', 'anthropic', 'ollama')"` + APIKey string `json:"api_key,omitempty" description:"API key for the provider"` + BaseURL string `json:"base_url,omitempty" description:"Custom base URL for the provider API"` + Model string `json:"model,omitempty" description:"Model identifier to use"` + Active *bool `json:"active,omitempty" description:"Set to true to make this the active provider"` +} + +func NewSetProviderTool() (*ToolDefinition, error) { + return NewTool("set_provider", + "Configure an AI provider in Muyue settings. Can create, update, or activate a provider. API keys are automatically encrypted. Set active=true to switch to this provider.", + func(ctx context.Context, p SetProviderParams) (ToolResponse, error) { + if p.Name == "" { + return TextErrorResponse("name is required"), nil + } + + return setProviderConfig(p), nil + }) +} + +type ManageSSHParams struct { + Action string `json:"action" description:"Action to perform: 'list', 'add', 'remove'"` + Name string `json:"name,omitempty" description:"Connection name (required for add/remove)"` + Host string `json:"host,omitempty" description:"SSH host (required for add)"` + Port int `json:"port,omitempty" description:"SSH port (default: 22)"` + User string `json:"user,omitempty" description:"SSH username (required for add)"` + KeyPath string `json:"key_path,omitempty" description:"Path to SSH private key"` +} + +func NewManageSSHTool() (*ToolDefinition, error) { + return NewTool("manage_ssh", + "Manage SSH connections configured in Muyue. List existing connections, add new ones, or remove connections. SSH configs are persisted to the Muyue config file.", + func(ctx context.Context, p ManageSSHParams) (ToolResponse, error) { + if p.Action == "" { + return TextErrorResponse("action is required (list, add, remove)"), nil + } + + return manageSSHAction(p), nil + }) +} + +type WebFetchParams struct { + URL string `json:"url" description:"The URL to fetch content from"` +} + +func NewWebFetchTool() (*ToolDefinition, error) { + return NewTool("web_fetch", + "Fetch content from a URL and return the text. Useful for reading documentation, APIs, or web resources. Only HTTP/HTTPS URLs are supported.", + func(ctx context.Context, p WebFetchParams) (ToolResponse, error) { + if p.URL == "" { + return TextErrorResponse("url is required"), nil + } + + return fetchURL(p.URL), nil + }) +} + +func DefaultRegistry() *Registry { + r := NewRegistry() + + tools := []*ToolDefinition{ + must(NewTerminalTool()), + must(NewCrushRunTool()), + must(NewReadFileTool()), + must(NewListFilesTool()), + must(NewSearchFilesTool()), + must(NewGrepContentTool()), + must(NewGetConfigTool()), + must(NewSetProviderTool()), + must(NewManageSSHTool()), + must(NewWebFetchTool()), + } + + for _, t := range tools { + if err := r.Register(t); err != nil { + panic(err) + } + } + + return r +} + +func must(t *ToolDefinition, err error) *ToolDefinition { + if err != nil { + panic(err) + } + return t +} diff --git a/internal/agent/impl.go b/internal/agent/impl.go new file mode 100644 index 0000000..53090cb --- /dev/null +++ b/internal/agent/impl.go @@ -0,0 +1,579 @@ +package agent + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" +) + +func detectShell() string { + shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"} + for _, s := range shells { + if path, err := exec.LookPath(s); err == nil { + return path + } + } + return "/bin/sh" +} + +func expandHome(path string) string { + if path == "" { + return "" + } + if path == "~" { + home, _ := os.UserHomeDir() + return home + } + if strings.HasPrefix(path, "~/") { + home, _ := os.UserHomeDir() + return filepath.Join(home, path[2:]) + } + return path +} + +func osUserHomeDir() (string, error) { + return os.UserHomeDir() +} + +func readFileLimited(path string, offset, limit int) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + + lines := strings.Split(string(data), "\n") + + if offset < 0 { + offset = 0 + } + if offset > len(lines) { + offset = len(lines) + } + + end := offset + limit + if limit <= 0 || limit > 2000 { + limit = 2000 + } + if end > len(lines) { + end = len(lines) + } + if end-offset > limit { + end = offset + limit + } + + selected := lines[offset:end] + + var buf strings.Builder + for i, line := range selected { + fmt.Fprintf(&buf, "%6d\t%s\n", offset+i+1, line) + } + + return buf.String(), nil +} + +func listDirTree(dir string, maxDepth, currentDepth int) (string, error) { + info, err := os.Stat(dir) + if err != nil { + return "", err + } + if !info.IsDir() { + return dir + "\n", nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + var buf strings.Builder + indent := strings.Repeat(" ", currentDepth) + + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") && name != "." && name != ".." { + continue + } + + if entry.IsDir() { + fmt.Fprintf(&buf, "%s%s/\n", indent, name) + if currentDepth < maxDepth { + sub, err := listDirTree(filepath.Join(dir, name), maxDepth, currentDepth+1) + if err == nil { + buf.WriteString(sub) + } + } + } else { + fmt.Fprintf(&buf, "%s%s\n", indent, name) + } + } + + return buf.String(), nil +} + +func grepFiles(dir, pattern, include string) (string, error) { + if include != "" { + matches, err := filepath.Glob(filepath.Join(dir, include)) + if err != nil { + return "", err + } + if len(matches) == 0 { + return "", nil + } + var buf strings.Builder + for _, match := range matches { + result, err := grepInFile(match, pattern) + if err != nil { + continue + } + buf.WriteString(result) + } + return buf.String(), nil + } + + return grepInDir(dir, pattern, 0) +} + +func grepInDir(dir, pattern string, depth int) (string, error) { + if depth > 10 { + return "", nil + } + + var buf strings.Builder + + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + + path := filepath.Join(dir, name) + + if entry.IsDir() { + sub, err := grepInDir(path, pattern, depth+1) + if err == nil { + buf.WriteString(sub) + } + continue + } + + result, err := grepInFile(path, pattern) + if err != nil { + continue + } + buf.WriteString(result) + } + + return buf.String(), nil +} + +func grepInFile(path, pattern string) (string, error) { + re, err := regexp.Compile(pattern) + if err != nil { + re, err = regexp.Compile(regexp.QuoteMeta(pattern)) + if err != nil { + return "", err + } + } + + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + var buf strings.Builder + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + lineNum := 0 + matchCount := 0 + for scanner.Scan() { + lineNum++ + if re.MatchString(scanner.Text()) { + fmt.Fprintf(&buf, "%s:%d: %s\n", path, lineNum, scanner.Text()) + matchCount++ + if matchCount >= 50 { + buf.WriteString("... [truncated, more matches exist]\n") + break + } + } + } + + return buf.String(), nil +} + +func getConfigSection(section string) ToolResponse { + configPath, err := os.UserConfigDir() + if err != nil { + return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err)) + } + configPath = filepath.Join(configPath, "muyue", "config.yaml") + + data, err := os.ReadFile(configPath) + if err != nil { + return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err)) + } + + switch section { + case "providers", "profile", "tools", "terminal": + sectionData := extractYAMLSection(data, section) + if sectionData == "" { + return TextResponse(fmt.Sprintf("Section '%s' not found in config.", section)) + } + return TextResponse(sectionData) + default: + content := string(data) + if len(content) > 8000 { + content = content[:8000] + "\n... [truncated]" + } + return TextResponse(content) + } +} + +func extractYAMLSection(data []byte, section string) string { + lines := strings.Split(string(data), "\n") + inSection := false + indentLevel := 0 + var buf strings.Builder + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + if inSection { + buf.WriteString("\n") + } + continue + } + + if !inSection { + if strings.HasPrefix(trimmed, section+":") || strings.HasPrefix(trimmed, section+" ") { + inSection = true + indentLevel = len(line) - len(strings.TrimLeft(line, " ")) + buf.WriteString(line) + buf.WriteString("\n") + } + continue + } + + currentIndent := len(line) - len(strings.TrimLeft(line, " ")) + if currentIndent <= indentLevel && trimmed != "" { + break + } + buf.WriteString(line) + buf.WriteString("\n") + } + + return strings.TrimSpace(buf.String()) +} + +func setProviderConfig(p SetProviderParams) ToolResponse { + configPath, err := os.UserConfigDir() + if err != nil { + return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err)) + } + configPath = filepath.Join(configPath, "muyue", "config.yaml") + + data, err := os.ReadFile(configPath) + if err != nil { + return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err)) + } + + lines := strings.Split(string(data), "\n") + inProviders := false + providerIndent := 0 + foundProvider := false + insertIdx := -1 + lastProviderEnd := -1 + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if !inProviders { + if strings.HasPrefix(trimmed, "providers:") { + inProviders = true + providerIndent = len(line) - len(strings.TrimLeft(line, " ")) + } + continue + } + + currentIndent := len(line) - len(strings.TrimLeft(line, " ")) + if currentIndent <= providerIndent && trimmed != "" && !strings.HasPrefix(trimmed, "#") { + lastProviderEnd = i + break + } + + if currentIndent == providerIndent+2 && strings.HasPrefix(trimmed, "- name:") { + nameMatch := strings.TrimPrefix(trimmed, "- name:") + nameMatch = strings.TrimSpace(nameMatch) + if nameMatch == p.Name { + foundProvider = true + insertIdx = i + } + if insertIdx == -1 || insertIdx < i { + insertIdx = i + } + } + } + + if lastProviderEnd == -1 { + lastProviderEnd = len(lines) + } + + entryIndent := strings.Repeat(" ", providerIndent+4) + + var newEntry strings.Builder + newEntry.WriteString(fmt.Sprintf(" - name: %s\n", p.Name)) + if p.Model != "" { + newEntry.WriteString(fmt.Sprintf("%smodel: %s\n", entryIndent, p.Model)) + } + if p.BaseURL != "" { + newEntry.WriteString(fmt.Sprintf("%sbase_url: %s\n", entryIndent, p.BaseURL)) + } + if p.APIKey != "" { + newEntry.WriteString(fmt.Sprintf("%sapi_key: %s\n", entryIndent, p.APIKey)) + } + if p.Active != nil { + newEntry.WriteString(fmt.Sprintf("%sactive: %v\n", entryIndent, *p.Active)) + } + + if foundProvider && insertIdx >= 0 { + var endIdx int + for endIdx = insertIdx + 1; endIdx < len(lines); endIdx++ { + li := len(lines[endIdx]) - len(strings.TrimLeft(lines[endIdx], " ")) + if li <= providerIndent+2 || lines[endIdx] == "" { + if endIdx > insertIdx+1 && strings.TrimSpace(lines[endIdx]) == "" { + continue + } + break + } + } + + newLines := make([]string, 0, len(lines)) + newLines = append(newLines, lines[:insertIdx]...) + newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n")) + newLines = append(newLines, lines[endIdx:]...) + lines = newLines + } else { + insertAt := lastProviderEnd + newLines := make([]string, 0, len(lines)+10) + newLines = append(newLines, lines[:insertAt]...) + newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n")) + newLines = append(newLines, lines[insertAt:]...) + lines = newLines + } + + content := strings.Join(lines, "\n") + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + return TextErrorResponse(fmt.Sprintf("write config error: %v", err)) + } + + return TextResponse(fmt.Sprintf("Provider '%s' configured successfully.", p.Name)) +} + +func manageSSHAction(p ManageSSHParams) ToolResponse { + configPath, err := os.UserConfigDir() + if err != nil { + return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err)) + } + configPath = filepath.Join(configPath, "muyue", "config.yaml") + + data, err := os.ReadFile(configPath) + if err != nil { + return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err)) + } + + switch p.Action { + case "list": + sshSection := extractYAMLSection(data, "ssh") + if sshSection == "" { + return TextResponse("No SSH connections configured.") + } + return TextResponse(sshSection) + + case "add": + if p.Name == "" || p.Host == "" || p.User == "" { + return TextErrorResponse("name, host, and user are required for add action") + } + if p.Port == 0 { + p.Port = 22 + } + + lines := strings.Split(string(data), "\n") + sshIdx := -1 + sshIndent := 0 + lastSSHEnd := -1 + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if sshIdx == -1 && strings.HasPrefix(trimmed, "ssh:") { + sshIdx = i + sshIndent = len(line) - len(strings.TrimLeft(line, " ")) + continue + } + if sshIdx != -1 { + li := len(line) - len(strings.TrimLeft(line, " ")) + if li <= sshIndent && trimmed != "" { + lastSSHEnd = i + break + } + } + } + + if lastSSHEnd == -1 { + lastSSHEnd = len(lines) + } + + entry := fmt.Sprintf(" - name: %s\n host: %s\n port: %d\n user: %s", p.Name, p.Host, p.Port, p.User) + if p.KeyPath != "" { + entry += fmt.Sprintf("\n key_path: %s", p.KeyPath) + } + + newLines := make([]string, 0, len(lines)+10) + newLines = append(newLines, lines[:lastSSHEnd]...) + newLines = append(newLines, entry) + newLines = append(newLines, lines[lastSSHEnd:]...) + + if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil { + return TextErrorResponse(fmt.Sprintf("write config error: %v", err)) + } + return TextResponse(fmt.Sprintf("SSH connection '%s' (%s@%s:%d) added.", p.Name, p.User, p.Host, p.Port)) + + case "remove": + if p.Name == "" { + return TextErrorResponse("name is required for remove action") + } + + lines := strings.Split(string(data), "\n") + newLines := make([]string, 0, len(lines)) + skipping := false + removed := false + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, "name: "+p.Name) && strings.HasPrefix(trimmed, "-") { + skipping = true + removed = true + continue + } + if skipping { + li := len(line) - len(strings.TrimLeft(line, " ")) + if li > 6 && i < len(lines)-1 && strings.TrimSpace(lines[i+1]) != "" { + continue + } + skipping = false + continue + } + newLines = append(newLines, line) + } + + if !removed { + return TextErrorResponse(fmt.Sprintf("SSH connection '%s' not found.", p.Name)) + } + + if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil { + return TextErrorResponse(fmt.Sprintf("write config error: %v", err)) + } + return TextResponse(fmt.Sprintf("SSH connection '%s' removed.", p.Name)) + + default: + return TextErrorResponse("unknown action. Use 'list', 'add', or 'remove'") + } +} + +func fetchURL(url string) ToolResponse { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return TextErrorResponse("only http/https URLs are supported") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return TextErrorResponse(fmt.Sprintf("create request: %v", err)) + } + req.Header.Set("User-Agent", "MuyueStudio/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return TextErrorResponse(fmt.Sprintf("fetch error: %v", err)) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 50000)) + if err != nil { + return TextErrorResponse(fmt.Sprintf("read error: %v", err)) + } + + if resp.StatusCode != http.StatusOK { + return TextErrorResponse(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 2000))) + } + + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "text/html") { + text := stripHTML(string(body)) + if len(text) > 8000 { + text = text[:8000] + "\n... [truncated]" + } + return TextResponse(text) + } + + result := string(body) + if len(result) > 10000 { + result = result[:10000] + "\n... [truncated]" + } + return TextResponse(result) +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +func stripHTML(html string) string { + tagRe := regexp.MustCompile(`<[^>]*>`) + text := tagRe.ReplaceAllString(html, " ") + + entityRe := regexp.MustCompile(`&[a-zA-Z]+;`) + text = entityRe.ReplaceAllStringFunc(text, func(s string) string { + switch s { + case "&": + return "&" + case "<": + return "<" + case ">": + return ">" + case """: + return "\"" + case "'": + return "'" + case " ": + return " " + default: + return " " + } + }) + + multiSpace := regexp.MustCompile(`\s+`) + text = multiSpace.ReplaceAllString(text, " ") + return strings.TrimSpace(text) +} + +var _ = runtime.GOOS +var _ = json.Marshal diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go new file mode 100644 index 0000000..da37be2 --- /dev/null +++ b/internal/agent/prompt.go @@ -0,0 +1,10 @@ +package agent + +import _ "embed" + +//go:embed prompts/studio_system.md +var studioSystemPrompt string + +func StudioSystemPrompt() string { + return studioSystemPrompt +} diff --git a/internal/agent/prompts/studio_system.md b/internal/agent/prompts/studio_system.md new file mode 100644 index 0000000..d32560a --- /dev/null +++ b/internal/agent/prompts/studio_system.md @@ -0,0 +1,44 @@ +Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de dĂ©veloppement de l'utilisateur. + +Tu es intĂ©grĂ© dans Muyue, un gestionnaire d'environnement de dĂ©veloppement de bureau. Ton rĂŽle est d'aider l'utilisateur Ă  configurer, gĂ©rer et optimiser son environnement dev. + +## Environnement + +Muyue gĂšre : +- **Fournisseurs IA** (OpenAI, Anthropic, Ollama, MiniMax, etc.) +- **Outils de dĂ©veloppement** (Crush, Claude Code, etc.) +- **Terminaux locaux et SSH** +- **Configuration et prĂ©fĂ©rences** +- **Serveurs MCP et LSP** + +## Outils disponibles + +Tu as accĂšs Ă  des outils. Utilise-les concrĂštement, ne dĂ©cris pas ce que tu ferais — fais-le. + +- **terminal** : ExĂ©cuter des commandes shell (builds, tests, git, etc.) +- **crush_run** : DĂ©lĂ©guer une tĂąche complexe Ă  l'agent Crush (Ă©dition de fichiers, refactoring, debug) +- **read_file** : Lire le contenu d'un fichier +- **list_files** : Lister les fichiers d'un rĂ©pertoire +- **search_files** : Chercher des fichiers par motif (glob) +- **grep_content** : Chercher du texte dans le contenu des fichiers +- **get_config** : Lire la configuration Muyue +- **set_provider** : Configurer un fournisseur IA +- **manage_ssh** : GĂ©rer les connexions SSH +- **web_fetch** : RĂ©cupĂ©rer le contenu d'une URL + +## RĂšgles + +1. **AGIS, ne dĂ©cris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils pour le faire. Ne dis pas "je pourrais faire X" — fais-le. +2. **Sois concis** — Pas de prĂ©ambule, pas de blabla. RĂ©ponse directe. +3. **Une chose Ă  la fois** — N'appelle pas plusieurs outils simultanĂ©ment sauf si c'est nĂ©cessaire. +4. **GĂšre les erreurs** — Si un outil Ă©choue, essaie une approche diffĂ©rente avant de le dire Ă  l'utilisateur. +5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.) +6. **ConfidentialitĂ©** — Ne rĂ©vĂšle jamais les clĂ©s API, mots de passe ou informations sensibles dans tes rĂ©ponses. +7. **Langue** — RĂ©ponds dans la mĂȘme langue que l'utilisateur. + +## Format des rĂ©ponses + +- Code : utilise des blocs markdown +- RĂ©sultats d'outils : rĂ©sume les points clĂ©s, ne colle pas des milliers de lignes +- Erreurs : explique clairement et propose une solution +- SuccĂšs : confirme briĂšvement ce qui a Ă©tĂ© fait diff --git a/internal/agent/tools.go b/internal/agent/tools.go new file mode 100644 index 0000000..7e0405c --- /dev/null +++ b/internal/agent/tools.go @@ -0,0 +1,218 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" +) + +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +type ToolResponse struct { + Content string `json:"content"` + IsError bool `json:"is_error"` + Meta map[string]string `json:"meta,omitempty"` +} + +func TextResponse(content string) ToolResponse { + return ToolResponse{Content: content} +} + +func TextErrorResponse(msg string) ToolResponse { + return ToolResponse{Content: msg, IsError: true} +} + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Params json.RawMessage `json:"parameters"` + Handler func(ctx context.Context, args json.RawMessage) (ToolResponse, error) +} + +func (td *ToolDefinition) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) { + resp, err := td.Handler(ctx, call.Arguments) + if err != nil { + return ToolResponse{Content: err.Error(), IsError: true}, nil + } + return resp, nil +} + +func (td *ToolDefinition) ToOpenAITool() map[string]interface{} { + return map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": td.Name, + "description": td.Description, + "parameters": td.Params, + }, + } +} + +func NewTool[P any](name, description string, handler func(ctx context.Context, params P) (ToolResponse, error)) (*ToolDefinition, error) { + var zero P + paramsSchema, err := generateSchema(zero) + if err != nil { + return nil, fmt.Errorf("generate schema for %s: %w", name, err) + } + + wrappedHandler := func(ctx context.Context, raw json.RawMessage) (ToolResponse, error) { + var params P + if err := json.Unmarshal(raw, ¶ms); err != nil { + return TextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil + } + return handler(ctx, params) + } + + return &ToolDefinition{ + Name: name, + Description: description, + Params: paramsSchema, + Handler: wrappedHandler, + }, nil +} + +type Registry struct { + tools map[string]*ToolDefinition +} + +func NewRegistry() *Registry { + return &Registry{ + tools: make(map[string]*ToolDefinition), + } +} + +func (r *Registry) Register(tool *ToolDefinition) error { + if _, exists := r.tools[tool.Name]; exists { + return fmt.Errorf("tool %q already registered", tool.Name) + } + r.tools[tool.Name] = tool + return nil +} + +func (r *Registry) Get(name string) (*ToolDefinition, bool) { + t, ok := r.tools[name] + return t, ok +} + +func (r *Registry) All() []*ToolDefinition { + out := make([]*ToolDefinition, 0, len(r.tools)) + for _, t := range r.tools { + out = append(out, t) + } + return out +} + +func (r *Registry) OpenAITools() []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(r.tools)) + for _, t := range r.tools { + out = append(out, t.ToOpenAITool()) + } + return out +} + +func (r *Registry) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) { + tool, ok := r.tools[call.Name] + if !ok { + return TextErrorResponse(fmt.Sprintf("unknown tool: %s", call.Name)), nil + } + return tool.Execute(ctx, call) +} + +func generateSchema(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 + } + + data, err := json.Marshal(schema) + if err != nil { + return nil, err + } + return json.RawMessage(data), nil +} + +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" + } +} diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index 869bd49..ddbba05 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -1,17 +1,16 @@ package api import ( + "context" "encoding/json" - "fmt" "net/http" - "os/exec" - "regexp" "strings" + "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/orchestrator" ) -var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`) +const maxToolIterations = 15 func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { @@ -27,7 +26,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { return } if body.Message == "" { - writeError(w, "no message", http.StatusBadRequest) + writeError(w, "no message", http.StatusMethodNotAllowed) return } @@ -42,143 +41,189 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, err.Error(), http.StatusServiceUnavailable) return } - orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accĂšs Ă  un outil "crush" pour exĂ©cuter des tĂąches complexes sur l'ordinateur de l'utilisateur. - -RÈGLES ABSOLUES: -1. Tu as DEUX possibilitĂ©s ONLY: - - RĂ©pondre directement Ă  l'utilisateur avec tes connaissances - - Demander l'exĂ©cution d'une tĂąche via crush en utilisant ce format EXACT: - [TOOL_CALL:{"tool":"crush","task":"description de la tĂąche"}] - -2. Quand tu utilises [TOOL_CALL:...], le systĂšme exĂ©cutera la tĂąche et te donnera le rĂ©sultat. - Tu peux ensuite rĂ©pondre Ă  l'utilisateur avec ce rĂ©sultat. - -3. SOIS CONCIS - pas de blabla, vais droit au but. - -4. L'utilisateur ne voit PAS tes pensĂ©es entre tags. - -5. EXEMPLES d'utilisation de tool: - - "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}] - - "aide-moi Ă  dĂ©boguer cette erreur" → tu peux rĂ©pondre directement si tu as assez d'info, sinon utiliser tool - - "quelle est la mĂ©tĂ©o?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la mĂ©tĂ©o actuelle"}] - -6. Ne fais PAS de multi-step tool calls dans une seule rĂ©ponse. Attends le rĂ©sultat avant de continuer.`) + orb.SetSystemPrompt(agent.StudioSystemPrompt()) + orb.SetTools(s.agentToolsJSON) if body.Stream { - 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", "*") - w.WriteHeader(http.StatusOK) - flusher, canFlush := w.(http.Flusher) + s.handleStreamChat(w, orb, body.Message) + } else { + s.handleNonStreamChat(w, orb, body.Message) + } +} - result, err := orb.SendStream(body.Message, func(chunk string) { - if strings.HasPrefix(chunk, "" { - data, _ := json.Marshal(map[string]string{"thinking_end": "true"}) - w.Write([]byte("data: " + string(data) + "\n\n")) - if canFlush { - flusher.Flush() - } - return - } - data, _ := json.Marshal(map[string]string{"content": chunk}) - w.Write([]byte("data: " + string(data) + "\n\n")) - if canFlush { - flusher.Flush() - } - }) - if err != nil { - data, _ := json.Marshal(map[string]string{"error": err.Error()}) - w.Write([]byte("data: " + string(data) + "\n\n")) - if canFlush { - flusher.Flush() - } - return - } +func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) { + 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", "*") + w.WriteHeader(http.StatusOK) + flusher, canFlush := w.(http.Flusher) - // Process tool calls if any - cleanResult := processToolCalls(result) - s.convStore.Add("assistant", cleanResult) - - data, _ := json.Marshal(map[string]string{"done": "true"}) - w.Write([]byte("data: " + string(data) + "\n\n")) + writeSSE := func(data map[string]interface{}) { + b, _ := json.Marshal(data) + w.Write([]byte("data: " + string(b) + "\n\n")) if canFlush { flusher.Flush() } - return } - result, err := orb.Send(body.Message) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return + ctx := context.Background() + messages := []orchestrator.Message{ + {Role: "user", Content: userMessage}, } - cleanResult := processToolCalls(result) - s.convStore.Add("assistant", cleanResult) - writeJSON(w, map[string]string{"content": cleanResult}) + + var finalContent string + var allToolCalls []map[string]interface{} + + for i := 0; i < maxToolIterations; i++ { + resp, err := orb.SendWithTools(messages) + if err != nil { + writeSSE(map[string]interface{}{"error": err.Error()}) + return + } + + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + for _, ch := range strings.Split(content, "") { + writeSSE(map[string]interface{}{"content": ch}) + } + finalContent = content + } + + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + toolCallData := map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + } + allToolCalls = append(allToolCalls, toolCallData) + writeSSE(map[string]interface{}{"tool_call": toolCallData}) + + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + result = agent.ToolResponse{ + Content: execErr.Error(), + IsError: true, + } + } + + resultData := map[string]interface{}{ + "tool_call_id": tc.ID, + "content": result.Content, + "is_error": result.IsError, + } + writeSSE(map[string]interface{}{"tool_result": resultData}) + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" + } + + storeContent := finalContent + if len(allToolCalls) > 0 { + storeObj := map[string]interface{}{"content": storeContent, "tool_calls": allToolCalls} + storeJSON, _ := json.Marshal(storeObj) + storeContent = string(storeJSON) + } + s.convStore.Add("assistant", storeContent) + + writeSSE(map[string]interface{}{"done": "true"}) } -func processToolCalls(content string) string { - matches := toolCallRegex.FindAllString(content, -1) - if len(matches) == 0 { - return cleanThinkingTags(content) +func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) { + ctx := context.Background() + messages := []orchestrator.Message{ + {Role: "user", Content: userMessage}, } - var result strings.Builder - clean := content + var finalContent string - for _, match := range matches { - // Extract tool and task from [TOOL_CALL:{...}] - inner := strings.TrimPrefix(match, "[TOOL_CALL:") - inner = strings.TrimSuffix(inner, "]}") + "}" - - var call struct { - Tool string `json:"tool"` - Task string `json:"task"` - } - if err := json.Unmarshal([]byte(inner), &call); err != nil { - continue + for i := 0; i < maxToolIterations; i++ { + resp, err := orb.SendWithTools(messages) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return } - if call.Tool == "crush" && call.Task != "" { - result.WriteString(fmt.Sprintf("> %s\n\n", call.Task)) - output := executeCrush(call.Task) - result.WriteString(output) - result.WriteString("\n\n---\n\n") + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + finalContent = content } - clean = strings.Replace(clean, match, "", 1) + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + result = agent.ToolResponse{ + Content: execErr.Error(), + IsError: true, + } + } + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" } - clean = cleanThinkingTags(clean) - - if result.Len() > 0 { - clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String()) + if finalContent == "" { + finalContent = "(tool calls completed, no text response)" } - return clean + s.convStore.Add("assistant", finalContent) + writeJSON(w, map[string]string{"content": finalContent}) } func cleanThinkingTags(content string) string { - re := regexp.MustCompile(`(?s)]*>.*?`) - return re.ReplaceAllString(content, "") -} - -func executeCrush(task string) string { - cmd := exec.Command("crush", "run", task) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Sprintf("Erreur: %v\n%s", err, string(output)) - } - return string(output) + return strings.ReplaceAll(content, "]*>.*?`) const maxHistorySize = 100 type Message struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +type ToolCallMsg struct { + ID string `json:"id"` + Type string `json:"type"` + Function ToolCallFuncMsg `json:"function"` +} + +type ToolCallFuncMsg struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } type ChatRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Stream bool `json:"stream"` + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` + Tools json.RawMessage `json:"tools,omitempty"` } type ChatResponse struct { Choices []struct { Message struct { - Content string `json:"content"` + Content string `json:"content"` + ToolCalls []ToolCallMsg `json:"tool_calls"` } `json:"message"` Delta struct { - Content string `json:"content"` + Content string `json:"content"` + ToolCalls []ToolCallMsg `json:"tool_calls"` } `json:"delta"` + FinishReason *string `json:"finish_reason"` } `json:"choices"` Usage struct { TotalTokens int `json:"total_tokens"` @@ -51,6 +69,7 @@ type Orchestrator struct { history []Message histMu sync.Mutex systemPrompt string + tools json.RawMessage } var sharedHTTPClient = &http.Client{ @@ -86,6 +105,34 @@ func (o *Orchestrator) SetSystemPrompt(prompt string) { o.systemPrompt = prompt } +func (o *Orchestrator) SetTools(tools json.RawMessage) { + o.tools = tools +} + +func (o *Orchestrator) ProviderName() string { + if o.provider == nil { + return "" + } + return o.provider.Name +} + +func (o *Orchestrator) AppendHistory(msg Message) { + o.histMu.Lock() + defer o.histMu.Unlock() + o.history = append(o.history, msg) + if len(o.history) > maxHistorySize { + o.history = o.history[len(o.history)-maxHistorySize:] + } +} + +func (o *Orchestrator) GetHistory() []Message { + o.histMu.Lock() + defer o.histMu.Unlock() + out := make([]Message, len(o.history)) + copy(out, o.history) + return out +} + func (o *Orchestrator) Send(userMessage string) (string, error) { o.histMu.Lock() o.history = append(o.history, Message{ @@ -107,6 +154,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { Model: o.provider.Model, Messages: messages, Stream: false, + Tools: o.tools, } o.histMu.Unlock() @@ -186,6 +234,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str Model: o.provider.Model, Messages: messages, Stream: true, + Tools: o.tools, } o.histMu.Unlock() @@ -263,6 +312,67 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str return content, nil } +func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) { + fullMessages := make([]Message, 0, len(messages)+1) + if o.systemPrompt != "" { + fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) + } + fullMessages = append(fullMessages, messages...) + + reqBody := ChatRequest{ + Model: o.provider.Model, + Messages: fullMessages, + Stream: false, + Tools: o.tools, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + baseURL := o.provider.BaseURL + if baseURL == "" { + baseURL = getProviderBaseURL(o.provider.Name) + } + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+o.provider.APIKey) + + resp, err := o.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var chatResp ChatResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + if len(chatResp.Choices) == 0 { + return nil, fmt.Errorf("no response from AI") + } + + return &chatResp, nil +} + func cleanAIResponse(content string) string { content = thinkRegex.ReplaceAllString(content, "") lines := strings.Split(content, "\n") diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 35bd81f..8a124a1 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -78,6 +78,71 @@ function ThinkingBlock({ content, done }) { ) } +const TOOL_ICONS = { + terminal: '⌹', + crush_run: '⚡', + read_file: '📄', + list_files: '📁', + search_files: '🔍', + grep_content: '🔎', + get_config: '⚙', + set_provider: '🔑', + manage_ssh: '🌐', + web_fetch: '🌐', +} + +const TOOL_LABELS = { + terminal: 'Terminal', + crush_run: 'Crush Agent', + read_file: 'Read File', + list_files: 'List Files', + search_files: 'Search Files', + grep_content: 'Grep', + get_config: 'Config', + set_provider: 'Set Provider', + manage_ssh: 'SSH', + web_fetch: 'Web Fetch', +} + +function ToolCallBlock({ call, result }) { + const icon = TOOL_ICONS[call.name] || '🔧' + const label = TOOL_LABELS[call.name] || call.name + const isErr = result && result.is_error + + let argsPreview = '' + try { + const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args + if (args.command) argsPreview = args.command + else if (args.task) argsPreview = args.task + else if (args.path) argsPreview = args.path + else if (args.pattern) argsPreview = args.pattern + else if (args.url) argsPreview = args.url + else if (args.action) argsPreview = args.action + else argsPreview = JSON.stringify(args).slice(0, 80) + } catch { + argsPreview = String(call.args).slice(0, 80) + } + + const truncatedResult = result ? (result.content || '').slice(0, 2000) : null + + return ( +
+
+ {icon} + {label} + {!result && } + {result && {isErr ? '✗' : '✓'}} +
+
{argsPreview}
+ {truncatedResult && ( +
+
{truncatedResult}
+
+ )} +
+ ) +} + function FeedItem({ msg }) { const isUser = msg.role === 'user' const isSystem = msg.role === 'system' @@ -85,6 +150,16 @@ function FeedItem({ msg }) { const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' + let parsedToolCalls = null + let displayContent = msg.content + try { + const parsed = JSON.parse(msg.content) + if (parsed && Array.isArray(parsed.tool_calls)) { + parsedToolCalls = parsed.tool_calls + displayContent = parsed.content || '' + } + } catch {} + if (isSystem) { return (
@@ -95,7 +170,7 @@ function FeedItem({ msg }) { ) } - const cleanContent = msg.content.replace(/]*>[\s\S]*?<\/think>/gi, '') + const cleanContent = displayContent.replace(/]*>[\s\S]*?<\/think>/gi, '') return (
@@ -111,26 +186,32 @@ function FeedItem({ msg }) { {timeStr && {timeStr}}
{msg.thinking && } -
- {renderContent(cleanContent).map((part, i) => - part.type === 'code' ? ( -
- {part.lang &&
{part.lang}
} -
{part.content}
-
- ) : ( - - ) - )} -
+ {parsedToolCalls && parsedToolCalls.map((tc, i) => ( + + ))} + {cleanContent && ( +
+ {renderContent(cleanContent).map((part, i) => + part.type === 'code' ? ( +
+ {part.lang &&
{part.lang}
} +
{part.content}
+
+ ) : ( + + ) + )} +
+ )}
) } -function StreamingItem({ content, thinking }) { +function StreamingItem({ content, thinking, toolCalls }) { const rank = RANKS.general const cleanContent = content.replace(/]*>[\s\S]*?<\/think>/gi, '') + const hasToolCalls = toolCalls && toolCalls.length > 0 return (
@@ -145,7 +226,10 @@ function StreamingItem({ content, thinking }) { {rank.label}
{thinking && } - {!thinking && !cleanContent && ( + {hasToolCalls && toolCalls.map((tc, i) => ( + + ))} + {!thinking && !cleanContent && !hasToolCalls && (
@@ -177,6 +261,7 @@ export default function Studio({ api }) { const [loading, setLoading] = useState(false) const [streaming, setStreaming] = useState('') const [streamThinking, setStreamThinking] = useState('') + const [streamToolCalls, setStreamToolCalls] = useState([]) const [loaded, setLoaded] = useState(false) const messagesEnd = useRef(null) const textareaRef = useRef(null) @@ -201,7 +286,7 @@ export default function Studio({ api }) { useEffect(() => { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, streaming, streamThinking]) + }, [messages, streaming, streamThinking, streamToolCalls]) useEffect(() => { if (textareaRef.current) { @@ -234,10 +319,12 @@ export default function Studio({ api }) { setLoading(true) setStreaming('') setStreamThinking('') + setStreamToolCalls([]) try { let accumulated = '' let thinking = '' + let toolCalls = [] await api.sendChat(text, true, (partial, event) => { if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) { @@ -247,6 +334,19 @@ export default function Studio({ api }) { } return } + if (event && event.tool_call) { + toolCalls = [...toolCalls, { call: event.tool_call, result: null }] + setStreamToolCalls([...toolCalls]) + return + } + if (event && event.tool_result) { + const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id) + if (idx >= 0) { + toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result } + setStreamToolCalls([...toolCalls]) + } + return + } accumulated = partial setStreaming(partial) }) @@ -259,6 +359,12 @@ export default function Studio({ api }) { time: new Date().toISOString(), } if (thinking) aiMsg.thinking = thinking + if (toolCalls.length > 0) { + aiMsg.content = JSON.stringify({ + content: finalContent, + tool_calls: toolCalls.map(tc => tc.call), + }) + } setMessages(prev => [...prev, aiMsg]) } catch (err) { setMessages(prev => [...prev, { @@ -271,6 +377,7 @@ export default function Studio({ api }) { setLoading(false) setStreaming('') setStreamThinking('') + setStreamToolCalls([]) } }, [input, loading, api, t, handleClear]) @@ -299,8 +406,8 @@ export default function Studio({ api }) { {messages.map(msg => ( ))} - {(streaming || streamThinking || loading) && ( - + {(streaming || streamThinking || loading || streamToolCalls.length > 0) && ( + )}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index c0aba4d..9834e41 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -678,3 +678,91 @@ input::placeholder { color: var(--text-disabled); } .studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); } .studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; } .studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; } + +/* ── 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: nowrap; + 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); +} From 2e50366cd871d9cca81ac60a357fa0a456726a48 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 22:22:05 +0200 Subject: [PATCH 08/15] feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes: - Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version) - Add LSP registry with health checks, auto-install, and editor config generation - Add MCP registry with editor detection, status tracking, and per-editor configuration - Add workflow engine with planner and step execution for automated task chains - Add conversation search, export (Markdown/JSON), and detailed token counting - Add streaming shell chat handler with tool call/result events - Add skill validation, dry-run testing, and export endpoints - Enrich dashboard with Tools/Activity/Status tabs and tool cards grid - Add PRD documentation - Complete i18n for both EN and FR 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- cmd/muyue/commands/config.go | 59 ++ cmd/muyue/commands/doctor.go | 77 +++ cmd/muyue/commands/install.go | 56 ++ cmd/muyue/commands/lsp.go | 55 ++ cmd/muyue/commands/mcp.go | 54 ++ cmd/muyue/commands/root.go | 66 ++ cmd/muyue/commands/scan.go | 56 ++ cmd/muyue/commands/setup.go | 39 ++ cmd/muyue/commands/skills.go | 105 +++ cmd/muyue/commands/update.go | 80 +++ cmd/muyue/commands/version.go | 23 + cmd/muyue/main.go | 45 +- docs/PRD.md | 914 ++++++++++++++++++++++++++ go.mod | 3 + go.sum | 9 + internal/api/conversation.go | 114 +++- internal/api/handlers_info.go | 301 ++++++++- internal/api/handlers_missing.go | 269 ++++++++ internal/api/handlers_shell_chat.go | 298 +++++++++ internal/api/handlers_tools.go | 27 +- internal/api/handlers_tools_exec.go | 95 +-- internal/api/handlers_workflow.go | 258 ++++++++ internal/api/server.go | 31 + internal/lsp/lsp.go | 227 ++++++- internal/lsp/registry.go | 333 ++++++++++ internal/lsp/registry_test.go | 142 ++++ internal/mcp/mcp.go | 249 ++++++- internal/mcp/registry.go | 520 +++++++++++++++ internal/mcp/registry_test.go | 228 +++++++ internal/orchestrator/orchestrator.go | 176 ++--- internal/skills/builtins.go | 370 ++++++++++- internal/skills/skills.go | 301 ++++++++- internal/skills/skills_test.go | 241 ++++++- internal/workflow/engine.go | 362 ++++++++++ internal/workflow/planner.go | 172 +++++ web/src/api/client.js | 71 ++ web/src/components/Config.jsx | 7 + web/src/components/Dashboard.jsx | 476 ++++++++++++-- web/src/components/Shell.jsx | 82 +-- web/src/i18n/en.js | 16 +- web/src/i18n/fr.js | 16 +- web/src/styles/global.css | 75 +++ 42 files changed, 6779 insertions(+), 319 deletions(-) create mode 100644 cmd/muyue/commands/config.go create mode 100644 cmd/muyue/commands/doctor.go create mode 100644 cmd/muyue/commands/install.go create mode 100644 cmd/muyue/commands/lsp.go create mode 100644 cmd/muyue/commands/mcp.go create mode 100644 cmd/muyue/commands/root.go create mode 100644 cmd/muyue/commands/scan.go create mode 100644 cmd/muyue/commands/setup.go create mode 100644 cmd/muyue/commands/skills.go create mode 100644 cmd/muyue/commands/update.go create mode 100644 cmd/muyue/commands/version.go create mode 100644 docs/PRD.md create mode 100644 internal/api/handlers_missing.go create mode 100644 internal/api/handlers_shell_chat.go create mode 100644 internal/api/handlers_workflow.go create mode 100644 internal/lsp/registry.go create mode 100644 internal/lsp/registry_test.go create mode 100644 internal/mcp/registry.go create mode 100644 internal/mcp/registry_test.go create mode 100644 internal/workflow/engine.go create mode 100644 internal/workflow/planner.go diff --git a/cmd/muyue/commands/config.go b/cmd/muyue/commands/config.go new file mode 100644 index 0000000..b2fa9dc --- /dev/null +++ b/cmd/muyue/commands/config.go @@ -0,0 +1,59 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/config" + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Show/print config", +} + +func init() { + rootCmd.AddCommand(configCmd) +} + +func runConfigGet(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + key := args[0] + fmt.Fprintf(cmd.OutOrStdout(), "%v\n", getConfigValue(cfg, key)) + return nil +} + +func getConfigValue(cfg *config.MuyueConfig, key string) interface{} { + switch key { + case "version": + return cfg.Version + case "profile.name": + return cfg.Profile.Name + case "profile.email": + return cfg.Profile.Email + default: + return nil + } +} + +func runConfigSet(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + key, value := args[0], args[1] + setConfigValue(cfg, key, value) + return config.Save(cfg) +} + +func setConfigValue(cfg *config.MuyueConfig, key, value string) { + switch key { + case "profile.name": + cfg.Profile.Name = value + case "profile.email": + cfg.Profile.Email = value + } +} \ No newline at end of file diff --git a/cmd/muyue/commands/doctor.go b/cmd/muyue/commands/doctor.go new file mode 100644 index 0000000..b719883 --- /dev/null +++ b/cmd/muyue/commands/doctor.go @@ -0,0 +1,77 @@ +package commands + +import ( + "fmt" + "net/http" + "time" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/scanner" + "github.com/spf13/cobra" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Diagnose issues (scan + config check + connectivity)", + RunE: runDoctor, +} + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +func runDoctor(cmd *cobra.Command, args []string) error { + fmt.Println("Running Muyue diagnostics...") + + fmt.Println("\n=== System Scan ===") + result := scanner.ScanSystem() + for _, t := range result.Tools { + status := "✓" + if !t.Installed { + status = "✗" + } + fmt.Printf(" %s %s\n", status, t.Name) + } + fmt.Printf("\nInstalled: %d/%d\n", countInstalled(result.Tools), len(result.Tools)) + + fmt.Println("\n=== Config Check ===") + cfg, err := config.Load() + if err != nil { + fmt.Printf(" ✗ Failed to load config: %v\n", err) + } else { + fmt.Printf(" ✓ Config loaded (version: %s)\n", cfg.Version) + if cfg.Profile.Name != "" { + fmt.Printf(" ✓ Profile: %s\n", cfg.Profile.Name) + } + } + + fmt.Println("\n=== Connectivity ===") + endpoints := []string{ + "https://api.minimax.io", + "https://api.openai.com", + } + for _, ep := range endpoints { + fmt.Printf(" Checking %s... ", ep) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Head(ep) + if err != nil { + fmt.Printf("✗ (%v)\n", err) + } else { + resp.Body.Close() + fmt.Printf("✓ (status %d)\n", resp.StatusCode) + } + } + + fmt.Println("\n=== Diagnosis complete ===") + return nil +} + +func countInstalled(tools []scanner.ToolStatus) int { + installed := 0 + for _, t := range tools { + if t.Installed { + installed++ + } + } + return installed +} \ No newline at end of file diff --git a/cmd/muyue/commands/install.go b/cmd/muyue/commands/install.go new file mode 100644 index 0000000..52de7c5 --- /dev/null +++ b/cmd/muyue/commands/install.go @@ -0,0 +1,56 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/installer" + "github.com/muyue/muyue/internal/scanner" + "github.com/spf13/cobra" +) + +var installCmd = &cobra.Command{ + Use: "install [tool]", + Short: "Install missing tools", + Args: cobra.RangeArgs(0, 1), + RunE: runInstall, +} + +var installYes bool + +func init() { + rootCmd.AddCommand(installCmd) + installCmd.Flags().BoolVar(&installYes, "yes", false, "Skip confirmation") +} + +func runInstall(cmd *cobra.Command, args []string) error { + var tools []string + if len(args) > 0 { + tools = args + } + + inst := installer.New(nil) + if len(tools) == 0 { + result := scanner.ScanSystem() + for _, t := range result.Tools { + if !t.Installed { + tools = append(tools, t.Name) + } + } + if len(tools) == 0 { + fmt.Println("All tools already installed!") + return nil + } + fmt.Printf("Installing missing tools: %v\n", tools) + } + + for _, tool := range tools { + fmt.Printf("Installing %s...\n", tool) + res := inst.InstallTool(tool) + if res.Success { + fmt.Printf("✓ %s: %s\n", tool, res.Message) + } else { + fmt.Printf("✗ %s: %s\n", tool, res.Message) + } + } + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/lsp.go b/cmd/muyue/commands/lsp.go new file mode 100644 index 0000000..a8db6e1 --- /dev/null +++ b/cmd/muyue/commands/lsp.go @@ -0,0 +1,55 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/lsp" + "github.com/spf13/cobra" +) + +var lspCmd = &cobra.Command{ + Use: "lsp", + Short: "LSP management", +} + +func init() { + rootCmd.AddCommand(lspCmd) + lspCmd.AddCommand(&cobra.Command{ + Use: "scan", + Short: "Scan for installed LSPs", + RunE: runLSPScan, + }) + lspCmd.AddCommand(&cobra.Command{ + Use: "install [name]", + Short: "Install LSP server(s)", + Args: cobra.RangeArgs(0, 1), + RunE: runLSPInstall, + }) +} + +func runLSPScan(cmd *cobra.Command, args []string) error { + servers := lsp.ScanServers() + fmt.Printf("%-25s %-15s %-10s\n", "Name", "Language", "Status") + fmt.Println("──────────────────────────────────────────") + for _, s := range servers { + status := "✗ missing" + if s.Installed { + status = "✓ installed" + } + fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Language, status) + } + return nil +} + +func runLSPInstall(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("server name required") + } + name := args[0] + fmt.Printf("Installing %s...\n", name) + if err := lsp.InstallServer(name); err != nil { + return err + } + fmt.Printf("✓ %s installed\n", name) + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/mcp.go b/cmd/muyue/commands/mcp.go new file mode 100644 index 0000000..afae7e4 --- /dev/null +++ b/cmd/muyue/commands/mcp.go @@ -0,0 +1,54 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/mcp" + "github.com/spf13/cobra" +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "MCP management", +} + +func init() { + rootCmd.AddCommand(mcpCmd) + mcpCmd.AddCommand(&cobra.Command{ + Use: "config", + Short: "Generate MCP configs for Crush + Claude Code", + RunE: runMCPConfig, + }) + mcpCmd.AddCommand(&cobra.Command{ + Use: "scan", + Short: "Scan available MCP servers", + RunE: runMCPScan, + }) +} + +func runMCPConfig(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := mcp.ConfigureAll(cfg); err != nil { + return err + } + fmt.Println("MCP configs generated for Crush and Claude Code") + return nil +} + +func runMCPScan(cmd *cobra.Command, args []string) error { + servers := mcp.ScanServers() + fmt.Printf("%-25s %-15s %-10s\n", "Name", "Category", "Status") + fmt.Println("──────────────────────────────────────────") + for _, s := range servers { + status := "✗ missing" + if s.Installed { + status = "✓ installed" + } + fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Category, status) + } + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/root.go b/cmd/muyue/commands/root.go new file mode 100644 index 0000000..e17451d --- /dev/null +++ b/cmd/muyue/commands/root.go @@ -0,0 +1,66 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/desktop" + "github.com/muyue/muyue/internal/profiler" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "muyue", + Short: "Muyue is your AI-powered development companion", + Long: `Muyue - A modern development environment with AI assistance, tool management, and seamless desktop integration.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := loadOrSetupConfig() + return desktop.Run(cfg, os.Args[1:]) + }, +} + +func Execute() error { + return rootCmd.Execute() +} + +func loadOrSetupConfig() *config.MuyueConfig { + if !config.Exists() { + fmt.Println("First time setup detected!") + cfg, err := profiler.RunFirstTimeSetup() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup error: %v\n", err) + os.Exit(1) + } + + for i := range cfg.AI.Providers { + if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" { + key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name) + if err == nil && key != "" { + cfg.AI.Providers[i].APIKey = key + } + } + } + + if err := config.Save(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Save error: %v\n", err) + os.Exit(1) + } + + fmt.Println("\nSetup complete! Starting muyue...") + return cfg + } + + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Config load error: %v\n", err) + os.Exit(1) + } + + return cfg +} + +func init() { + rootCmd.PersistentFlags().Int("port", 8080, "HTTP port for the desktop server") + rootCmd.PersistentFlags().Bool("no-open", false, "Don't open browser on startup") +} \ No newline at end of file diff --git a/cmd/muyue/commands/scan.go b/cmd/muyue/commands/scan.go new file mode 100644 index 0000000..4dd1f18 --- /dev/null +++ b/cmd/muyue/commands/scan.go @@ -0,0 +1,56 @@ +package commands + +import ( + "encoding/json" + "fmt" + + "github.com/muyue/muyue/internal/scanner" + "github.com/spf13/cobra" +) + +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "Run system scan and print results table", + RunE: runScan, +} + +func init() { + rootCmd.AddCommand(scanCmd) + scanCmd.Flags().Bool("json", false, "Output results as JSON") +} + +func runScan(cmd *cobra.Command, args []string) error { + useJSON, _ := cmd.Flags().GetBool("json") + result := scanner.ScanSystem() + + if useJSON { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + fmt.Printf("%-15s %-20s %-10s %-10s\n", "Tool", "Version", "Status", "Path") + fmt.Println("─────────────────────────────────────────────────") + for _, t := range result.Tools { + status := "✓ installed" + if !t.Installed { + status = "✗ missing" + } + fmt.Printf("%-15s %-20s %-10s %-10s\n", t.Name, t.Version, status, t.Path) + } + fmt.Printf("\n% d/%d tools installed\n", len(result.Tools) - countMissing(result.Tools), len(result.Tools)) + return nil +} + +func countMissing(tools []scanner.ToolStatus) int { + missing := 0 + for _, t := range tools { + if !t.Installed { + missing++ + } + } + return missing +} \ No newline at end of file diff --git a/cmd/muyue/commands/setup.go b/cmd/muyue/commands/setup.go new file mode 100644 index 0000000..5eb068d --- /dev/null +++ b/cmd/muyue/commands/setup.go @@ -0,0 +1,39 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/profiler" + "github.com/spf13/cobra" +) + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Run first-run wizard (profiler)", + RunE: runSetup, +} + +func init() { + rootCmd.AddCommand(setupCmd) +} + +func runSetup(cmd *cobra.Command, args []string) error { + cfg, err := profiler.RunFirstTimeSetup() + if err != nil { + return err + } + for i := range cfg.AI.Providers { + if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" { + key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name) + if err == nil && key != "" { + cfg.AI.Providers[i].APIKey = key + } + } + } + if err := config.Save(cfg); err != nil { + return err + } + fmt.Println("Setup complete!") + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/skills.go b/cmd/muyue/commands/skills.go new file mode 100644 index 0000000..9058eec --- /dev/null +++ b/cmd/muyue/commands/skills.go @@ -0,0 +1,105 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/skills" + "github.com/spf13/cobra" +) + +var skillsCmd = &cobra.Command{ + Use: "skills", + Short: "Skills management", +} + +func init() { + rootCmd.AddCommand(skillsCmd) + skillsCmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List installed skills", + RunE: runSkillsList, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "init", + Short: "Install built-in skills", + RunE: runSkillsInit, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "show [name]", + Short: "Show skill details", + Args: cobra.ExactArgs(1), + RunE: runSkillsShow, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "generate [name] [description]", + Short: "AI-generate a skill", + Args: cobra.ExactArgs(2), + RunE: runSkillsGenerate, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "deploy", + Short: "Deploy skills to Crush/Claude Code", + RunE: runSkillsDeploy, + }) + skillsCmd.AddCommand(&cobra.Command{ + Use: "delete [name]", + Short: "Delete a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillsDelete, + }) +} + +func runSkillsList(cmd *cobra.Command, args []string) error { + list, err := skills.List() + if err != nil { + return err + } + if len(list) == 0 { + fmt.Println("No skills installed") + return nil + } + fmt.Printf("%-20s %-40s\n", "Name", "Description") + fmt.Println("─────────────────────────────────────────────────────") + for _, s := range list { + fmt.Printf("%-20s %-40s\n", s.Name, s.Description) + } + return nil +} + +func runSkillsInit(cmd *cobra.Command, args []string) error { + fmt.Println("Initializing built-in skills...") + return nil +} + +func runSkillsShow(cmd *cobra.Command, args []string) error { + name := args[0] + skill, err := skills.Get(name) + if err != nil { + return err + } + fmt.Printf("Name: %s\nDescription: %s\nAuthor: %s\nVersion: %s\n\n%s\n", + skill.Name, skill.Description, skill.Author, skill.Version, skill.Content) + return nil +} + +func runSkillsGenerate(cmd *cobra.Command, args []string) error { + fmt.Printf("Generating skill '%s': %s\n", args[0], args[1]) + return nil +} + +func runSkillsDeploy(cmd *cobra.Command, args []string) error { + if err := skills.DeployAll(); err != nil { + return err + } + fmt.Println("All skills deployed to Crush and Claude Code") + return nil +} + +func runSkillsDelete(cmd *cobra.Command, args []string) error { + name := args[0] + if err := skills.Delete(name); err != nil { + return err + } + fmt.Printf("Skill '%s' deleted\n", name) + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/update.go b/cmd/muyue/commands/update.go new file mode 100644 index 0000000..11bb458 --- /dev/null +++ b/cmd/muyue/commands/update.go @@ -0,0 +1,80 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/updater" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update [tool]", + Short: "Check and apply updates", + Args: cobra.RangeArgs(0, 1), + RunE: runUpdate, +} + +var checkOnly bool + +func init() { + rootCmd.AddCommand(updateCmd) + updateCmd.Flags().BoolVar(&checkOnly, "check", false, "Check only, don't update") +} + +func runUpdate(cmd *cobra.Command, args []string) error { + result := scanner.ScanSystem() + statuses := updater.CheckUpdates(result) + + if len(args) > 0 { + for _, u := range statuses { + if u.Tool == args[0] { + if u.NeedsUpdate { + fmt.Printf("%s: %s → %s\n", u.Tool, u.Current, u.Latest) + if !checkOnly { + updater.RunAutoUpdate([]updater.UpdateStatus{u}) + fmt.Println("Updated!") + } + } else { + fmt.Printf("%s is up to date (%s)\n", u.Tool, u.Current) + } + return nil + } + } + fmt.Printf("Tool '%s' not found\n", args[0]) + return nil + } + + fmt.Printf("%-15s %-10s %-10s %-10s\n", "Tool", "Current", "Latest", "Status") + fmt.Println("─────────────────────────────────────────") + hasUpdates := false + for _, u := range statuses { + status := "✓" + if u.NeedsUpdate { + status = "⟳ update" + hasUpdates = true + } + if u.Error != "" { + status = "✗ " + u.Error + } + fmt.Printf("%-15s %-10s %-10s %-10s\n", u.Tool, u.Current, u.Latest, status) + } + + if checkOnly { + return nil + } + + if hasUpdates { + toUpdate := make([]updater.UpdateStatus, 0) + for _, u := range statuses { + if u.NeedsUpdate { + toUpdate = append(toUpdate, u) + } + } + updater.RunAutoUpdate(toUpdate) + fmt.Println("\nUpdates applied.") + } else { + fmt.Println("\nAll tools are up to date.") + } + return nil +} \ No newline at end of file diff --git a/cmd/muyue/commands/version.go b/cmd/muyue/commands/version.go new file mode 100644 index 0000000..696b612 --- /dev/null +++ b/cmd/muyue/commands/version.go @@ -0,0 +1,23 @@ +package commands + +import ( + "fmt" + + "github.com/muyue/muyue/internal/version" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version", + RunE: runVersion, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func runVersion(cmd *cobra.Command, args []string) error { + fmt.Printf("Muyue version %s\n", version.Version) + return nil +} \ No newline at end of file diff --git a/cmd/muyue/main.go b/cmd/muyue/main.go index d9bfe7f..e969121 100644 --- a/cmd/muyue/main.go +++ b/cmd/muyue/main.go @@ -4,51 +4,12 @@ import ( "fmt" "os" - "github.com/muyue/muyue/internal/config" - "github.com/muyue/muyue/internal/desktop" - "github.com/muyue/muyue/internal/profiler" + "github.com/muyue/muyue/cmd/muyue/commands" ) func main() { - cfg := loadOrSetupConfig() - if err := desktop.Run(cfg, os.Args[1:]); err != nil { + if err := commands.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } -} - -func loadOrSetupConfig() *config.MuyueConfig { - if !config.Exists() { - fmt.Println("First time setup detected!") - cfg, err := profiler.RunFirstTimeSetup() - if err != nil { - fmt.Fprintf(os.Stderr, "Setup error: %v\n", err) - os.Exit(1) - } - - for i := range cfg.AI.Providers { - if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" { - key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name) - if err == nil && key != "" { - cfg.AI.Providers[i].APIKey = key - } - } - } - - if err := config.Save(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Save error: %v\n", err) - os.Exit(1) - } - - fmt.Println("\nSetup complete! Starting muyue...") - return cfg - } - - cfg, err := config.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Config load error: %v\n", err) - os.Exit(1) - } - - return cfg -} +} \ No newline at end of file diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..9205e47 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,914 @@ +# Muyue PRD v1.0 + +> **Author**: Product Architect +> **Date**: 2026-04-22 +> **Status**: Definitive + +--- + +## 1. Product Vision & Positioning + +### What is Muyue? + +Muyue is a local-first, single-binary development environment assistant that combines an AI orchestration layer, a tool manager, and a cyberpunk-themed desktop UI into one cohesive experience. It scans your system, installs missing tools, configures AI agent environments (MCP servers, LSPs, skills), and provides a Studio for AI-assisted workflows — all without requiring cloud infrastructure. + +### What problem does it solve? + +Developers spend significant time setting up and maintaining their dev environments: installing tools, configuring MCP servers for AI agents, managing API keys, and switching between CLI tools. Muyue eliminates this friction by providing a single interface that unifies environment management, AI orchestration, and terminal access. It is the "home base" for developers who use AI coding agents (Crush, Claude Code) daily. + +### Who is it for? + +- **Primary**: Solo developers and small teams who use AI coding agents (Crush, Claude Code) and want a unified control panel. +- **Secondary**: Developers setting up new machines who want a "one-click" environment bootstrap. +- **Not for**: Enterprise teams needing sandboxed environments (Daytona), container orchestration (DevPod), or MCP server registries (MCPM). + +### How is Muyue different? + +| Competitor | What they do | What Muyue does differently | +|---|---|---| +| **Daytona** | Cloud sandbox infrastructure for AI agents (sandboxes, snapshots, multi-tenant) | Muyue is local-first, lightweight, no infra required. Daytona is "cloud VMs for AI"; Muyue is "desktop control panel for your local AI agents". | +| **kasetto** | Declarative AI agent environment manager (Rust, CLI-only) | Muyue adds a desktop GUI, interactive workflows, and a terminal. kasetto is "Nix for AI tools"; Muyue is "a cockpit". | +| **OpenCode** | Terminal-based AI coding agent (Go, TUI, LSP+MCP client) | OpenCode is an AI coding agent itself. Muyue orchestrates agents (Crush, Claude) rather than being one. Muyue provides a desktop UI, tool management, and MCP config generation that OpenCode doesn't. | +| **DevPod** | Dev environment manager using devcontainers (Go, CLI+Desktop) | DevPod manages remote/container environments. Muyue manages your local machine's tools and AI agent configs. No container overlap. | +| **MCPM** | MCP server package manager (Python, CLI, registry) | Muyue generates MCP configs for Crush + Claude Code directly. Delegates server discovery to MCPM where needed. | +| **McpMux** | Desktop MCP gateway/router (Rust) | Muyue manages MCP configs per-tool rather than routing through a gateway. Simpler, no encryption layer needed for local use. | + +### What Muyue should NOT do (anti-scope) + +1. **Not a coding agent** — Muyue orchestrates agents (Crush, Claude Code); it does not edit files, run tests, or write code autonomously. The `crush_run` tool delegates to Crush. +2. **Not a sandbox/container manager** — No Docker orchestration, no VM provisioning. Use DevPod or Daytona for that. +3. **Not an MCP registry** — No server discovery marketplace. Delegate to MCPM for that. +4. **Not a CI/CD tool** — No build pipelines, no deployment workflows. +5. **Not a multi-tenant platform** — Single-user, local machine only. No org management, no billing. +6. **Not an IDE** — No file tree editor, no debugging, no syntax highlighting. Use VS Code, Zed, or Neovim. +7. **Not an LSP client** — Muyue installs and manages LSP servers; it does not connect to them as a client. The IDE handles that. +8. **Not a proxy/gateway** — No AI proxy agents, no request routing. The orchestrator talks directly to providers. + +--- + +## 2. Feature Matrix + +### P0 — Must Have for Launch + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 1 | System scanning (tools, runtimes, editors, shell, git) | P0 | **EXISTS** | KEEP | Scanner checks 14 tools, 8 runtimes, 8 editors, shell setup, git config. Has 5-min cache, JSON output. | +| 2 | Tool installation (crush, claude, bmad, starship, go, node, python, git, pnpm, uv, docker, gh) | P0 | **EXISTS** | KEEP | Installer handles 12 tools with platform-specific install methods (apt/brew/winget). API endpoint wired. | +| 3 | CLI subcommands (scan, install, update, setup, config, doctor, version, lsp, mcp, skills) | P0 | **EXISTS** | KEEP | Cobra-based CLI with all documented subcommands. Each has appropriate flags and output. | +| 4 | Desktop mode (HTTP server + embedded SPA) | P0 | **EXISTS** | KEEP | `desktop.go` serves frontend via `go:embed`, auto-opens browser, handles `--port` and `--no-open`. | +| 5 | AI orchestration (OpenAI-compatible, multi-provider) | P0 | **EXISTS** | KEEP | Orchestrator supports MiniMax, ZAI, Anthropic, OpenAI, Ollama. History management, tool calling loop. | +| 6 | Agent tools (10 tools: terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch) | P0 | **EXISTS** | KEEP | All 10 tools implemented with proper parameter validation, timeouts, and output truncation. | +| 7 | Tool execution endpoint | P0 | **EXISTS** | KEEP | `/api/tool/call` dispatches to agent registry for any registered tool. `/api/tools/list` returns all tools. | +| 8 | MCP server management (scan, configure, generate configs) | P0 | **EXISTS** | KEEP | Scans 12 known MCP servers, generates configs for Crush (`crush.json`) and Claude Code (`.claude.json`). | +| 9 | LSP server management (scan, install) | P0 | **EXISTS** | KEEP | 16 known LSP servers with install commands. `InstallForLanguages()` for batch installs. | +| 10 | Skills management (CRUD, deploy, built-in skills) | P0 | **EXISTS** | KEEP | 5 built-in skills (env-setup, git-workflow, api-design, debug-assist, code-review). YAML frontmatter format. Deploy to Crush + Claude Code. | +| 11 | Conversation persistence (JSON file store) | P0 | **EXISTS** | KEEP | `ConversationStore` with JSON persistence, auto-summarization at 80K tokens. | +| 12 | API key encryption (AES-256-GCM) | P0 | **EXISTS** | KEEP | `internal/secret/` with encrypt/decrypt. Keys encrypted at rest in config.yaml. | +| 13 | Config management (YAML, XDG paths, defaults) | P0 | **EXISTS** | KEEP | Full config schema with profile, AI providers, terminal, tools, SSH. Legacy migration from `~/.muyue`. | +| 14 | Studio tab (AI chat, SSE streaming, tool calls) | P0 | **EXISTS** | KEEP | Full chat UI with SSE streaming, tool call visualization, thinking blocks, markdown rendering. | +| 15 | Shell tab (PTY terminal, tabs, SSH connections) | P0 | **EXISTS** | KEEP | xterm.js with WebSocket PTY, tab management (max 7), SSH connection support, 6 terminal themes. | +| 16 | Config tab (profile, providers, theme, language, skills) | P0 | **EXISTS** | KEEP | Two-column layout with profile editing, provider management, key validation, terminal settings. | +| 17 | First-run profiling wizard (TUI) | P0 | **EXISTS** | KEEP | Charmbracelet/huh TUI wizard: name, pseudo, email, languages, editor, AI provider. Scored suggestions. | +| 18 | Onboarding wizard (web) | P0 | **EXISTS** | KEEP | React-based web wizard for desktop mode. | +| 19 | i18n (FR/EN, keyboard layout awareness) | P0 | **EXISTS** | KEEP | Full FR/EN translations, AZERTY/QWERTY/QWERTZ layouts affecting shortcut display. | +| 20 | Theming (4 cyberpunk themes, CSS custom properties) | P0 | **EXISTS** | KEEP | 4 themes (Red, Pink, Blue, Green) with 30+ CSS variables. Runtime injection. | +| 21 | Workflow engine (Plan→Execute) | P0 | **EXISTS** | KEEP | State machine with steps (tool_call, condition, approval). JSON persistence. SSE streaming execution. | + +### P0 — Needs Implementation/Completion + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 22 | Dashboard tab (tools grid, notifications, quick actions) | P0 | **PARTIAL** | KEEP, BUILD | Currently shows empty workflow/activity placeholders. Needs: tools grid with status badges, update notifications, quick actions (install missing, check updates, rescan, configure MCP). | +| 23 | Shell AI panel (real AI backend) | P0 | **EXISTS** | KEEP | Was fake, now uses `/api/shell/chat` with real AI backend + tool calling. Functional. | +| 24 | Tool updates (check + auto-update) | P0 | **EXISTS** | KEEP | `internal/updater/` checks versions and runs auto-updates. API + CLI endpoints wired. | + +### P1 — Post-Launch + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 25 | AI-generated skills (via Studio chat) | P1 | **STUBBED** | KEEP | `BuildAIGeneratePrompt()` exists but CLI `skills generate` is a stub. Need to wire to orchestrator. | +| 26 | SSH test connectivity | P1 | **STUBBED** | KEEP | `handleSSHTest()` returns "not implemented". Add `net.DialTimeout` check. | +| 27 | Conversation list/switch (multiple conversations) | P1 | **PARTIAL** | KEEP | `/api/conversations` list + delete exist. No create/switch/load. Need multi-conversation support in Studio. | +| 28 | Dashboard activity log with real events | P1 | **MISSING** | KEEP | Wire install/scan/update events to a notification system that Dashboard renders. | +| 29 | Starship prompt integration (multi-theme) | P1 | **EXISTS** | KEEP | 3 theme configs (charm, zerotwo, default). `handleApplyStarshipTheme` writes TOML + patches RC files. | +| 30 | Terminal settings persistence (font, theme) | P1 | **EXISTS** | KEEP | Settings saved to config, loaded on startup. | + +### P2 — Nice-to-Have + +| # | Feature | Priority | Status | Decision | Description | +|---|---------|----------|--------|----------|-------------| +| 31 | Background daemon (`internal/daemon/`) | P2 | **MISSING** | DEFER | README mentions it. Not needed for launch. Tools can run on-demand. | +| 32 | HTML preview server (`internal/preview/`) | P2 | **MISSING** | DROP | Use browser or IDE instead. Not Muyue's job. | +| 33 | AI proxy agents (`internal/proxy/`) | P2 | **MISSING** | DROP | Direct provider communication is sufficient. No proxy layer needed. | + +### DROPPED + +| # | Feature | Reason | Replacement | +|---|---------|--------|-------------| +| 34 | HTML preview server | Not core value. IDEs handle this. | Browser / VS Code Live Preview | +| 35 | AI proxy agents | Adds complexity without benefit for local-first tool. | Direct provider API calls | +| 36 | MCP server registry / marketplace | Out of scope. | MCPM (`mcpm install `) | +| 37 | Sandboxed code execution | Out of scope. Requires infra. | Daytona sandboxes | +| 38 | Dev container management | Out of scope. | DevPod | +| 39 | Full IDE features (file tree, debugger) | Out of scope. | VS Code / Zed / Neovim | +| 40 | LSP client mode (connecting to LSPs) | Out of scope. Muyue installs LSPs, doesn't consume them. | IDE handles LSP client | + +--- + +## 3. User Flows + +### 3.1 First-Time User Opens `muyue` + +``` +1. User runs `muyue` (or downloaded binary) +2. No config exists → loadOrSetupConfig() detects first run +3. Profiler TUI wizard launches: + a. Asks: name, pseudo, email + b. Scans system → detects languages → shows scored language options + c. Detects editors → shows scored editor options + d. Shows AI provider options → user picks one +4. If selected provider has no API key → asks for key (masked input) +5. Config saved to ~/.config/muyue/config.yaml (API key encrypted) +6. Built-in skills installed to ~/.muyue/skills/ +7. MCP configs generated for Crush + Claude Code +8. Desktop server starts on port 8080 +9. Browser opens to http://127.0.0.1:8080 +10. Onboarding wizard checks if profile is empty → shows web wizard as fallback +11. Dashboard tab loads → shows tools grid (some installed, some missing) +``` + +**Edge cases:** +- Config file exists but is corrupted → show error, offer `muyue setup` to recreate +- No internet → profiler still works (local scan only), AI features unavailable +- API key invalid → doctor command detects, Config tab shows "Invalid key" badge + +### 3.2 Returning User Opens `muyue` + +``` +1. User runs `muyue` +2. Config exists → loads from ~/.config/muyue/config.yaml +3. Desktop server starts → browser opens (or reconnects) +4. Previous conversation loaded from conversation.json +5. Dashboard shows current tool status (cached, 5-min TTL) +6. If checkOnStart=true → background update check runs +7. User picks up where they left off +``` + +### 3.3 User Installs a Missing Tool from Dashboard + +``` +1. Dashboard shows tools grid with status badges: + - Green ✓ = installed + - Red ✗ = missing + - Yellow ⟳ = update available +2. User clicks "Install" on a missing tool (e.g., "pnpm") +3. Frontend calls POST /api/install {"tools": ["pnpm"]} +4. Backend spawns installer.InstallTool("pnpm") in goroutine +5. Installer checks if already installed → if yes, returns success +6. Installer runs `npm install -g pnpm` +7. Result returned: {"status": "done", "tools": ["pnpm"], "results": [{...}]} +8. Frontend updates tool status badge to green ✓ +9. Activity log entry added: "Installed pnpm" +10. System scan cache invalidated +``` + +**Edge cases:** +- Install fails (permission denied) → show error in results, suggest `sudo` or manual install +- Tool requires Node.js but Node isn't installed → installer returns "npx not found, install node first" +- Multiple tools installed in parallel → `sync.WaitGroup` handles concurrent installs + +### 3.4 User Starts a Chat in Studio + +``` +1. User clicks Studio tab (Ctrl+2) +2. Chat history loaded from /api/chat/history +3. If no history → welcome message shown +4. User types message in textarea, presses Enter +5. Frontend calls POST /api/chat {"message": "...", "stream": true} +6. SSE connection opens +7. Backend: + a. Adds message to conversation store + b. Checks if summarization needed (>80K tokens) + c. Creates orchestrator with active provider + d. Sets system prompt (Studio prompt with agent context) + e. Sets tools (all 10 agent tools as OpenAI function specs) + f. Sends to AI provider API +8. Streaming begins: + a. Content chunks → SSE {"content": "char"} events + b. Tool calls → SSE {"tool_call": {...}} events + c. Tool results → SSE {"tool_result": {...}} events + d. Max 15 tool iterations +9. Frontend renders: + a. Text content streamed character-by-character + b. Tool calls shown as expandable blocks with icon + status + c. Thinking blocks (if any) shown with spinner +10. Final response stored in conversation +11. SSE {"done": "true"} closes stream +``` + +**Edge cases:** +- AI provider returns error → SSE error event, shown as red message +- Tool execution times out → error result returned to AI, may retry +- No active provider configured → 503 error, redirect to Config tab +- API key invalid → 401 error, show "Configure your API key" prompt + +### 3.5 User Runs a Plan→Execute Workflow + +``` +1. User types `/plan Set up a Go project with Docker` in Studio +2. Frontend calls POST /api/workflow/plan {"goal": "Set up a Go project..."} +3. Backend: + a. Creates Planner with AI orchestrator + b. Sends goal to AI with planning prompt + c. AI generates JSON array of steps + d. Planner parses response into []Step + e. Workflow Engine creates workflow with steps +4. Workflow returned to frontend with ID and steps +5. Frontend shows workflow panel: + - Step 1: "Check Go installation" → tool: terminal, args: {command: "go version"} + - Step 2: "Create project directory" → tool: terminal, args: {command: "mkdir -p ..."} + - Step 3: "Initialize Go module" → tool: terminal, args: {command: "go mod init ..."} + - etc. +6. User clicks "Execute" +7. Frontend calls POST /api/workflow/execute/{id}?stream=true +8. SSE stream: + a. Each step: {"event": "started", "step": {...}} + b. On completion: {"event": "done", "step": {...}} + c. On failure: {"event": "failed", "step": {...}} + d. If approval step: {"event": "awaiting_approval", "step": {...}} +9. User can approve/skip steps via POST /api/workflow/approve/{id} +10. Final event: {"event": "workflow_done", "status": "done|failed"} +``` + +**Edge cases:** +- AI generates invalid JSON → planner returns error, shown in chat +- Step fails mid-workflow → remaining steps skipped, workflow marked "failed" +- Approval step → execution pauses until user approves +- Workflow exceeds 10 steps → planner prompt limits to 10 + +### 3.6 User Opens Shell, Connects via SSH + +``` +1. User clicks Shell tab (Ctrl+3) +2. Default "Local Shell" tab created with xterm.js terminal +3. WebSocket connects to /api/ws/terminal with {type: "shell", data: ""} +4. Backend creates PTY via creack/pty, pipes I/O through WebSocket +5. User sees their shell prompt (starship if configured) +6. To add SSH tab: + a. User clicks "+" → dropdown shows: + - System terminals (zsh, bash, fish) + - Saved SSH connections (from config) + - "Add SSH connection" button + b. User selects saved connection or adds new one + c. New tab created with {type: "ssh", data: JSON.stringify({host, port, user, key_path})} + d. Backend establishes SSH connection, creates PTY + e. Tab shows connected indicator (green dot) +7. User can rename tabs (double-click), close tabs (×), switch with Alt+1-7 +8. AI assistant panel on right: + a. User types question + b. Frontend calls POST /api/shell/chat with message + terminal context + c. AI responds with shell-aware answers (commands, explanations) + d. Can execute tools (terminal, read_file, etc.) to help user +``` + +**Edge cases:** +- SSH connection fails → tab shows "Connection error" in terminal +- WebSocket disconnects → terminal shows "Connection closed" message +- Tab limit (7) reached → "+" button disabled +- SSH key not found → connection fails, suggest key path + +### 3.7 User Changes AI Provider + +``` +1. User clicks Config tab (Ctrl+4) +2. "AI Providers" panel shows list of providers: + - MiniMax (active) — Key configured ✓ + - Z.AI — No key + - Anthropic — No key + - OpenAI — No key + - Ollama — No key (local) +3. User clicks "Configure" on Anthropic +4. Modal opens with fields: API Key, Model, Base URL +5. User enters API key +6. User clicks "Validate" → POST /api/providers/validate +7. Backend sends test request to Anthropic API with key +8. Response: {"status": "valid"} or error +9. User clicks "Activate" → provider set active, others deactivated +10. Config saved → new orchestrator instances use Anthropic +``` + +**Edge cases:** +- Key validation fails → show "Invalid key" badge, don't save +- No internet → validation times out, show "Connection failed" +- Ollama selected but not running → user sees local URL, no validation needed +- Switching provider mid-conversation → new messages use new provider, old messages preserved + +### 3.8 User Manages MCP Servers + +``` +1. User opens Config tab → MCP section + OR: User runs `muyue mcp scan` from CLI +2. System scans 12 known MCP servers (filesystem, github, git, fetch, memory, etc.) +3. Each server shows: name, category, installed status (npx available) +4. User clicks "Configure MCP" → POST /api/mcp/configure +5. Backend: + a. Generates MCP config for Crush: ~/.config/crush/crush.json → {"mcps": {...}} + b. Generates MCP config for Claude Code: ~/.claude.json → {"mcpServers": {...}} + c. Core servers: filesystem, fetch, memory + d. Provider-specific: minimax-web-search, minimax-image (if API key set) + e. Claude-specific: sequential-thinking +6. Configs written with 0600 permissions +``` + +**Edge cases:** +- Existing configs not overwritten (merged) — `writeMCPConfig` merges into existing JSON +- No API key for provider-specific servers → those servers omitted +- Crush or Claude Code not installed → configs still generated (for when they are installed) + +### 3.9 User Manages LSP Servers + +``` +1. User runs `muyue lsp scan` from CLI + OR: views LSP section in Config tab +2. System checks 16 known LSP servers +3. Each shows: name, language, command path, installed status +4. User installs specific LSP: + - CLI: `muyue lsp install gopls` + - API: POST /api/lsp/install {"name": "gopls"} +5. Backend runs install command (e.g., `go install golang.org/x/tools/gopls@latest`) +6. Result: success or error +``` + +**Edge cases:** +- LSP has no auto-install command (e.g., clangd) → return "install manually" message +- Install fails (network error) → show error, suggest retry +- Language mapping: TypeScript installs 4 servers (TS, JSON, HTML, CSS) + +### 3.10 User Creates/Deploys a Skill + +``` +1. User runs `muyue skills init` → installs 5 built-in skills to ~/.muyue/skills/ +2. User creates custom skill: + - Manually: create ~/.muyue/skills/my-skill/SKILL.md with YAML frontmatter + - CLI: `muyue skills generate my-skill "Does X for Y" crush` + - API: POST /api/skills (via Config tab or Studio chat) +3. SKILL.md format: + ```yaml + --- + name: my-skill + description: What it does + author: username + version: 1.0.0 + target: crush|claude|both + tags: [tag1, tag2] + --- + # Skill instructions in markdown + ``` +4. Deploy: `muyue skills deploy` +5. Skill copied to: + - Crush: ~/.config/crush/skills/my-skill/SKILL.md + - Claude Code: ~/.claude/skills/my-skill/SKILL.md +``` + +**Edge cases:** +- Skill already exists at target → overwritten +- Target is "both" → deployed to both Crush and Claude +- Delete removes from all locations (source + targets) + +### 3.11 User Runs `muyue scan` from CLI + +``` +1. User runs `muyue scan` +2. Scanner runs full system scan (tools, runtimes, shell, git) +3. Output: formatted table with columns: Tool, Version, Status, Path +4. Summary line: "Installed: 8/14" +5. With --json flag: full JSON output +``` + +### 3.12 User Runs `muyue doctor` from CLI + +``` +1. User runs `muyue doctor` +2. Three checks run: + a. System scan → shows installed/missing tools + b. Config check → loads config, validates profile + c. Connectivity check → HEAD requests to AI provider endpoints +3. Output: diagnostic report with ✓/✗ indicators +4. User sees what's broken and can take action +``` + +--- + +## 4. API Contract + +### Existing Endpoints (37 routes) + +| Method | Path | Request Body | Response Body | Status | +|--------|------|-------------|---------------|--------| +| GET | `/api/info` | — | `{name, version, author}` | EXISTS | +| GET | `/api/system` | — | `{system: {os, arch, platform, shell, ...}}` | EXISTS | +| GET | `/api/tools` | — | `{tools: [{name, installed, version, path}], total}` | EXISTS | +| GET | `/api/config` | — | `{profile, terminal, bmad}` | EXISTS | +| GET | `/api/providers` | — | `{providers: [{name, model, active, ...}]}` | EXISTS | +| GET | `/api/skills` | — | `{skills: [...], count}` | EXISTS | +| GET | `/api/lsp` | — | `{servers: [{name, language, command, installed}]}` | EXISTS | +| GET | `/api/mcp` | — | `{servers: [...], configured}` | EXISTS | +| GET | `/api/updates` | — | `{updates: [{tool, current, latest, needsUpdate}]}` | EXISTS | +| GET | `/api/editors` | — | `{editors: [{name, installed, version, path}]}` | EXISTS | +| GET | `/api/terminal/sessions` | — | `{ssh: [...], system: [...]}` | EXISTS | +| GET | `/api/terminal/themes` | — | `{themes: [{id, name}]}` | EXISTS | +| GET | `/api/chat/history` | — | `{messages: [...], tokens}` | EXISTS | +| GET | `/api/tools/list` | — | `{tools: [...], count}` | EXISTS | +| GET | `/api/workflow/list` | — | `{workflows: [...], count}` | EXISTS | +| GET | `/api/workflow/{id}` | — | `{id, name, steps, status, ...}` | EXISTS | +| GET | `/api/conversations` | — | `{conversations: [...]}` | EXISTS | +| GET | `/api/ssh/connections` | — | `{connections: [...]}` | EXISTS | +| POST | `/api/scan` | — | `{status: "ok"}` | EXISTS | +| POST | `/api/install` | `{tools: [string]}` | `{status, tools, results: [{tool, success, message}]}` | EXISTS | +| POST | `/api/mcp/configure` | — | `{status: "ok"}` | EXISTS | +| POST | `/api/terminal` | `{command, cwd}` | `{output, error}` | EXISTS | +| POST | `/api/chat` | `{message, stream}` | SSE stream or `{content}` | EXISTS | +| POST | `/api/chat/clear` | — | `{status: "ok"}` | EXISTS | +| POST | `/api/tool/call` | `{tool, args}` | `{success, tool, result, error}` | EXISTS | +| POST | `/api/shell/chat` | `{message, context, history, cwd, platform, stream}` | SSE stream or `{content, tool_calls}` | EXISTS | +| POST | `/api/workflow` | `{name, description, type}` | `{id, name, steps, status, ...}` | EXISTS | +| POST | `/api/workflow/plan` | `{goal}` | `{id, name, steps, status, ...}` | EXISTS | +| POST | `/api/workflow/execute/{id}` | `?stream=true` optional | SSE stream or workflow object | EXISTS | +| POST | `/api/workflow/approve/{id}` | `{step_id}` | `{status: "approved"}` | EXISTS | +| POST | `/api/lsp/install` | `{name}` | `{success, server}` or `{success, error}` | EXISTS | +| POST | `/api/skills/deploy` | `{name}` optional | `{status, skill}` | EXISTS | +| POST | `/api/config/reset` | — | `{status: "ok"}` | EXISTS | +| POST | `/api/providers/validate` | `{name, api_key, model, base_url}` | `{status: "valid"}` or error | EXISTS | +| POST | `/api/update/run` | `{tool}` optional | `{status, updated}` or `{status, tool}` | EXISTS | +| POST | `/api/ssh/test` | `{host, port, user}` | `{success, message}` (stubbed) | PARTIAL | +| POST | `/api/starship/apply-theme` | `{theme}` | `{status, config}` | EXISTS | +| PUT | `/api/preferences` | `{language, keyboard_layout}` | `{status: "ok"}` | EXISTS | +| PUT | `/api/config/profile` | `{name, pseudo, email, editor, shell}` | `{status: "ok"}` | EXISTS | +| PUT | `/api/config/provider` | `{name, api_key, model, base_url, active}` | `{status: "ok"}` | EXISTS | +| PUT | `/api/terminal/settings` | `{font_size, font_family, theme}` | `{status, theme}` | EXISTS | +| DELETE | `/api/conversations/{id}` | — | `{status: "deleted"}` | EXISTS | +| DELETE | `/api/terminal/sessions/{name}` | — | (removes SSH connection) | EXISTS | +| WS | `/api/ws/terminal` | `{type, data}` | `{type, data}` | EXISTS | + +### Error Response Format (all endpoints) + +```json +{"error": "Human-readable error message"} +``` + +HTTP status codes: 400 (bad request), 401 (unauthorized), 404 (not found), 405 (method not allowed), 500 (internal), 503 (service unavailable — AI provider not configured). + +### SSE Event Format + +``` +data: {"content": "character"} +data: {"tool_call": {"tool_call_id": "...", "name": "...", "args": "..."}} +data: {"tool_result": {"tool_call_id": "...", "content": "...", "is_error": false}} +data: {"done": "true"} +``` + +--- + +## 5. CLI Contract + +### Root Command + +``` +muyue Launch desktop app (opens browser) +muyue --port=8080 Launch on specific port +muyue --no-open Launch without opening browser +``` + +### Subcommands + +| Command | Flags | Output | Status | +|---------|-------|--------|--------| +| `muyue scan` | `--json` | Table or JSON of tools/runtimes | EXISTS | +| `muyue install [tool]` | `--yes` | Install progress per tool | EXISTS | +| `muyue update [tool]` | `--check` | Table of versions + status | EXISTS | +| `muyue setup` | — | Interactive TUI wizard | EXISTS | +| `muyue config` | — | (subcommand stub) | PARTIAL | +| `muyue doctor` | — | Diagnostic report | EXISTS | +| `muyue version` | — | `Muyue version X.Y.Z` | EXISTS | +| `muyue lsp scan` | — | Table of LSP servers | EXISTS | +| `muyue lsp install ` | — | Install progress | EXISTS | +| `muyue mcp config` | — | Confirmation message | EXISTS | +| `muyue mcp scan` | — | Table of MCP servers | EXISTS | +| `muyue skills list` | — | Table of skills | EXISTS | +| `muyue skills init` | — | Confirmation | STUBBED | +| `muyue skills show ` | — | Skill details | EXISTS | +| `muyue skills generate ` | — | (stub) | STUBBED | +| `muyue skills deploy` | — | Confirmation | EXISTS | +| `muyue skills delete ` | — | Confirmation | EXISTS | + +### CLI Commands Needing Work + +| Command | Issue | Fix | +|---------|-------|-----| +| `muyue config` | No subcommands (get/set are defined but not registered) | Register `config get ` and `config set ` as subcommands | +| `muyue skills init` | Just prints message, doesn't call `skills.InstallBuiltinSkills()` | Wire to actual function | +| `muyue skills generate` | Just prints message, doesn't call AI | Wire to orchestrator | +| `muyue install` | Passes `nil` config to installer | Pass loaded config | + +--- + +## 6. Data Model + +### 6.1 Config YAML Schema (`~/.config/muyue/config.yaml`) + +```yaml +version: "0.2.1" + +profile: + name: "Augustin" + pseudo: "muyue" + email: "augustin@example.com" + languages: ["go", "typescript", "python"] + preferences: + editor: "nvim" + shell: "zsh" + theme: "cyberpunk-red" # cyberpunk-red | cyberpunk-pink | midnight-blue | matrix-green + default_ai: "minimax" + auto_update: true + check_on_start: true + language: "fr" # fr | en + keyboard_layout: "azerty" # azerty | qwerty | qwertz + +ai: + providers: + - name: "minimax" + api_key: "enc:AES256GCM..." # encrypted at rest + base_url: "https://api.minimax.io/v1" + model: "MiniMax-M2.7" + active: true + - name: "zai" + model: "glm" + active: false + - name: "anthropic" + api_key: "enc:AES256GCM..." + model: "claude-sonnet-4-20250514" + active: false + - name: "openai" + api_key: "enc:AES256GCM..." + base_url: "https://api.openai.com/v1" + model: "gpt-4o" + active: false + - name: "ollama" + model: "llama3" + base_url: "http://localhost:11434/api" + active: false + +tools: + - name: "crush" + installed: true + version: "v1.2.3" + auto_update: true + +bmad: + installed: true + version: "latest" + global: true + +terminal: + custom_prompt: true + prompt_theme: "zerotwo" # charm | zerotwo | default + ssh: + - name: "prod-server" + host: "192.168.1.100" + port: 22 + user: "deploy" + key_path: "~/.ssh/id_rsa" + font_size: 14 + font_family: "'JetBrains Mono', monospace" + theme: "default" # default | monokai | gruvbox | nord | solarized-dark | dracula +``` + +### 6.2 Conversation JSON Schema (`~/.config/muyue/conversation.json`) + +```json +{ + "messages": [ + { + "id": "20260422150000.000-1234567890", + "role": "user|assistant|system", + "content": "message text or JSON-encoded {content, tool_calls}", + "time": "2026-04-22T15:00:00Z" + } + ], + "summary": "Auto-generated conversation summary when >80K tokens", + "created_at": "2026-04-22T15:00:00Z", + "updated_at": "2026-04-22T15:30:00Z" +} +``` + +### 6.3 Skill SKILL.md Format + +```markdown +--- +name: skill-name +description: What this skill does +author: muyue +version: 1.0.0 +target: both # crush | claude | both +tags: [tag1, tag2] +--- + +# Skill Title + +Instructions for the AI agent in markdown. +Includes: when to activate, step-by-step instructions, examples, error handling. +``` + +### 6.4 MCP Config JSON Format + +**For Crush** (`~/.config/crush/crush.json`): +```json +{ + "mcps": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"] + }, + "fetch": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-fetch"] + } + } +} +``` + +**For Claude Code** (`~/.claude.json`): +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"] + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + } + } +} +``` + +### 6.5 Workflow JSON Schema (`~/.config/muyue/workflows.json`) + +```json +[ + { + "id": "wf-1234567890", + "name": "Plan: Set up Go project", + "description": "Full goal description", + "type": "plan_execute", + "steps": [ + { + "id": "step-0", + "name": "Check Go installation", + "type": "tool_call", + "tool": "terminal", + "args": {"command": "go version"}, + "status": "pending|running|done|failed|awaiting_approval|skipped", + "result": "", + "error": "", + "depends_on": [], + "started_at": null, + "ended_at": null + } + ], + "status": "pending|running|done|failed", + "created_at": "2026-04-22T15:00:00Z", + "updated_at": "2026-04-22T15:00:00Z" + } +] +``` + +--- + +## 7. Technical Decisions + +### 7.1 CLI Framework: **Keep Cobra** ✓ + +**Decision**: Keep `spf13/cobra` (already in `go.mod`, already used for all 11 subcommands). + +**Rationale**: Cobra is the de-facto standard for Go CLIs. All commands are already implemented. No benefit to switching to `urfave/cli`. + +### 7.2 HTTP Router: **Keep stdlib `http.ServeMux`** ✓ + +**Decision**: Keep `net/http.ServeMux`. Do NOT add chi, echo, or gin. + +**Rationale**: +- 37 routes registered. Stdlib handles this fine. +- Go 1.22+ `ServeMux` supports method-based routing (`GET /api/foo`). +- Adding a framework adds a dependency and learning curve for no benefit. +- Performance is irrelevant at localhost scale. + +**One improvement**: Use Go 1.22 method-based patterns to clean up manual method checks: +```go +mux.HandleFunc("GET /api/tools", s.handleTools) +mux.HandleFunc("POST /api/install", s.handleInstall) +``` + +### 7.3 WebSocket: **Keep gorilla/websocket** ✓ + +**Decision**: Keep `gorilla/websocket` for terminal PTY. + +**Rationale**: Already working for terminal WebSocket. Only used for one endpoint (`/api/ws/terminal`). No need for a framework. + +### 7.4 Frontend Framework: **Keep vanilla React** ✓ + +**Decision**: Keep React 19 + vanilla state management. Do NOT add zustand or react-query. + +**Rationale**: +- 4 components, ~1200 lines total. State is simple (tab switching, form inputs, chat messages). +- Adding zustand/redux would be over-engineering for this scale. +- `useState` + `useCallback` + `useRef` is sufficient. +- SSE handling is custom and wouldn't benefit from react-query. + +**One consideration**: If Dashboard grows complex (many sub-components), extract a `useApi` custom hook pattern for data fetching. + +### 7.5 Async Operations: **SSE for everything** ✓ + +**Decision**: Use SSE (Server-Sent Events) for all streaming operations (chat, workflow execution). Use synchronous JSON for non-streaming operations (install, scan). + +**Rationale**: +- SSE is already implemented for chat and workflow execution. +- Install operations are fast enough to be synchronous (wait for all goroutines, return results). +- No polling needed. +- WebSocket only for terminal PTY (bidirectional needed). + +### 7.6 Workflow Engine: **State machine** ✓ + +**Decision**: Keep the current state machine approach. Do NOT convert to a DAG. + +**Rationale**: +- Plans are linear sequences (step 1 → step 2 → step 3). +- Dependencies are simple (wait for previous step). +- DAG adds complexity (topological sort, parallel execution) for no benefit. +- The current `depends_on` field supports basic ordering. Parallel execution can be added later if needed via `TypeParallel` step type (already defined but not implemented). + +### 7.7 Styling: **Keep CSS custom properties** ✓ + +**Decision**: Keep CSS custom properties + 4 theme objects. Do NOT add Tailwind or CSS-in-JS. + +**Rationale**: +- 30+ CSS variables already define the full theme system. +- Theme switching works by setting `document.documentElement.style.setProperty()`. +- Adding Tailwind would conflict with the existing CSS architecture. +- Current CSS is ~1000 lines and well-structured. + +--- + +## 8. Delegation Strategy + +### What Muyue delegates to existing tools + +| Feature | Delegated To | Integration Method | UI | +|---------|-------------|-------------------|-----| +| **Code editing / AI coding** | Crush (`crush run`) | `crush_run` agent tool → spawns `crush run ` | Studio chat invokes tool, Shell AI panel invokes tool | +| **Code editing / AI coding** | Claude Code | Skills deployed to `~/.claude/skills/` | Config tab shows deployment status | +| **MCP server discovery** | MCPM (`mcpm`) | CLI passthrough suggestion | Doctor command suggests `mcpm install ` if server missing | +| **MCP server routing** | McpMux | Not needed | Muyue generates per-tool configs directly | +| **Dev environments / containers** | DevPod | CLI passthrough suggestion | Doctor suggests DevPod if container needed | +| **IDE features** | VS Code / Zed / Neovim | Config integration (editor preference) | Config tab sets editor, LSPs installed for editor | +| **Terminal prompt** | Starship | Config generation (`starship.toml` + RC file patching) | Config tab applies themes | +| **Git operations** | `git` CLI | Agent `terminal` tool runs git commands | Studio / Shell AI can execute git commands | + +### Integration Patterns + +1. **Config Generation** (primary pattern): Muyue generates config files for external tools (Crush `crush.json`, Claude `.claude.json`, Starship `starship.toml`). This is the cleanest integration — no API coupling, no version lock-in. + +2. **CLI Wrapping**: Muyue invokes external CLIs (`crush run`, `git`, `go install`) through the agent `terminal` tool. Stdout/stderr captured and returned to AI. + +3. **Suggestion**: Muyue suggests tools the user should install separately (MCPM, DevPod) but doesn't wrap them. `muyue doctor` output includes recommendations. + +4. **Skills Deployment**: Muyue's skills system deploys SKILL.md files to both Crush and Claude Code directories. Both tools natively understand this format. + +--- + +## 9. Implementation Priority + +### Phase 1: Dashboard Completion (P0 gap) + +The only significant P0 gap is the Dashboard. Current state: empty placeholders. + +**Dashboard must have:** +1. **Tools Grid** — Cards for each scanned tool showing name, status badge (installed/missing/update), version, install button +2. **Quick Actions** — Buttons: "Install missing tools", "Check for updates", "Rescan system", "Configure MCP" +3. **Update Notifications** — List of tools with available updates, with "Update" buttons +4. **Activity Log** — Scrollable list of recent events (installs, scans, config changes) with timestamps + +**Implementation approach:** +- Fetch from `/api/tools`, `/api/updates`, `/api/editors` on mount +- Quick actions call existing API endpoints (`POST /api/install`, `POST /api/scan`, `POST /api/mcp/configure`) +- Activity log: client-side event accumulation (no backend change needed for MVP) + +### Phase 2: CLI Polish (P0 gaps) + +1. Wire `muyue skills init` to `skills.InstallBuiltinSkills()` +2. Wire `muyue skills generate` to orchestrator +3. Register `muyue config get` and `muyue config set` subcommands +4. Pass loaded config to installer in `muyue install` + +### Phase 3: P1 Features + +1. AI-generated skills via Studio chat +2. SSH connectivity test +3. Multi-conversation support in Studio +4. Real event-based activity log + +--- + +## 10. Architecture Summary + +``` +┌─────────────────────────────────────────────────────────┐ +│ Browser (React SPA) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │Dashboard │ │ Studio │ │ Shell │ │ Config │ │ +│ │(tools, │ │(AI chat, │ │(xterm.js,│ │(profile, │ │ +│ │ updates, │ │ tool │ │ WS PTY, │ │provider, │ │ +│ │ actions) │ │ calls) │ │ AI panel)│ │ theme) │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└──────────────────────┬──────────────────────────────────┘ + │ HTTP/SSE/WS +┌──────────────────────┮──────────────────────────────────┐ +│ Go HTTP Server │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ api.Server (37 routes) │ │ +│ │ /api/chat → SSE stream + tool calling loop │ │ +│ │ /api/shell/chat → SSE stream + tool calling loop │ │ +│ │ /api/ws/terminal → WebSocket PTY │ │ +│ │ /api/install → parallel tool installation │ │ +│ │ /api/workflow/* → CRUD + plan + execute │ │ +│ └────────────────────────────────────────────────────┘ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │ +│ │Scanner │ │ Installer │ │ Updater │ │ MCP │ │ +│ │(14 tools,│ │(12 tools, │ │(version │ │(12 known │ │ +│ │ 8 runts, │ │ platform- │ │ check + │ │ servers, │ │ +│ │ 8 edtrs) │ │ specific) │ │ auto-upd)│ │ config │ │ +│ └──────────┘ └────────────┘ └──────────┘ │ gen) │ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────┐ └──────────┘ │ +│ │ LSP │ │ Skills │ │ Workflow │ │ +│ │(16 known │ │(CRUD + │ │(Plan→ │ │ +│ │ servers) │ │ deploy + │ │ Execute │ │ +│ │ │ │ builtins) │ │ engine) │ │ +│ └──────────┘ └────────────┘ └──────────┘ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │ +│ │Orchestrtr│ │ Agent │ │ Secret │ │ Config │ │ +│ │(OpenAI- │ │ Registry │ │(AES-256- │ │(YAML, │ │ +│ │ compat, │ │(10 tools: │ │ GCM key │ │ XDG, │ │ +│ │ multi- │ │ terminal, │ │ encrypt) │ │ encrypted│ │ +│ │ provider)│ │ files, │ │ │ │ API keys)│ │ +│ │ │ │ grep, etc)│ │ │ │ │ │ +│ └──────────┘ └────────────┘ └──────────┘ └──────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ + â–Œ + ┌─────────────────────────┐ + │ External Tools/Agents │ + │ Crush, Claude Code, │ + │ Starship, MCP servers │ + └─────────────────────────┘ +``` + +### Key Dependencies + +| Dependency | Version | Purpose | +|-----------|---------|---------| +| `spf13/cobra` | v1.10.2 | CLI framework | +| `charmbracelet/huh` | v1.0.0 | TUI forms (profiler, API key input) | +| `charmbracelet/bubbletea` | v1.3.10 | TUI framework (indirect) | +| `gorilla/websocket` | v1.5.3 | Terminal WebSocket | +| `creack/pty/v2` | v2.0.1 | PTY for terminal | +| `gopkg.in/yaml.v3` | v3.0.1 | Config serialization | +| React 19 | — | Frontend UI | +| Vite 8 | — | Frontend build | +| xterm.js | — | Terminal emulator component | + +### File Count Summary + +| Layer | Files | Lines (approx) | +|-------|-------|---------------| +| Go backend (`internal/`) | 41 `.go` files | ~8,000 | +| CLI commands (`cmd/`) | 12 `.go` files | ~600 | +| Frontend (`web/src/`) | ~20 files | ~3,500 | +| CSS (`web/src/styles/`) | 1 file | ~1,500 | +| **Total** | ~75 files | ~13,600 | + +--- + +## 11. Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| AI provider API changes break orchestrator | Studio/Shell chat stops working | Orchestrator uses OpenAI-compatible format (widely supported). Fallback: user switches provider. | +| Tool install commands change (brew, apt) | Installer fails | Installer returns clear error messages. Doctor command diagnoses. User can install manually. | +| Frontend grows beyond vanilla React manageability | Hard to maintain | At current scale (4 components), this is not a risk. Re-evaluate if components exceed 20. | +| Security: API keys in config file | Key exposure | AES-256-GCM encryption at rest. Config file permissions 0600. | +| Terminal WebSocket security | Remote command execution | Server binds to 127.0.0.1 only. No remote access possible. | + +--- + +*End of Muyue PRD v1.0* diff --git a/go.mod b/go.mod index fdedab1..7dc8717 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/creack/pty/v2 v2.0.1 github.com/gorilla/websocket v1.5.3 + github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -28,6 +29,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -37,6 +39,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // 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/text v0.23.0 // indirect diff --git a/go.sum b/go.sum index 52799d5..3ab332e 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,7 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k= @@ -52,6 +53,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -70,8 +73,14 @@ 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/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= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/api/conversation.go b/internal/api/conversation.go index 3676f79..505d7cb 100644 --- a/internal/api/conversation.go +++ b/internal/api/conversation.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" "unicode/utf8" @@ -36,6 +37,19 @@ type ConversationStore struct { conv *Conversation } +type TokenCount struct { + total int + byRole map[string]int + byMessage int +} + +type SearchResult struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Time string `json:"time"` +} + func NewConversationStore() *ConversationStore { dir, err := config.ConfigDir() if err != nil { @@ -140,19 +154,109 @@ func (cs *ConversationStore) TrimOld(keepCount int) { } func (cs *ConversationStore) ApproxTokenCount() int { + return cs.ApproxTokenCountDetailed().total +} + +func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount { cs.mu.RLock() defer cs.mu.RUnlock() - total := utf8.RuneCountInString(cs.conv.Summary) - for _, m := range cs.conv.Messages { - total += utf8.RuneCountInString(m.Content) + + result := TokenCount{ + byRole: make(map[string]int), } - return total / charsPerToken + + for _, m := range cs.conv.Messages { + count := utf8.RuneCountInString(m.Content) / charsPerToken + result.byMessage += count + result.byRole[m.Role] += count + } + + if cs.conv.Summary != "" { + result.total = result.byMessage + utf8.RuneCountInString(cs.conv.Summary)/charsPerToken + } else { + result.total = result.byMessage + } + + return result } func (cs *ConversationStore) NeedsSummarization() bool { return cs.ApproxTokenCount() > summarizeThreshold } +func (cs *ConversationStore) Search(query string) []SearchResult { + cs.mu.RLock() + defer cs.mu.RUnlock() + + var results []SearchResult + queryLower := strings.ToLower(query) + + for _, msg := range cs.conv.Messages { + if strings.Contains(strings.ToLower(msg.Content), queryLower) { + results = append(results, SearchResult{ + ID: msg.ID, + Role: msg.Role, + Content: msg.Content, + Time: msg.Time, + }) + } + } + + return results +} + +func (cs *ConversationStore) ExportMarkdown() string { + cs.mu.RLock() + defer cs.mu.RUnlock() + + var sb strings.Builder + sb.WriteString("# Conversation Export\n\n") + sb.WriteString(fmt.Sprintf("ExportĂ© le: %s\n\n", time.Now().Format(time.RFC3339))) + + if cs.conv.Summary != "" { + sb.WriteString("## RĂ©sumĂ©\n\n") + sb.WriteString(cs.conv.Summary) + sb.WriteString("\n\n---\n\n") + } + + sb.WriteString("## Messages\n\n") + + for i, msg := range cs.conv.Messages { + roleLabel := msg.Role + if roleLabel == "user" { + roleLabel = "đŸ‘€ Utilisateur" + } else if roleLabel == "assistant" { + roleLabel = "đŸ€– Assistant" + } else if roleLabel == "system" { + roleLabel = "⚙ SystĂšme" + } + + timestamp := "" + if msg.Time != "" { + if t, err := time.Parse(time.RFC3339, msg.Time); err == nil { + timestamp = t.Format("2006-01-02 15:04") + } + } + + sb.WriteString(fmt.Sprintf("### [%d] %s (%s)\n\n", i+1, roleLabel, timestamp)) + sb.WriteString(msg.Content) + sb.WriteString("\n\n---\n\n") + } + + return sb.String() +} + +func (cs *ConversationStore) ExportJSON() string { + cs.mu.RLock() + defer cs.mu.RUnlock() + + data, err := json.MarshalIndent(cs.conv, "", " ") + if err != nil { + return "{}" + } + return string(data) +} + func generateMsgID() string { return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano()) -} +} \ No newline at end of file diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 5519412..43ec284 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "net/http" + "os" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" @@ -95,9 +97,14 @@ func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { servers := mcp.ScanServers() + home, _ := os.UserHomeDir() + editors := mcp.DetectInstalledEditors(home) + statuses := mcp.GetAllStatuses() writeJSON(w, map[string]interface{}{ - "servers": servers, - "configured": true, + "servers": servers, + "configured": true, + "detected_editors": editors, + "statuses": statuses, }) } @@ -106,11 +113,297 @@ func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) { writeError(w, "POST only", http.StatusMethodNotAllowed) return } - if err := mcp.ConfigureAll(s.config); err != nil { + + var body struct { + Editor string `json:"editor,omitempty"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&body) + } + + if body.Editor != "" { + if err := mcp.ConfigureForEditor(s.config, body.Editor); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + } else { + if err := mcp.ConfigureAll(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleMCPStatus(w http.ResponseWriter, r *http.Request) { + statuses := mcp.GetAllStatuses() + writeJSON(w, map[string]interface{}{ + "statuses": statuses, + }) +} + +func (s *Server) handleMCPRegistry(w http.ResponseWriter, r *http.Request) { + reg, err := mcp.LoadRegistry() + if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } - writeJSON(w, map[string]string{"status": "ok"}) + writeJSON(w, map[string]interface{}{ + "registry": reg, + }) +} + +func (s *Server) handleLSPHealth(w http.ResponseWriter, r *http.Request) { + servers := lsp.ScanServers() + type healthInfo struct { + Name string `json:"name"` + Language string `json:"language"` + Installed bool `json:"installed"` + Healthy bool `json:"healthy"` + Detail string `json:"detail,omitempty"` + } + var results []healthInfo + for _, srv := range servers { + healthy, detail := lsp.HealthCheck(srv.Name) + results = append(results, healthInfo{ + Name: srv.Name, + Language: srv.Language, + Installed: srv.Installed, + Healthy: healthy, + Detail: detail, + }) + } + writeJSON(w, map[string]interface{}{ + "servers": results, + }) +} + +func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + ProjectDir string `json:"project_dir,omitempty"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&body) + } + + if body.ProjectDir == "" { + home, _ := os.UserHomeDir() + body.ProjectDir = home + } + + results, err := lsp.AutoInstallForProject(body.ProjectDir) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "results": results, + }) +} + +func (s *Server) handleLSPEditorConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Editor string `json:"editor"` + Names []string `json:"names,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + allServers := lsp.ScanServers() + var selected []lsp.LSPServer + if len(body.Names) > 0 { + nameSet := map[string]bool{} + for _, n := range body.Names { + nameSet[n] = true + } + for _, srv := range allServers { + if nameSet[srv.Name] { + selected = append(selected, srv) + } + } + } else { + for _, srv := range allServers { + if srv.Installed { + selected = append(selected, srv) + } + } + } + + config, err := lsp.GenerateEditorConfigs(selected, body.Editor, "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]interface{}{ + "editor": body.Editor, + "config": config, + }) +} + +func (s *Server) handleSkillValidate(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + skill, err := skills.Get(body.Name) + if err != nil { + writeError(w, err.Error(), http.StatusNotFound) + return + } + + errs := skills.Validate(skill) + writeJSON(w, map[string]interface{}{ + "name": body.Name, + "valid": len(errs) == 0, + "errors": errs, + "dependencies": skills.CheckDependencies(skill), + }) +} + +func (s *Server) handleSkillTest(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + SampleTask string `json:"sample_task,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + result := skills.DryRun(body.Name, body.SampleTask) + writeJSON(w, result) +} + +func (s *Server) handleSkillExport(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + ExportPath string `json:"export_path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + home, _ := os.UserHomeDir() + if body.ExportPath == "" { + body.ExportPath = home + "/.muyue/exports/" + body.Name + ".md" + } + + if err := skills.Export(body.Name, body.ExportPath); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok", "path": body.ExportPath}) +} + +func (s *Server) handleSkillImport(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + ImportPath string `json:"import_path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + skill, err := skills.Import(body.ImportPath) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if err := skills.Create(skill); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{"status": "ok", "skill": skill.Name}) +} + +func (s *Server) handleDashboardStatus(w http.ResponseWriter, r *http.Request) { + mcpStatuses := mcp.GetAllStatuses() + lspServers := lsp.ScanServers() + skillList, _ := skills.List() + + mcpHealthy := 0 + mcpTotal := len(mcpStatuses) + for _, st := range mcpStatuses { + if st.Healthy { + mcpHealthy++ + } + } + + lspInstalled := 0 + lspTotal := len(lspServers) + for _, srv := range lspServers { + if srv.Installed { + lspInstalled++ + } + } + + skillsDeployed := len(skillList) + var skillIssues []string + for _, sk := range skillList { + missing := skills.CheckDependencies(&sk) + if len(missing) > 0 { + for _, dep := range missing { + skillIssues = append(skillIssues, sk.Name+": missing "+dep.Type+" "+dep.Name) + } + } + } + + writeJSON(w, map[string]interface{}{ + "mcp": map[string]interface{}{ + "total": mcpTotal, + "healthy": mcpHealthy, + "servers": mcpStatuses, + }, + "lsp": map[string]interface{}{ + "total": lspTotal, + "installed": lspInstalled, + "servers": lspServers, + }, + "skills": map[string]interface{}{ + "total": skillsDeployed, + "issues": skillIssues, + "deployed": skillList, + }, + }) } func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_missing.go b/internal/api/handlers_missing.go new file mode 100644 index 0000000..ff6007b --- /dev/null +++ b/internal/api/handlers_missing.go @@ -0,0 +1,269 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/lsp" + "github.com/muyue/muyue/internal/skills" +) + +type SavedConversation struct { + ID string `json:"id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []MessageEntry `json:"messages,omitempty"` +} + +type MessageEntry struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Time string `json:"time"` +} + +type conversationsStore struct { + Path string + Items []SavedConversation +} + +func conversationsPath() string { + dir, _ := config.ConfigDir() + return filepath.Join(dir, "conversations.json") +} + +func listConversations() ([]SavedConversation, error) { + path := conversationsPath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return []SavedConversation{}, nil + } + return nil, err + } + var store conversationsStore + if err := json.Unmarshal(data, &store); err != nil { + return []SavedConversation{}, nil + } + return store.Items, nil +} + +func saveConversations(items []SavedConversation) error { + path := conversationsPath() + dir := filepath.Dir(path) + os.MkdirAll(dir, 0755) + data, err := json.MarshalIndent(struct { + Items []SavedConversation `json:"items"` + }{Items: items}, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + convs, err := listConversations() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + conv := s.convStore.Get() + tokenInfo := s.convStore.ApproxTokenCountDetailed() + + writeJSON(w, map[string]interface{}{ + "conversations": convs, + "current_messages": conv, + "tokens": tokenInfo.total, + "tokens_by_role": tokenInfo.byRole, + "summary": s.convStore.GetSummary(), + }) +} + +func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + writeError(w, "DELETE only", http.StatusMethodNotAllowed) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/conversations/") + id = strings.TrimPrefix(id, "/") + if id == "" { + s.convStore.Clear() + writeJSON(w, map[string]string{"status": "cleared"}) + return + } + convs, err := listConversations() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + filtered := make([]SavedConversation, 0, len(convs)) + found := false + for _, c := range convs { + if c.ID == id { + found = true + continue + } + filtered = append(filtered, c) + } + if !found { + writeError(w, "conversation not found", http.StatusNotFound) + return + } + if err := saveConversations(filtered); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "deleted"}) +} + +func (s *Server) handleSearchConversations(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 + } + + results := s.convStore.Search(query) + writeJSON(w, map[string]interface{}{ + "query": query, + "results": results, + "count": len(results), + }) +} + +func (s *Server) handleExportConversation(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + format := r.URL.Query().Get("format") + if format == "markdown" || format == "md" { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Write([]byte(s.convStore.ExportMarkdown())) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(s.convStore.ExportJSON())) +} + +func (s *Server) handleLSPInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + if err := lsp.InstallServer(body.Name); err != nil { + writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + writeJSON(w, map[string]interface{}{ + "success": true, + "server": body.Name, + }) +} + +func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Name != "" { + skill, err := skills.Get(body.Name) + if err != nil { + writeError(w, err.Error(), http.StatusNotFound) + return + } + if err := skills.Deploy(skill); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "deployed", "skill": body.Name}) + return + } + if err := skills.DeployAll(); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "all deployed"}) +} + +func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + cfg, err := config.Load() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "connections": cfg.Terminal.SSH, + }) +} + +func (s *Server) handleSSHTest(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Host == "" || body.User == "" { + writeError(w, "host and user are required", http.StatusBadRequest) + return + } + if body.Port == 0 { + body.Port = 22 + } + writeJSON(w, map[string]interface{}{ + "success": true, + "message": "SSH connection test not implemented (requires net.DialTimeout)", + }) +} \ No newline at end of file diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go new file mode 100644 index 0000000..c33d829 --- /dev/null +++ b/internal/api/handlers_shell_chat.go @@ -0,0 +1,298 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/muyue/muyue/internal/agent" + "github.com/muyue/muyue/internal/orchestrator" +) + +const maxShellToolIterations = 10 + +type ShellChatRequest struct { + Message string `json:"message"` + Context string `json:"context,omitempty"` + History []string `json:"history,omitempty"` + Cwd string `json:"cwd,omitempty"` + Platform string `json:"platform,omitempty"` + Stream bool `json:"stream"` +} + +type ShellChatResponse struct { + Content string `json:"content,omitempty"` + ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"` + Error string `json:"error,omitempty"` +} + +type ToolCallInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Args map[string]interface{} `json:"args"` + Result *toolResponseData `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var req ShellChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Message == "" { + writeError(w, "message is required", http.StatusBadRequest) + return + } + + orb, err := orchestrator.New(s.config) + if err != nil { + writeError(w, err.Error(), http.StatusServiceUnavailable) + return + } + + orb.SetSystemPrompt(s.buildShellSystemPrompt(req)) + orb.SetTools(s.agentToolsJSON) + + if req.Stream { + s.handleShellChatStream(w, orb, req) + } else { + s.handleShellChatNonStream(w, orb, req) + } +} + +func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string { + var sb strings.Builder + + sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accĂšs Ă  un terminal et peux aider l'utilisateur avec: +- ExĂ©cuter des commandes shell +- Expliquer des erreurs de commandes +- SuggĂ©rer des commandes appropriĂ©es pour la tĂąche demandĂ©e +- Lire et explorer des fichiers +- Configurer l'environnement de dĂ©veloppement + +Tu peux appeler des outils pour exĂ©cuter des commandes, lire des fichiers, etc. Sois prĂ©cis et concis dans tes rĂ©ponses. + +`) + + if req.Cwd != "" { + sb.WriteString("RĂ©pertoire courant: " + req.Cwd + "\n") + } + if req.Platform != "" { + sb.WriteString("Plateforme: " + req.Platform + "\n") + } + if req.Context != "" { + sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n") + } + if len(req.History) > 0 { + sb.WriteString("\nDerniĂšres commandes exĂ©cutĂ©es:\n") + for _, h := range req.History { + sb.WriteString(" " + h + "\n") + } + } + + return sb.String() +} + +func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { + 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", "*") + w.WriteHeader(http.StatusOK) + flusher, canFlush := w.(http.Flusher) + + writeSSE := func(data map[string]interface{}) { + b, _ := json.Marshal(data) + w.Write([]byte("data: " + string(b) + "\n\n")) + if canFlush { + flusher.Flush() + } + } + + ctx := context.Background() + messages := []orchestrator.Message{ + {Role: "user", Content: req.Message}, + } + + var finalContent string + var toolCalls []ToolCallInfo + + for i := 0; i < maxShellToolIterations; i++ { + resp, err := orb.SendWithTools(messages) + if err != nil { + writeSSE(map[string]interface{}{"error": err.Error()}) + return + } + + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + for _, ch := range strings.Split(content, "") { + writeSSE(map[string]interface{}{"content": ch}) + } + finalContent = content + } + + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + toolCallData := map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + } + writeSSE(map[string]interface{}{"tool_call": toolCallData}) + + argsMap := make(map[string]interface{}) + json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) + + tcInfo := ToolCallInfo{ + ID: tc.ID, + Name: tc.Function.Name, + Args: argsMap, + } + + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + tcInfo.Error = execErr.Error() + writeSSE(map[string]interface{}{"tool_result": tcInfo}) + } else { + tcInfo.Result = &toolResponseData{ + Content: result.Content, + IsError: result.IsError, + Meta: result.Meta, + } + writeSSE(map[string]interface{}{"tool_result": tcInfo}) + } + + toolCalls = append(toolCalls, tcInfo) + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" + } + + if finalContent == "" && len(toolCalls) > 0 { + finalContent = "(opĂ©rations terminĂ©es)" + } + + writeJSONResp, _ := json.Marshal(ShellChatResponse{ + Content: finalContent, + ToolCalls: toolCalls, + }) + writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)}) +} + +func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { + ctx := context.Background() + messages := []orchestrator.Message{ + {Role: "user", Content: req.Message}, + } + + var finalContent string + var toolCalls []ToolCallInfo + + for i := 0; i < maxShellToolIterations; i++ { + resp, err := orb.SendWithTools(messages) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + finalContent = content + } + + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + argsMap := make(map[string]interface{}) + json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) + + tcInfo := ToolCallInfo{ + ID: tc.ID, + Name: tc.Function.Name, + Args: argsMap, + } + + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + tcInfo.Error = execErr.Error() + } else { + tcInfo.Result = &toolResponseData{ + Content: result.Content, + IsError: result.IsError, + Meta: result.Meta, + } + } + + toolCalls = append(toolCalls, tcInfo) + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" + } + + if finalContent == "" && len(toolCalls) > 0 { + finalContent = "(tool calls completed, no text response)" + } + + writeJSON(w, ShellChatResponse{ + Content: finalContent, + ToolCalls: toolCalls, + }) +} \ No newline at end of file diff --git a/internal/api/handlers_tools.go b/internal/api/handlers_tools.go index d618322..978ee84 100644 --- a/internal/api/handlers_tools.go +++ b/internal/api/handlers_tools.go @@ -3,7 +3,9 @@ package api import ( "encoding/json" "net/http" + "sync" + "github.com/muyue/muyue/internal/installer" "github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/updater" ) @@ -49,7 +51,30 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { writeError(w, "no tools specified", http.StatusBadRequest) return } - writeJSON(w, map[string]string{"status": "installing"}) + + results := make([]installer.InstallResult, len(body.Tools)) + var wg sync.WaitGroup + var mu sync.Mutex + + for i, tool := range body.Tools { + wg.Add(1) + go func(idx int, name string) { + defer wg.Done() + inst := installer.New(s.config) + res := inst.InstallTool(name) + mu.Lock() + results[idx] = res + mu.Unlock() + }(i, tool) + } + + wg.Wait() + + writeJSON(w, map[string]interface{}{ + "status": "done", + "tools": body.Tools, + "results": results, + }) } func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_tools_exec.go b/internal/api/handlers_tools_exec.go index 8f65181..e3a5abc 100644 --- a/internal/api/handlers_tools_exec.go +++ b/internal/api/handlers_tools_exec.go @@ -1,21 +1,29 @@ package api import ( + "context" "encoding/json" "net/http" - "os/exec" - "strings" + + "github.com/muyue/muyue/internal/agent" ) -type toolCallRequest struct { - Tool string `json:"tool"` - Task string `json:"task"` +type ToolCallRequest struct { + Tool string `json:"tool"` + Args json.RawMessage `json:"args"` } -type toolResult struct { - Success bool `json:"success"` - Output string `json:"output"` - Error string `json:"error,omitempty"` +type ToolResult struct { + Success bool `json:"success"` + Tool string `json:"tool"` + Result *toolResponseData `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type toolResponseData struct { + Content string `json:"content"` + IsError bool `json:"is_error"` + Meta map[string]string `json:"meta,omitempty"` } func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { @@ -24,57 +32,54 @@ func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { return } - var req toolCallRequest + var req ToolCallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } - if req.Tool != "crush" { - writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest) + if req.Tool == "" { + writeError(w, "tool is required", http.StatusBadRequest) return } - if req.Task == "" { - writeError(w, "task is required", http.StatusBadRequest) - return + ctx := context.Background() + call := agent.ToolCall{ + ID: generateMsgID(), + Name: req.Tool, + Arguments: req.Args, } - result := executeTool(req.Tool, req.Task) - writeJSON(w, result) -} - -func executeTool(tool, task string) toolResult { - var cmd *exec.Cmd - - switch tool { - case "crush": - cmd = exec.Command("crush", "run", task) - default: - return toolResult{Success: false, Error: "unknown tool: " + tool} - } - - output, err := cmd.CombinedOutput() - if err != nil { - return toolResult{ + result, execErr := s.agentRegistry.Execute(ctx, call) + if execErr != nil { + writeJSON(w, ToolResult{ Success: false, - Output: string(output), - Error: err.Error(), - } + Tool: req.Tool, + Error: execErr.Error(), + }) + return } - return toolResult{ + writeJSON(w, ToolResult{ Success: true, - Output: string(output), - } + Tool: req.Tool, + Result: &toolResponseData{ + Content: result.Content, + IsError: result.IsError, + Meta: result.Meta, + }, + }) } -func buildToolMessage(tool, task string, history []string) string { - var b strings.Builder - b.WriteString("TASK: " + task + "\n\n") - b.WriteString("CONVERSATION HISTORY:\n") - for _, msg := range history { - b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n") +func (s *Server) handleToolList(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return } - return b.String() + + tools := s.agentRegistry.All() + writeJSON(w, map[string]interface{}{ + "tools": tools, + "count": len(tools), + }) } \ No newline at end of file diff --git a/internal/api/handlers_workflow.go b/internal/api/handlers_workflow.go new file mode 100644 index 0000000..9e268c4 --- /dev/null +++ b/internal/api/handlers_workflow.go @@ -0,0 +1,258 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/muyue/muyue/internal/workflow" +) + +func (s *Server) handleWorkflowCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if body.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf := engine.Create(body.Name, body.Description, body.Type, []workflow.Step{}) + writeJSON(w, wf) +} + +func (s *Server) handleWorkflowList(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + workflows := engine.List() + writeJSON(w, map[string]interface{}{ + "workflows": workflows, + "count": len(workflows), + }) +} + +func (s *Server) handleWorkflowGet(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + writeError(w, "GET only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf, ok := engine.Get(id) + if !ok { + writeError(w, "workflow not found", http.StatusNotFound) + return + } + + writeJSON(w, wf) +} + +func (s *Server) handleWorkflowDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + writeError(w, "DELETE only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + if err := engine.Delete(id); err != nil { + writeError(w, err.Error(), http.StatusNotFound) + return + } + + writeJSON(w, map[string]string{"status": "deleted"}) +} + +func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Goal string `json:"goal"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if body.Goal == "" { + writeError(w, "goal is required", http.StatusBadRequest) + return + } + + planner, err := workflow.NewPlanner(s.config) + if err != nil { + writeError(w, err.Error(), http.StatusServiceUnavailable) + return + } + + steps, err := planner.GeneratePlan(context.Background(), body.Goal) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps) + writeJSON(w, wf) +} + +func (s *Server) handleWorkflowExecute(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/execute/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + wf, ok := engine.Get(id) + if !ok { + writeError(w, "workflow not found", http.StatusNotFound) + return + } + + if r.URL.Query().Get("stream") == "true" { + s.handleWorkflowExecuteStream(w, engine, wf) + } else { + err := engine.Execute(context.Background(), id, nil) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + wf, _ = engine.Get(id) + writeJSON(w, wf) + } +} + +func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *workflow.Engine, wf *workflow.Workflow) { + 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", "*") + w.WriteHeader(http.StatusOK) + flusher, canFlush := w.(http.Flusher) + + writeSSE := func(data map[string]interface{}) { + b, _ := json.Marshal(data) + w.Write([]byte("data: " + string(b) + "\n\n")) + if canFlush { + flusher.Flush() + } + } + + go func() { + engine.Execute(context.Background(), wf.ID, func(step *workflow.Step, event string) { + writeSSE(map[string]interface{}{ + "event": event, + "step": step, + }) + }) + + wf, _ = engine.Get(wf.ID) + writeSSE(map[string]interface{}{ + "event": "workflow_done", + "status": wf.Status, + "workflow": wf, + }) + }() +} + +func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/api/workflow/approve/") + if id == "" { + writeError(w, "workflow id required", http.StatusBadRequest) + return + } + + var body struct { + StepID string `json:"step_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + engine := s.workflowEngine + if engine == nil { + engine, _ = workflow.NewEngine(s.agentRegistry) + } + + if err := engine.ApproveStep(id, body.StepID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "approved"}) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} \ No newline at end of file diff --git a/internal/api/server.go b/internal/api/server.go index f7041d0..1890282 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -8,6 +8,7 @@ import ( "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/workflow" ) type Server struct { @@ -17,6 +18,7 @@ type Server struct { convStore *ConversationStore agentRegistry *agent.Registry agentToolsJSON json.RawMessage + workflowEngine *workflow.Engine } func NewServer(cfg *config.MuyueConfig) *Server { @@ -30,6 +32,7 @@ func NewServer(cfg *config.MuyueConfig) *Server { tools := s.agentRegistry.OpenAITools() toolsJSON, _ := json.Marshal(tools) s.agentToolsJSON = json.RawMessage(toolsJSON) + s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry) s.routes() return s } @@ -64,6 +67,34 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) + s.mux.HandleFunc("/api/tool/call", s.handleToolCall) + s.mux.HandleFunc("/api/tools/list", s.handleToolList) + s.mux.HandleFunc("/api/shell/chat", s.handleShellChat) + s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate) + s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList) + s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet) + s.mux.HandleFunc("/api/workflow/plan", s.handleWorkflowPlan) + s.mux.HandleFunc("/api/workflow/execute/", s.handleWorkflowExecute) + s.mux.HandleFunc("/api/workflow/approve/", s.handleWorkflowApprove) + s.mux.HandleFunc("/api/conversations", s.handleListConversations) + s.mux.HandleFunc("/api/conversations/search", s.handleSearchConversations) + s.mux.HandleFunc("/api/conversations/export", s.handleExportConversation) + s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation) + s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall) + s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy) + s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections) + s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest) + + s.mux.HandleFunc("/api/mcp/status", s.handleMCPStatus) + s.mux.HandleFunc("/api/mcp/registry", s.handleMCPRegistry) + s.mux.HandleFunc("/api/lsp/health", s.handleLSPHealth) + s.mux.HandleFunc("/api/lsp/auto-install", s.handleLSPAutoInstall) + s.mux.HandleFunc("/api/lsp/editor-config", s.handleLSPEditorConfig) + s.mux.HandleFunc("/api/skills/validate", s.handleSkillValidate) + s.mux.HandleFunc("/api/skills/test", s.handleSkillTest) + s.mux.HandleFunc("/api/skills/export", s.handleSkillExport) + s.mux.HandleFunc("/api/skills/import", s.handleSkillImport) + s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index d39d611..2c68614 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -1,9 +1,13 @@ package lsp import ( + "encoding/json" "fmt" "os" "os/exec" + "path/filepath" + "strings" + "time" ) type LSPServer struct { @@ -12,6 +16,10 @@ type LSPServer struct { Command string `json:"command"` InstallCmd string `json:"install_cmd"` Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + Healthy bool `json:"healthy,omitempty"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` } var knownServers = []LSPServer{ @@ -39,27 +47,131 @@ func ScanServers() []LSPServer { servers[i] = s _, err := exec.LookPath(s.Command) servers[i].Installed = err == nil + servers[i].Version = getInstalledLSPVersion(s.Name) } + + regServers, err := scanLSPRegistryServers() + if err == nil { + servers = append(servers, regServers...) + } + return servers } +func scanLSPRegistryServers() ([]LSPServer, error) { + reg, err := LoadLSPRegistry() + if err != nil { + return nil, err + } + + knownNames := map[string]bool{} + for _, s := range knownServers { + knownNames[s.Name] = true + } + + var servers []LSPServer + for _, rs := range reg.Servers { + if knownNames[rs.Name] { + continue + } + servers = append(servers, LSPServer{ + Name: rs.Name, + Language: rs.Language, + Command: rs.Command, + InstallCmd: rs.InstallCmd, + Installed: isLSPCommandAvailable(rs.Command), + Description: rs.Description, + Category: rs.Category, + Version: getInstalledLSPVersion(rs.Name), + }) + } + return servers, nil +} + +func isLSPCommandAvailable(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func getInstalledLSPVersion(name string) string { + home, _ := os.UserHomeDir() + if home == "" { + return "" + } + receiptPath := filepath.Join(home, ".muyue", "receipts", "lsp", name+".json") + data, err := os.ReadFile(receiptPath) + if err != nil { + return "" + } + var receipt struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &receipt) == nil { + return receipt.Version + } + return "" +} + +func saveLSPReceipt(name, version string) error { + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + receiptDir := filepath.Join(home, ".muyue", "receipts", "lsp") + os.MkdirAll(receiptDir, 0755) + + receipt := struct { + Name string `json:"name"` + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + }{ + Name: name, + Version: version, + UpdatedAt: time.Now().Format(time.RFC3339), + } + + data, _ := json.MarshalIndent(receipt, "", " ") + return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644) +} + func InstallServer(name string) error { for _, s := range knownServers { if s.Name == name { - if s.InstallCmd == "" { - return fmt.Errorf("%s has no auto-install command, install manually", name) - } - cmd := exec.Command("bash", "-c", s.InstallCmd) - cmd.Env = os.Environ() - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("install %s: %s: %w", name, string(output), err) - } - return nil + return doInstallLSP(s) } } + + reg, err := LoadLSPRegistry() + if err == nil { + for _, s := range reg.Servers { + if s.Name == name { + return doInstallLSP(LSPServer{ + Name: s.Name, + Language: s.Language, + Command: s.Command, + InstallCmd: s.InstallCmd, + }) + } + } + } + return fmt.Errorf("unknown LSP server: %s", name) } +func doInstallLSP(s LSPServer) error { + if s.InstallCmd == "" { + return fmt.Errorf("%s has no auto-install command, install manually", s.Name) + } + cmd := exec.Command("bash", "-c", s.InstallCmd) + cmd.Env = os.Environ() + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("install %s: %s: %w", s.Name, string(output), err) + } + + saveLSPReceipt(s.Name, "latest") + return nil +} + func InstallForLanguages(languages []string) []LSPServer { langMap := map[string][]string{ "go": {"gopls"}, @@ -101,3 +213,100 @@ func InstallForLanguages(languages []string) []LSPServer { return results } + +func AutoInstallForProject(projectDir string) ([]LSPServer, error) { + languages := DetectProjectLanguages(projectDir) + if len(languages) == 0 { + return nil, nil + } + results := InstallForLanguages(languages) + return results, nil +} + +func HealthCheck(name string) (bool, string) { + for _, s := range knownServers { + if s.Name == name { + return healthCheckServer(s) + } + } + return false, "unknown server" +} + +func healthCheckServer(s LSPServer) (bool, string) { + path, err := exec.LookPath(s.Command) + if err != nil { + return false, fmt.Sprintf("command %q not found in PATH", s.Command) + } + + versionArgs := map[string][]string{ + "gopls": {"version"}, + "pyright": {"--version"}, + "typescript-language-server": {"--version"}, + "rust-analyzer": {"--version"}, + "clangd": {"--version"}, + "lua-language-server": {"--version"}, + "bash-language-server": {"--version"}, + "yaml-language-server": {"--version"}, + } + + if args, ok := versionArgs[s.Command]; ok { + cmd := exec.Command(path, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return true, fmt.Sprintf("installed at %s but version check failed", path) + } + version := strings.TrimSpace(string(output)) + if idx := strings.Index(version, "\n"); idx > 0 { + version = version[:idx] + } + saveLSPReceipt(s.Name, version) + return true, version + } + + return true, fmt.Sprintf("installed at %s", path) +} + +func GenerateEditorConfigs(servers []LSPServer, editor string, homeDir string) (string, error) { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + reg, err := LoadLSPRegistry() + if err != nil { + return "", err + } + + regMap := map[string]RegistryEntry{} + for _, s := range reg.Servers { + regMap[s.Name] = s + } + + var regEntries []RegistryEntry + for _, s := range servers { + if re, ok := regMap[s.Name]; ok { + regEntries = append(regEntries, re) + } + } + + switch editor { + case "neovim", "nvim": + return GenerateNeovimConfig(regEntries), nil + case "helix", "hx": + return GenerateHelixConfig(regEntries), nil + case "vscode", "code", "cursor": + exts := GenerateVSCodeRecommendations(regEntries) + var b strings.Builder + b.WriteString("{\n \"recommendations\": [\n") + for i, ext := range exts { + if i > 0 { + b.WriteString(",\n") + } + b.WriteString(" \"" + ext + "\"") + } + b.WriteString("\n ]\n}") + return b.String(), nil + default: + return "", fmt.Errorf("unsupported editor: %s", editor) + } +} diff --git a/internal/lsp/registry.go b/internal/lsp/registry.go new file mode 100644 index 0000000..caff04e --- /dev/null +++ b/internal/lsp/registry.go @@ -0,0 +1,333 @@ +package lsp + +import ( + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +type RegistryEntry struct { + Name string `yaml:"name" json:"name"` + Language string `yaml:"language" json:"language"` + Description string `yaml:"description" json:"description"` + Command string `yaml:"command" json:"command"` + InstallCmd string `yaml:"install_cmd" json:"install_cmd"` + InstallType string `yaml:"install_type" json:"install_type"` + Category string `yaml:"category" json:"category"` + FilePatterns []string `yaml:"file_patterns,omitempty" json:"file_patterns,omitempty"` + ConfigFiles []string `yaml:"config_files,omitempty" json:"config_files,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + + NeovimSetup string `yaml:"neovim_setup,omitempty" json:"neovim_setup,omitempty"` + HelixLanguage string `yaml:"helix_language,omitempty" json:"helix_language,omitempty"` +} + +type LSPRegistry struct { + SchemaVersion string `yaml:"schema_version"` + UpdatedAt time.Time `yaml:"updated_at"` + Servers []RegistryEntry `yaml:"servers"` +} + +func DefaultLSPRegistry() *LSPRegistry { + return &LSPRegistry{ + SchemaVersion: "v1", + UpdatedAt: time.Now(), + Servers: []RegistryEntry{ + { + Name: "gopls", Language: "go", Description: "Go language server", + Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest", + InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"}, + ConfigFiles: []string{"go.mod"}, Tags: []string{"go", "linting", "completion"}, + HomePage: "https://github.com/golang/tools", + NeovimSetup: `lspconfig.gopls.setup{}`, + HelixLanguage: "go", + }, + { + Name: "pyright", Language: "python", Description: "Python type checker and language server", + Command: "pyright", InstallCmd: "npm install -g pyright", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.py", "*.pyi"}, + ConfigFiles: []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"}, + Tags: []string{"python", "type-checking"}, HomePage: "https://github.com/microsoft/pyright", + NeovimSetup: `lspconfig.pyright.setup{}`, + HelixLanguage: "python", + }, + { + Name: "typescript-language-server", Language: "typescript", Description: "TypeScript and JavaScript language server", + Command: "typescript-language-server", InstallCmd: "npm install -g typescript-language-server typescript", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.ts", "*.tsx", "*.js", "*.jsx"}, + ConfigFiles: []string{"tsconfig.json", "package.json"}, + Tags: []string{"typescript", "javascript"}, HomePage: "https://github.com/typescript-language-server/typescript-language-server", + NeovimSetup: `lspconfig.tsserver.setup{}`, + HelixLanguage: "typescript", + }, + { + Name: "vscode-json-language-server", Language: "json", Description: "JSON language server", + Command: "vscode-json-language-server", InstallCmd: "npm install -g vscode-langservers-extracted", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.json", "*.jsonc"}, + Tags: []string{"json"}, NeovimSetup: `lspconfig.jsonls.setup{}`, + HelixLanguage: "json", + }, + { + Name: "vscode-html-language-server", Language: "html", Description: "HTML language server", + Command: "vscode-html-language-server", InstallCmd: "npm install -g vscode-langservers-extracted", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.htm"}, + Tags: []string{"html"}, NeovimSetup: `lspconfig.html.setup{}`, + HelixLanguage: "html", + }, + { + Name: "vscode-css-language-server", Language: "css", Description: "CSS/SCSS/LESS language server", + Command: "vscode-css-language-server", InstallCmd: "npm install -g vscode-langservers-extracted", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.css", "*.scss", "*.less"}, + Tags: []string{"css"}, NeovimSetup: `lspconfig.cssls.setup{}`, + HelixLanguage: "css", + }, + { + Name: "yaml-language-server", Language: "yaml", Description: "YAML language server", + Command: "yaml-language-server", InstallCmd: "npm install -g yaml-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.yml", "*.yaml"}, + Tags: []string{"yaml"}, NeovimSetup: `lspconfig.yamlls.setup{}`, + HelixLanguage: "yaml", + }, + { + Name: "bash-language-server", Language: "bash", Description: "Bash language server", + Command: "bash-language-server", InstallCmd: "npm install -g bash-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.sh", "*.bash"}, + Tags: []string{"bash", "shell"}, NeovimSetup: `lspconfig.bashls.setup{}`, + HelixLanguage: "bash", + }, + { + Name: "rust-analyzer", Language: "rust", Description: "Rust language server", + Command: "rust-analyzer", InstallCmd: "rustup component add rust-analyzer", + InstallType: "rustup", Category: "lsp", FilePatterns: []string{"*.rs"}, + ConfigFiles: []string{"Cargo.toml"}, Tags: []string{"rust"}, + HomePage: "https://github.com/rust-lang/rust-analyzer", + NeovimSetup: `lspconfig.rust_analyzer.setup{}`, + HelixLanguage: "rust", + }, + { + Name: "clangd", Language: "c/c++", Description: "C/C++ language server", + Command: "clangd", InstallCmd: "", InstallType: "system", + Category: "lsp", FilePatterns: []string{"*.c", "*.cpp", "*.h", "*.hpp"}, + ConfigFiles: []string{"CMakeLists.txt", "Makefile"}, Tags: []string{"c", "cpp"}, + NeovimSetup: `lspconfig.clangd.setup{}`, + HelixLanguage: "c", + }, + { + Name: "lua-language-server", Language: "lua", Description: "Lua language server", + Command: "lua-language-server", InstallCmd: "npm install -g lua-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.lua"}, + Tags: []string{"lua"}, NeovimSetup: `lspconfig.lua_ls.setup{}`, + HelixLanguage: "lua", + }, + { + Name: "dockerfile-language-server", Language: "dockerfile", Description: "Dockerfile language server", + Command: "docker-langserver", InstallCmd: "npm install -g dockerfile-language-server-nodejs", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"Dockerfile", "Dockerfile.*"}, + Tags: []string{"docker"}, NeovimSetup: `lspconfig.dockerls.setup{}`, + HelixLanguage: "dockerfile", + }, + { + Name: "tailwindcss-language-server", Language: "tailwind", Description: "Tailwind CSS language server", + Command: "tailwindcss-language-server", InstallCmd: "npm install -g @tailwindcss/language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.tsx", "*.jsx"}, + ConfigFiles: []string{"tailwind.config.js", "tailwind.config.ts"}, + Tags: []string{"tailwind", "css"}, NeovimSetup: `lspconfig.tailwindcss.setup{}`, + }, + { + Name: "svelte-language-server", Language: "svelte", Description: "Svelte language server", + Command: "svelteserver", InstallCmd: "npm install -g svelte-language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.svelte"}, + Tags: []string{"svelte"}, NeovimSetup: `lspconfig.svelte.setup{}`, + HelixLanguage: "svelte", + }, + { + Name: "vue-language-server", Language: "vue", Description: "Vue language server", + Command: "vue-language-server", InstallCmd: "npm install -g @vue/language-server", + InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.vue"}, + Tags: []string{"vue"}, NeovimSetup: `lspconfig.vuels.setup{}`, + }, + { + Name: "golangci-lint-langserver", Language: "go-lint", Description: "Go linter language server", + Command: "golangci-lint-langserver", InstallCmd: "go install github.com/nametake/golangci-lint-langserver@latest", + InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"}, + Tags: []string{"go", "linting"}, + }, + }, + } +} + +var lspRegistryPath string + +func init() { + home, _ := os.UserHomeDir() + if home != "" { + lspRegistryPath = filepath.Join(home, ".muyue", "lsp-registry.yaml") + } +} + +func SetLSPRegistryPath(p string) { + lspRegistryPath = p +} + +func LoadLSPRegistry() (*LSPRegistry, error) { + if lspRegistryPath == "" { + return DefaultLSPRegistry(), nil + } + + data, err := os.ReadFile(lspRegistryPath) + if err != nil { + return DefaultLSPRegistry(), nil + } + + var reg LSPRegistry + if err := yaml.Unmarshal(data, ®); err != nil { + return nil, err + } + return ®, nil +} + +func SaveLSPRegistry(reg *LSPRegistry) error { + if lspRegistryPath == "" { + return nil + } + reg.UpdatedAt = time.Now() + data, err := yaml.Marshal(reg) + if err != nil { + return err + } + os.MkdirAll(filepath.Dir(lspRegistryPath), 0755) + return os.WriteFile(lspRegistryPath, data, 0644) +} + +func InitLSPRegistry() error { + if lspRegistryPath == "" { + return nil + } + if _, err := os.Stat(lspRegistryPath); err == nil { + return nil + } + return SaveLSPRegistry(DefaultLSPRegistry()) +} + +func DetectProjectLanguages(projectDir string) []string { + if projectDir == "" { + return nil + } + + langDetectors := map[string][]string{ + "go": {"go.mod", "go.sum"}, + "python": {"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"}, + "typescript": {"tsconfig.json", "package.json"}, + "javascript": {"package.json"}, + "rust": {"Cargo.toml"}, + "ruby": {"Gemfile"}, + "java": {"pom.xml", "build.gradle"}, + "c": {"CMakeLists.txt", "Makefile"}, + "cpp": {"CMakeLists.txt"}, + "php": {"composer.json"}, + "lua": {".luarc.json"}, + "docker": {"Dockerfile"}, + } + + extDetectors := map[string]string{ + ".go": "go", + ".py": "python", + ".rs": "rust", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".rb": "ruby", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".lua": "lua", + ".vue": "vue", + ".svelte": "svelte", + } + + detected := map[string]bool{} + for lang, files := range langDetectors { + for _, f := range files { + if _, err := os.Stat(filepath.Join(projectDir, f)); err == nil { + detected[lang] = true + break + } + } + } + + entries, err := os.ReadDir(projectDir) + if err == nil { + for _, e := range entries { + if e.IsDir() { + continue + } + ext := filepath.Ext(e.Name()) + if lang, ok := extDetectors[ext]; ok { + detected[lang] = true + } + } + } + + var languages []string + for lang := range detected { + languages = append(languages, lang) + } + return languages +} + +func GenerateNeovimConfig(servers []RegistryEntry) string { + config := `-- Generated by Muyue LSP Manager +-- Add to your init.lua or require from lspconfig setup +local lspconfig = require('lspconfig') + +` + for _, s := range servers { + if s.NeovimSetup != "" { + config += s.NeovimSetup + "\n" + } + } + return config +} + +func GenerateHelixConfig(servers []RegistryEntry) string { + config := `# Generated by Muyue LSP Manager +# Add to ~/.config/helix/languages.toml + +` + for _, s := range servers { + if s.HelixLanguage != "" { + config += "[[language]]\n" + config += "name = \"" + s.HelixLanguage + "\"\n" + config += "language-servers = [\"" + s.Name + "\"]\n\n" + } + } + return config +} + +func GenerateVSCodeRecommendations(servers []RegistryEntry) []string { + extensionMap := map[string][]string{ + "gopls": {"golang.go"}, + "pyright": {"ms-python.python", "ms-python.vscode-pylance"}, + "typescript-language-server": {"ms-vscode.vscode-typescript-next"}, + "rust-analyzer": {"rust-lang.rust-analyzer"}, + "lua-language-server": {"sumneko.lua"}, + "tailwindcss-language-server": {"bradlc.vscode-tailwindcss"}, + "svelte-language-server": {"svelte.svelte-vscode"}, + "vue-language-server": {"vue.volar"}, + "yaml-language-server": {"redhat.vscode-yaml"}, + "bash-language-server": {"mads-hartmann.bash-ide-vscode"}, + } + + var extensions []string + for _, s := range servers { + if exts, ok := extensionMap[s.Name]; ok { + extensions = append(extensions, exts...) + } + } + return extensions +} diff --git a/internal/lsp/registry_test.go b/internal/lsp/registry_test.go new file mode 100644 index 0000000..e697484 --- /dev/null +++ b/internal/lsp/registry_test.go @@ -0,0 +1,142 @@ +package lsp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultLSPRegistry(t *testing.T) { + reg := DefaultLSPRegistry() + if reg.SchemaVersion != "v1" { + t.Errorf("Expected v1, got %s", reg.SchemaVersion) + } + if len(reg.Servers) == 0 { + t.Error("Default LSP registry should have servers") + } + + names := map[string]bool{} + for _, s := range reg.Servers { + if names[s.Name] { + t.Errorf("Duplicate server name: %s", s.Name) + } + names[s.Name] = true + if s.Command == "" { + t.Errorf("Server %s missing command", s.Name) + } + if s.Language == "" { + t.Errorf("Server %s missing language", s.Name) + } + } +} + +func TestSaveAndLoadLSPRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-registry.yaml")) + + reg := DefaultLSPRegistry() + if err := SaveLSPRegistry(reg); err != nil { + t.Fatalf("SaveLSPRegistry failed: %v", err) + } + + loaded, err := LoadLSPRegistry() + if err != nil { + t.Fatalf("LoadLSPRegistry failed: %v", err) + } + if len(loaded.Servers) != len(reg.Servers) { + t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers)) + } +} + +func TestInitLSPRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-reg.yaml")) + + if err := InitLSPRegistry(); err != nil { + t.Fatalf("InitLSPRegistry failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, "lsp-reg.yaml")); os.IsNotExist(err) { + t.Error("LSP registry file should be created") + } +} + +func TestDetectProjectLanguages(t *testing.T) { + tmpDir := t.TempDir() + + os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test\ngo 1.24\n"), 0644) + os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name": "test"}`), 0644) + + languages := DetectProjectLanguages(tmpDir) + if len(languages) == 0 { + t.Error("Should detect languages") + } + + langSet := map[string]bool{} + for _, l := range languages { + langSet[l] = true + } + if !langSet["go"] { + t.Error("Should detect Go") + } + if !langSet["typescript"] { + t.Error("Should detect TypeScript/JS from package.json") + } +} + +func TestDetectProjectLanguagesEmpty(t *testing.T) { + tmpDir := t.TempDir() + languages := DetectProjectLanguages(tmpDir) + if len(languages) != 0 { + t.Errorf("Empty dir should detect no languages, got %v", languages) + } +} + +func TestGenerateNeovimConfig(t *testing.T) { + servers := []RegistryEntry{ + {Name: "gopls", Language: "go", NeovimSetup: "lspconfig.gopls.setup{}"}, + {Name: "pyright", Language: "python", NeovimSetup: "lspconfig.pyright.setup{}"}, + } + + config := GenerateNeovimConfig(servers) + if config == "" { + t.Error("Config should not be empty") + } + if len(config) < 50 { + t.Error("Config seems too short") + } +} + +func TestGenerateHelixConfig(t *testing.T) { + servers := []RegistryEntry{ + {Name: "gopls", Language: "go", HelixLanguage: "go"}, + } + + config := GenerateHelixConfig(servers) + if config == "" { + t.Error("Config should not be empty") + } +} + +func TestGenerateVSCodeRecommendations(t *testing.T) { + servers := []RegistryEntry{ + {Name: "gopls", Language: "go"}, + {Name: "pyright", Language: "python"}, + } + + exts := GenerateVSCodeRecommendations(servers) + if len(exts) == 0 { + t.Error("Should return some extensions") + } +} + +func TestHealthCheck(t *testing.T) { + healthy, detail := HealthCheck("gopls") + if healthy && detail == "" { + t.Error("If healthy, should have version detail") + } +} + +func TestHealthCheckUnknown(t *testing.T) { + _, _ = HealthCheck("nonexistent-server") +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 3bcced8..0a464a5 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -6,17 +6,22 @@ import ( "os" "os/exec" "path/filepath" + "strings" + "time" "github.com/muyue/muyue/internal/config" ) type MCPServer struct { - Name string `json:"name"` - Command string `json:"command"` - Args []string `json:"args"` - Env map[string]string `json:"env,omitempty"` - Installed bool `json:"installed"` - Category string `json:"category"` + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` + Installed bool `json:"installed"` + Category string `json:"category"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Status string `json:"status,omitempty"` } type mcpEntry struct { @@ -47,10 +52,52 @@ func ScanServers() []MCPServer { servers[i] = s _, err := exec.LookPath(s.Command) servers[i].Installed = err == nil + servers[i].Version = GetInstalledVersion(s.Name) } + + regServers, err := scanRegistryServers() + if err == nil { + servers = append(servers, regServers...) + } + return servers } +func scanRegistryServers() ([]MCPServer, error) { + reg, err := LoadRegistry() + if err != nil { + return nil, err + } + + knownNames := map[string]bool{} + for _, s := range knownMCPServers { + knownNames[s.Name] = true + } + + var servers []MCPServer + for _, rs := range reg.Servers { + if knownNames[rs.Name] { + continue + } + servers = append(servers, MCPServer{ + Name: rs.Name, + Command: rs.Command, + Args: rs.Args, + Env: rs.Env, + Category: rs.Category, + Description: rs.Description, + Installed: isCommandAvailable(rs.Command), + Version: GetInstalledVersion(rs.Name), + }) + } + return servers, nil +} + +func isCommandAvailable(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + func getCoreEntries(homeDir string) []mcpEntry { return []mcpEntry{ {"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil}, @@ -98,7 +145,8 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error "args": e.args, } if len(e.env) > 0 { - entry["env"] = e.env + resolved := ResolveEnv(e.env, nil) + entry["env"] = resolved } mcpMap[e.name] = entry } @@ -110,7 +158,49 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error return err } - return os.WriteFile(configPath, out, 0600) + if err := os.WriteFile(configPath, out, 0600); err != nil { + return err + } + + return ValidateConfig(configPath) +} + +func writeMCPConfigForEditor(editor EditorConfig, entries []mcpEntry) error { + configDir := filepath.Dir(editor.ConfigPath) + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("create config dir %s: %w", editor.Name, err) + } + + existing := map[string]interface{}{} + data, err := os.ReadFile(editor.ConfigPath) + if err == nil { + _ = json.Unmarshal(data, &existing) + } + + mcpMap := map[string]interface{}{} + for _, e := range entries { + if editor.TransformCommand != nil { + mcpMap[e.name] = editor.TransformCommand(e) + } else { + entry := map[string]interface{}{ + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + entry["env"] = e.env + } + mcpMap[e.name] = entry + } + } + + existing[editor.ConfigKey] = mcpMap + + out, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return err + } + + return os.WriteFile(editor.ConfigPath, out, 0600) } func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error { @@ -140,19 +230,154 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { return writeMCPConfig(configPath, "mcpServers", entries) } +func GenerateCursorMCPConfig(cfg *config.MuyueConfig, homeDir string) error { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + core := getCoreEntries(homeDir) + entries := withProviderEntries(core, cfg, nil) + editor := EditorConfig{ + Name: "cursor", + ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"), + ConfigKey: "mcpServers", + Format: "json", + TransformCommand: func(e mcpEntry) interface{} { + m := map[string]interface{}{ + "type": "stdio", + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + m["env"] = e.env + } + return m + }, + } + return writeMCPConfigForEditor(editor, entries) +} + +func GenerateVSCodeMCPConfig(cfg *config.MuyueConfig, homeDir string) error { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + core := getCoreEntries(homeDir) + entries := withProviderEntries(core, cfg, nil) + editor := EditorConfig{ + Name: "vscode", + ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"), + ConfigKey: "servers", + Format: "json", + } + return writeMCPConfigForEditor(editor, entries) +} + +func GenerateWindsurfMCPConfig(cfg *config.MuyueConfig, homeDir string) error { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + core := getCoreEntries(homeDir) + entries := withProviderEntries(core, cfg, nil) + editor := EditorConfig{ + Name: "windsurf", + ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"), + ConfigKey: "mcpServers", + Format: "json", + } + return writeMCPConfigForEditor(editor, entries) +} + func ConfigureAll(cfg *config.MuyueConfig) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("get home dir: %w", err) } - if err := GenerateCrushMCPConfig(cfg, home); err != nil { - return fmt.Errorf("crush MCP config: %w", err) + editors := []struct { + name string + fn func(*config.MuyueConfig, string) error + }{ + {"crush", GenerateCrushMCPConfig}, + {"claude", GenerateClaudeMCPConfig}, + {"cursor", GenerateCursorMCPConfig}, + {"vscode", GenerateVSCodeMCPConfig}, + {"windsurf", GenerateWindsurfMCPConfig}, } - if err := GenerateClaudeMCPConfig(cfg, home); err != nil { - return fmt.Errorf("claude MCP config: %w", err) + var errs []string + for _, e := range editors { + if err := e.fn(cfg, home); err != nil { + errs = append(errs, fmt.Sprintf("%s: %s", e.name, err)) + } + } + + SaveReceipt("all", time.Now().Format("2006-01-02")) + + if len(errs) > 0 { + return fmt.Errorf("MCP config errors: %s", strings.Join(errs, "; ")) } return nil } + +func ConfigureForEditor(cfg *config.MuyueConfig, editorName string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + + switch editorName { + case "crush": + return GenerateCrushMCPConfig(cfg, home) + case "claude", "claude-code": + return GenerateClaudeMCPConfig(cfg, home) + case "cursor": + return GenerateCursorMCPConfig(cfg, home) + case "vscode", "code": + return GenerateVSCodeMCPConfig(cfg, home) + case "windsurf": + return GenerateWindsurfMCPConfig(cfg, home) + default: + return fmt.Errorf("unknown editor: %s (supported: crush, claude-code, cursor, vscode, windsurf)", editorName) + } +} + +func DetectInstalledEditors(homeDir string) []string { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + editors := []struct { + name string + path string + }{ + {"crush", filepath.Join(homeDir, ".config", "crush", "crush.json")}, + {"claude-code", filepath.Join(homeDir, ".claude.json")}, + {"cursor", filepath.Join(homeDir, ".cursor")}, + {"vscode", filepath.Join(homeDir, ".vscode")}, + {"windsurf", filepath.Join(homeDir, ".windsurf")}, + } + + var detected []string + for _, e := range editors { + if _, err := os.Stat(e.path); err == nil { + detected = append(detected, e.name) + } + } + return detected +} + +func GetAllStatuses() []MCPStatus { + servers := ScanServers() + statuses := make([]MCPStatus, len(servers)) + for i, s := range servers { + statuses[i] = CheckServerStatus(s.Name) + } + return statuses +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go new file mode 100644 index 0000000..bd7651a --- /dev/null +++ b/internal/mcp/registry.go @@ -0,0 +1,520 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +type RegistryServer struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + Category string `yaml:"category" json:"category"` + Package string `yaml:"package" json:"package"` + Command string `yaml:"command" json:"command"` + Args []string `yaml:"args" json:"args"` + Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` + RequiredEnv []string `yaml:"required_env,omitempty" json:"required_env,omitempty"` + HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + InstallType string `yaml:"install_type" json:"install_type"` +} + +type Registry struct { + SchemaVersion string `yaml:"schema_version"` + UpdatedAt time.Time `yaml:"updated_at"` + Servers []RegistryServer `yaml:"servers"` +} + +type MCPStatus struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Running bool `json:"running"` + Healthy bool `json:"healthy"` + Version string `json:"version"` + Error string `json:"error,omitempty"` +} + +type EditorConfig struct { + Name string + ConfigPath string + ConfigKey string + LocalConfigPath string + Format string + TransformCommand func(entry mcpEntry) interface{} +} + +var ( + registryMu sync.RWMutex + registryCache *Registry + registryPath string +) + +func init() { + home, _ := os.UserHomeDir() + if home != "" { + registryPath = filepath.Join(home, ".muyue", "mcp-registry.yaml") + } +} + +func SetRegistryPath(p string) { + registryMu.Lock() + defer registryMu.Unlock() + registryPath = p + registryCache = nil +} + +func DefaultRegistry() *Registry { + return &Registry{ + SchemaVersion: "v1", + UpdatedAt: time.Now(), + Servers: []RegistryServer{ + { + Name: "filesystem", Description: "File system operations for AI tools", + Category: "core", Package: "@modelcontextprotocol/server-filesystem", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, + InstallType: "npm", Tags: []string{"files", "core"}, + }, + { + Name: "github", Description: "GitHub API integration", + Category: "vcs", Package: "@modelcontextprotocol/server-github", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"}, + Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""}, + RequiredEnv: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"}, + InstallType: "npm", Tags: []string{"github", "git"}, + }, + { + Name: "git", Description: "Git repository operations", + Category: "vcs", Package: "@modelcontextprotocol/server-git", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"}, + InstallType: "npm", Tags: []string{"git"}, + }, + { + Name: "fetch", Description: "Web fetching and HTTP requests", + Category: "web", Package: "@modelcontextprotocol/server-fetch", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"}, + InstallType: "npm", Tags: []string{"web", "http"}, + }, + { + Name: "memory", Description: "Persistent memory/knowledge graph", + Category: "core", Package: "@modelcontextprotocol/server-memory", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"}, + InstallType: "npm", Tags: []string{"memory", "core"}, + }, + { + Name: "sequential-thinking", Description: "Structured reasoning and chain-of-thought", + Category: "ai", Package: "@modelcontextprotocol/server-sequential-thinking", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"}, + InstallType: "npm", Tags: []string{"ai", "reasoning"}, + }, + { + Name: "brave-search", Description: "Web search via Brave Search API", + Category: "web", Package: "@modelcontextprotocol/server-brave-search", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"}, + Env: map[string]string{"BRAVE_API_KEY": ""}, + RequiredEnv: []string{"BRAVE_API_KEY"}, + InstallType: "npm", Tags: []string{"search", "web"}, + }, + { + Name: "sqlite", Description: "SQLite database operations", + Category: "database", Package: "@modelcontextprotocol/server-sqlite", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"}, + InstallType: "npm", Tags: []string{"database", "sqlite"}, + }, + { + Name: "postgres", Description: "PostgreSQL database operations", + Category: "database", Package: "@modelcontextprotocol/server-postgres", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"}, + InstallType: "npm", Tags: []string{"database", "postgres"}, + }, + { + Name: "docker", Description: "Docker container management", + Category: "devops", Package: "@modelcontextprotocol/server-docker", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"}, + InstallType: "npm", Tags: []string{"docker", "devops"}, + }, + { + Name: "minimax-web-search", Description: "Web search via MiniMax API", + Category: "ai", Package: "@minimax/mcp-web-search", + Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"}, + Env: map[string]string{"MINIMAX_API_KEY": ""}, + RequiredEnv: []string{"MINIMAX_API_KEY"}, + InstallType: "npm", Tags: []string{"ai", "search"}, + }, + { + Name: "minimax-image", Description: "Image understanding via MiniMax API", + Category: "ai", Package: "@minimax/mcp-image-understanding", + Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"}, + Env: map[string]string{"MINIMAX_API_KEY": ""}, + RequiredEnv: []string{"MINIMAX_API_KEY"}, + InstallType: "npm", Tags: []string{"ai", "image"}, + }, + { + Name: "puppeteer", Description: "Browser automation with Puppeteer", + Category: "web", Package: "@modelcontextprotocol/server-puppeteer", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-puppeteer"}, + InstallType: "npm", Tags: []string{"browser", "automation"}, + }, + { + Name: "everything", Description: "Test/debug MCP server with all features", + Category: "testing", Package: "@modelcontextprotocol/server-everything", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-everything"}, + InstallType: "npm", Tags: []string{"testing", "debug"}, + }, + { + Name: "slack", Description: "Slack workspace integration", + Category: "communication", Package: "@modelcontextprotocol/server-slack", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-slack"}, + Env: map[string]string{"SLACK_BOT_TOKEN": ""}, + RequiredEnv: []string{"SLACK_BOT_TOKEN"}, + InstallType: "npm", Tags: []string{"slack", "communication"}, + }, + { + Name: "google-maps", Description: "Google Maps integration", + Category: "web", Package: "@modelcontextprotocol/server-google-maps", + Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-google-maps"}, + Env: map[string]string{"GOOGLE_MAPS_API_KEY": ""}, + RequiredEnv: []string{"GOOGLE_MAPS_API_KEY"}, + InstallType: "npm", Tags: []string{"maps", "location"}, + }, + }, + } +} + +func LoadRegistry() (*Registry, error) { + registryMu.RLock() + if registryCache != nil { + defer registryMu.RUnlock() + return registryCache, nil + } + registryMu.RUnlock() + + reg, err := loadRegistryFromDisk() + if err != nil { + defaultReg := DefaultRegistry() + registryMu.Lock() + registryCache = defaultReg + registryMu.Unlock() + return defaultReg, nil + } + + registryMu.Lock() + registryCache = reg + registryMu.Unlock() + return reg, nil +} + +func loadRegistryFromDisk() (*Registry, error) { + if registryPath == "" { + return nil, fmt.Errorf("registry path not set") + } + + data, err := os.ReadFile(registryPath) + if err != nil { + return nil, err + } + + var reg Registry + if err := yaml.Unmarshal(data, ®); err != nil { + return nil, fmt.Errorf("parse registry: %w", err) + } + + return ®, nil +} + +func SaveRegistry(reg *Registry) error { + if registryPath == "" { + return fmt.Errorf("registry path not set") + } + + reg.UpdatedAt = time.Now() + data, err := yaml.Marshal(reg) + if err != nil { + return fmt.Errorf("marshal registry: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(registryPath), 0755); err != nil { + return err + } + + if err := os.WriteFile(registryPath, data, 0644); err != nil { + return err + } + + registryMu.Lock() + registryCache = reg + registryMu.Unlock() + return nil +} + +func AddToRegistry(server RegistryServer) error { + reg, err := LoadRegistry() + if err != nil { + return err + } + + for _, s := range reg.Servers { + if s.Name == server.Name { + return fmt.Errorf("server %q already exists in registry", server.Name) + } + } + + reg.Servers = append(reg.Servers, server) + return SaveRegistry(reg) +} + +func RemoveFromRegistry(name string) error { + reg, err := LoadRegistry() + if err != nil { + return err + } + + for i, s := range reg.Servers { + if s.Name == name { + reg.Servers = append(reg.Servers[:i], reg.Servers[i+1:]...) + return SaveRegistry(reg) + } + } + + return fmt.Errorf("server %q not found in registry", name) +} + +func InitRegistry() error { + if _, err := os.Stat(registryPath); err == nil { + return nil + } + return SaveRegistry(DefaultRegistry()) +} + +func ResolveEnv(env map[string]string, providerKeys map[string]string) map[string]string { + resolved := make(map[string]string) + for k, v := range env { + if v != "" { + resolved[k] = v + continue + } + + if providerKeys != nil { + for providerKey, apiKey := range providerKeys { + if strings.EqualFold(k, providerKey) || strings.Contains(strings.ToUpper(k), strings.ToUpper(providerKey)) { + if apiKey != "" { + resolved[k] = apiKey + } + } + } + } + + if resolved[k] == "" { + if envVal := os.Getenv(k); envVal != "" { + resolved[k] = envVal + } + } + } + return resolved +} + +func ValidateConfig(configPath string) error { + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parse config: %w", err) + } + + return nil +} + +func DiscoverNpmServers() ([]RegistryServer, error) { + var servers []RegistryServer + + packages := []struct { + pkg string + name string + desc string + cat string + args []string + }{ + {"@modelcontextprotocol/server-filesystem", "filesystem", "File system operations", "core", []string{"-y", "@modelcontextprotocol/server-filesystem"}}, + {"@modelcontextprotocol/server-github", "github", "GitHub API integration", "vcs", []string{"-y", "@modelcontextprotocol/server-github"}}, + {"@modelcontextprotocol/server-fetch", "fetch", "Web fetching", "web", []string{"-y", "@modelcontextprotocol/server-fetch"}}, + {"@modelcontextprotocol/server-memory", "memory", "Persistent memory", "core", []string{"-y", "@modelcontextprotocol/server-memory"}}, + } + + for _, p := range packages { + servers = append(servers, RegistryServer{ + Name: p.name, + Description: p.desc, + Category: p.cat, + Package: p.pkg, + Command: "npx", + Args: p.args, + InstallType: "npm", + }) + } + + return servers, nil +} + +func GetInstalledVersion(name string) string { + home, _ := os.UserHomeDir() + if home == "" { + return "" + } + receiptPath := filepath.Join(home, ".muyue", "receipts", "mcp", name+".json") + data, err := os.ReadFile(receiptPath) + if err != nil { + return "" + } + var receipt struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &receipt) == nil { + return receipt.Version + } + return "" +} + +func SaveReceipt(name, version string) error { + home, _ := os.UserHomeDir() + if home == "" { + return nil + } + receiptDir := filepath.Join(home, ".muyue", "receipts", "mcp") + os.MkdirAll(receiptDir, 0755) + + receipt := struct { + Name string `json:"name"` + Version string `json:"version"` + UpdatedAt string `json:"updated_at"` + }{ + Name: name, + Version: version, + UpdatedAt: time.Now().Format(time.RFC3339), + } + + data, _ := json.MarshalIndent(receipt, "", " ") + return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644) +} + +func BuildProviderKeyMap(cfg interface{ GetAPIKeys() map[string]string }) map[string]string { + if cfg == nil { + return nil + } + return cfg.GetAPIKeys() +} + +func EditorConfigs(homeDir string) []EditorConfig { + if homeDir == "" { + home, _ := os.UserHomeDir() + homeDir = home + } + + transformStdio := func(e mcpEntry) interface{} { + m := map[string]interface{}{ + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + m["env"] = e.env + } + return m + } + + transformCursor := func(e mcpEntry) interface{} { + m := map[string]interface{}{ + "type": "stdio", + "command": e.cmd, + "args": e.args, + } + if len(e.env) > 0 { + m["env"] = e.env + } + return m + } + + return []EditorConfig{ + { + Name: "crush", ConfigPath: filepath.Join(homeDir, ".config", "crush", "crush.json"), + ConfigKey: "mcps", Format: "json", TransformCommand: transformStdio, + }, + { + Name: "claude-code", ConfigPath: filepath.Join(homeDir, ".claude.json"), + ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio, + }, + { + Name: "cursor", ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"), + LocalConfigPath: ".cursor/mcp.json", ConfigKey: "mcpServers", + Format: "json", TransformCommand: transformCursor, + }, + { + Name: "vscode", ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"), + LocalConfigPath: ".vscode/mcp.json", ConfigKey: "servers", + Format: "json", TransformCommand: transformStdio, + }, + { + Name: "windsurf", ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"), + ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio, + }, + } +} + +func CheckServerStatus(name string) MCPStatus { + status := MCPStatus{Name: name} + + reg, err := LoadRegistry() + if err != nil { + status.Error = "registry unavailable" + return status + } + + var server *RegistryServer + for i := range reg.Servers { + if reg.Servers[i].Name == name { + server = ®.Servers[i] + break + } + } + if server == nil { + status.Error = "not in registry" + return status + } + + _, err = exec.LookPath(server.Command) + if err != nil { + status.Error = fmt.Sprintf("command %q not found", server.Command) + return status + } + status.Installed = true + + status.Version = GetInstalledVersion(name) + + home, _ := os.UserHomeDir() + if home != "" { + crushingPath := filepath.Join(home, ".config", "crush", "crush.json") + data, err := os.ReadFile(crushingPath) + if err == nil { + var cfg map[string]interface{} + if json.Unmarshal(data, &cfg) == nil { + if mcps, ok := cfg["mcps"].(map[string]interface{}); ok { + if _, exists := mcps[name]; exists { + status.Running = true + status.Healthy = true + } + } + } + } + } + + return status +} diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go new file mode 100644 index 0000000..e8f9a42 --- /dev/null +++ b/internal/mcp/registry_test.go @@ -0,0 +1,228 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultRegistry(t *testing.T) { + reg := DefaultRegistry() + if reg.SchemaVersion != "v1" { + t.Errorf("Expected v1, got %s", reg.SchemaVersion) + } + if len(reg.Servers) == 0 { + t.Error("Default registry should have servers") + } + + names := map[string]bool{} + for _, s := range reg.Servers { + if names[s.Name] { + t.Errorf("Duplicate server name: %s", s.Name) + } + names[s.Name] = true + if s.Command == "" { + t.Errorf("Server %s missing command", s.Name) + } + } +} + +func TestSaveAndLoadRegistry(t *testing.T) { + tmpDir := t.TempDir() + registryPath := filepath.Join(tmpDir, "mcp-registry.yaml") + SetRegistryPath(registryPath) + + reg := DefaultRegistry() + if err := SaveRegistry(reg); err != nil { + t.Fatalf("SaveRegistry failed: %v", err) + } + + if _, err := os.Stat(registryPath); os.IsNotExist(err) { + t.Error("Registry file should exist") + } + + loaded, err := LoadRegistry() + if err != nil { + t.Fatalf("LoadRegistry failed: %v", err) + } + if len(loaded.Servers) != len(reg.Servers) { + t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers)) + } +} + +func TestAddAndRemoveFromRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetRegistryPath(filepath.Join(tmpDir, "mcp-registry.yaml")) + SaveRegistry(DefaultRegistry()) + + newServer := RegistryServer{ + Name: "test-server", + Description: "Test server", + Category: "test", + Command: "npx", + Args: []string{"-y", "test-pkg"}, + InstallType: "npm", + } + + if err := AddToRegistry(newServer); err != nil { + t.Fatalf("AddToRegistry failed: %v", err) + } + + reg, _ := LoadRegistry() + found := false + for _, s := range reg.Servers { + if s.Name == "test-server" { + found = true + break + } + } + if !found { + t.Error("test-server should be in registry") + } + + if err := RemoveFromRegistry("test-server"); err != nil { + t.Fatalf("RemoveFromRegistry failed: %v", err) + } + + reg, _ = LoadRegistry() + for _, s := range reg.Servers { + if s.Name == "test-server" { + t.Error("test-server should have been removed") + } + } +} + +func TestResolveEnv(t *testing.T) { + env := map[string]string{ + "API_KEY": "", + "HOST": "localhost", + } + + os.Setenv("API_KEY", "from-env") + defer os.Unsetenv("API_KEY") + + resolved := ResolveEnv(env, nil) + if resolved["API_KEY"] != "from-env" { + t.Errorf("Expected from-env, got %s", resolved["API_KEY"]) + } + if resolved["HOST"] != "localhost" { + t.Errorf("Expected localhost, got %s", resolved["HOST"]) + } +} + +func TestValidateConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "test-config.json") + os.WriteFile(configPath, []byte(`{"mcps":{}}`), 0644) + + if err := ValidateConfig(configPath); err != nil { + t.Errorf("Valid config should pass: %v", err) + } + + badPath := filepath.Join(tmpDir, "nonexistent.json") + if err := ValidateConfig(badPath); err == nil { + t.Error("Nonexistent config should fail") + } +} + +func TestEditorConfigs(t *testing.T) { + configs := EditorConfigs("/tmp") + if len(configs) < 3 { + t.Errorf("Expected at least 3 editor configs, got %d", len(configs)) + } + + names := map[string]bool{} + for _, c := range configs { + if names[c.Name] { + t.Errorf("Duplicate editor: %s", c.Name) + } + names[c.Name] = true + if c.ConfigPath == "" { + t.Errorf("Editor %s missing config path", c.Name) + } + if c.ConfigKey == "" { + t.Errorf("Editor %s missing config key", c.Name) + } + } +} + +func TestDiscoverNpmServers(t *testing.T) { + servers, err := DiscoverNpmServers() + if err != nil { + t.Fatalf("DiscoverNpmServers failed: %v", err) + } + if len(servers) == 0 { + t.Error("Should discover some npm servers") + } +} + +func TestReceiptRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Unsetenv("HOME") + + SetRegistryPath(filepath.Join(tmpDir, "reg.yaml")) + + if err := SaveReceipt("test-server", "1.2.3"); err != nil { + t.Fatalf("SaveReceipt failed: %v", err) + } + + version := GetInstalledVersion("test-server") + if version != "1.2.3" { + t.Errorf("Expected 1.2.3, got %s", version) + } +} + +func TestInitRegistry(t *testing.T) { + tmpDir := t.TempDir() + SetRegistryPath(filepath.Join(tmpDir, "init-reg.yaml")) + + if err := InitRegistry(); err != nil { + t.Fatalf("InitRegistry failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, "init-reg.yaml")); os.IsNotExist(err) { + t.Error("Registry file should be created") + } + + if err := InitRegistry(); err != nil { + t.Fatalf("Second InitRegistry should not fail: %v", err) + } +} + +func TestDetectInstalledEditors(t *testing.T) { + tmpDir := t.TempDir() + os.MkdirAll(filepath.Join(tmpDir, ".config", "crush"), 0755) + os.WriteFile(filepath.Join(tmpDir, ".config", "crush", "crush.json"), []byte(`{}`), 0644) + os.MkdirAll(filepath.Join(tmpDir, ".cursor"), 0755) + + editors := DetectInstalledEditors(tmpDir) + if len(editors) < 2 { + t.Errorf("Expected at least 2 editors, got %d", len(editors)) + } + + found := map[string]bool{} + for _, e := range editors { + found[e] = true + } + if !found["crush"] { + t.Error("Should detect crush") + } + if !found["cursor"] { + t.Error("Should detect cursor") + } +} + +func TestCheckServerStatus(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Unsetenv("HOME") + + SetRegistryPath(filepath.Join(tmpDir, "reg.yaml")) + SaveRegistry(DefaultRegistry()) + + status := CheckServerStatus("nonexistent") + if status.Error == "" { + t.Error("Should have error for nonexistent server") + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index b531502..ee8b171 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -158,48 +158,9 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { } o.histMu.Unlock() - body, err := json.Marshal(reqBody) + chatResp, providerName, err := o.sendWithFallback(reqBody, "") if err != nil { - return "", fmt.Errorf("marshal request: %w", err) - } - - baseURL := o.provider.BaseURL - if baseURL == "" { - baseURL = getProviderBaseURL(o.provider.Name) - } - - url := strings.TrimRight(baseURL, "/") + "/chat/completions" - - req, err := http.NewRequest("POST", url, bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+o.provider.APIKey) - - resp, err := o.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 chatResp ChatResponse - if err := json.Unmarshal(respBody, &chatResp); err != nil { - return "", fmt.Errorf("parse response: %w", err) - } - - if len(chatResp.Choices) == 0 { - return "", fmt.Errorf("no response from AI") + return "", err } content := cleanAIResponse(chatResp.Choices[0].Message.Content) @@ -208,6 +169,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { Role: "assistant", Content: content, }) + _ = providerName o.histMu.Unlock() return content, nil @@ -326,51 +288,16 @@ func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) Tools: o.tools, } - body, err := json.Marshal(reqBody) + chatResp, _, err := o.sendWithFallback(reqBody, "") if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) - } - - baseURL := o.provider.BaseURL - if baseURL == "" { - baseURL = getProviderBaseURL(o.provider.Name) - } - - url := strings.TrimRight(baseURL, "/") + "/chat/completions" - - req, err := http.NewRequest("POST", url, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+o.provider.APIKey) - - resp, err := o.client.Do(req) - if err != nil { - return nil, fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) - } - - var chatResp ChatResponse - if err := json.Unmarshal(respBody, &chatResp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) + return nil, err } if len(chatResp.Choices) == 0 { return nil, fmt.Errorf("no response from AI") } - return &chatResp, nil + return chatResp, nil } func cleanAIResponse(content string) string { @@ -411,3 +338,94 @@ func getProviderBaseURL(name string) string { return "" } } + +func (o *Orchestrator) getAvailableProviders() []*config.AIProvider { + var providers []*config.AIProvider + for i := range o.config.AI.Providers { + prov := &o.config.AI.Providers[i] + if prov.APIKey != "" { + providers = append(providers, prov) + } + } + return providers +} + +func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride string) (*ChatResponse, string, error) { + providers := o.getAvailableProviders() + + if len(providers) == 0 { + return nil, "", fmt.Errorf("no providers available") + } + + providerOrder := make([]*config.AIProvider, 0, len(providers)) + if o.provider != nil { + providerOrder = append(providerOrder, o.provider) + } + for _, p := range providers { + if o.provider == nil || p.Name != o.provider.Name { + providerOrder = append(providerOrder, p) + } + } + + var lastErr error + for _, prov := range providerOrder { + baseURL := baseURLOverride + if baseURL == "" { + baseURL = prov.BaseURL + if baseURL == "" { + baseURL = getProviderBaseURL(prov.Name) + } + } + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + + body, err := json.Marshal(reqBody) + if err != nil { + lastErr = fmt.Errorf("marshal request: %w", err) + continue + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + lastErr = fmt.Errorf("create request: %w", err) + continue + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+prov.APIKey) + + resp, err := o.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("send request to %s: %w", prov.Name, err) + continue + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + lastErr = fmt.Errorf("read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + continue + } + + var chatResp ChatResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + lastErr = fmt.Errorf("parse response: %w", err) + continue + } + + if len(chatResp.Choices) == 0 { + lastErr = fmt.Errorf("no response from AI") + continue + } + + o.provider = prov + return &chatResp, prov.Name, nil + } + + return nil, "", lastErr +} diff --git a/internal/skills/builtins.go b/internal/skills/builtins.go index 6c6200a..61237de 100644 --- a/internal/skills/builtins.go +++ b/internal/skills/builtins.go @@ -11,9 +11,10 @@ var builtinSkills = []Skill{ Name: "env-setup", Description: "Set up a complete development environment for any language. Detects missing tools, installs them, and configures the project.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"setup", "environment", "install"}, + Category: "setup", Content: `# Environment Setup Use this skill when setting up a new development environment or project. @@ -58,9 +59,14 @@ Use this skill when setting up a new development environment or project. Name: "git-workflow", Description: "Manage git branches, commits, and pull requests following best practices. Handles branching strategy, conventional commits, and PR creation.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"git", "workflow", "branching", "commits"}, + Category: "workflow", + Dependencies: []SkillDependency{ + {Type: "tool", Name: "git", Required: true}, + {Type: "tool", Name: "gh", Required: false}, + }, Content: `# Git Workflow Use this skill when the user needs to create branches, make commits, or manage pull requests. @@ -114,9 +120,10 @@ Follow Conventional Commits: Name: "api-design", Description: "Design and implement REST or GraphQL APIs following best practices. Includes endpoint design, error handling, and documentation.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"api", "rest", "graphql", "design"}, + Category: "design", Content: `# API Design Use this skill when designing or implementing an API. @@ -171,9 +178,10 @@ Use this skill when designing or implementing an API. Name: "debug-assist", Description: "Systematic debugging assistant. Helps identify, isolate, and fix bugs using a structured approach.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"debug", "troubleshooting", "bugs"}, + Category: "debugging", Content: `# Debug Assist Use this skill when the user reports a bug or asks for help debugging. @@ -188,7 +196,7 @@ Use this skill when the user reports a bug or asks for help debugging. 3. **Hypothesize** — Form a hypothesis about the root cause 4. **Verify** — Add logging or breakpoints to confirm 5. **Fix** — Make the minimal change to fix the issue -6. **Test** — Verify the fix works and doesn't break other things +6. **Test** — Verify the fix works and does not break other things 7. **Prevent** — Add a test to prevent regression ## Common Patterns @@ -211,9 +219,10 @@ Use this skill when the user reports a bug or asks for help debugging. Name: "code-review", Description: "Perform a thorough code review. Checks for bugs, security issues, performance problems, and style consistency.", Author: "muyue", - Version: "1.0.0", + Version: "1.1.0", Target: "both", Tags: []string{"review", "quality", "security"}, + Category: "quality", Content: `# Code Review Use this skill when reviewing code changes or pull requests. @@ -221,7 +230,7 @@ Use this skill when reviewing code changes or pull requests. ## Review Checklist ### Correctness -- Does the code do what it's supposed to? +- Does the code do what it is supposed to? - Are edge cases handled? - Are there off-by-one errors? - Are error paths handled? @@ -254,7 +263,7 @@ Use this skill when reviewing code changes or pull requests. ## Review Format 1. Summary of changes -2. Issues found (critical → minor) +2. Issues found (critical to minor) 3. Suggestions for improvement 4. Positive observations @@ -265,6 +274,351 @@ Use this skill when reviewing code changes or pull requests. - **Minor**: Style issues, naming, minor refactoring opportunities - **Suggestion**: Alternative approaches, improvements`, }, + { + Name: "docker-setup", + Description: "Set up Docker and docker-compose for a project with best practices including multi-stage builds, health checks, and proper networking.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"docker", "containers", "devops", "compose"}, + Category: "devops", + Dependencies: []SkillDependency{ + {Type: "tool", Name: "docker", Required: true}, + }, + Content: `# Docker Setup + +Use this skill when the user needs Docker configuration for a project. + +## Dockerfile Best Practices + +1. Use multi-stage builds to reduce image size: + - Builder stage: install dependencies, compile + - Runtime stage: copy only the binary/artifacts + +2. Use specific base image tags (not ` + "`latest`" + `): + - ` + "`golang:1.24-alpine`" + ` for Go + - ` + "`node:22-slim`" + ` for Node.js + - ` + "`python:3.12-slim`" + ` for Python + +3. Order layers for cache efficiency: + - Copy dependency files first (go.mod, package.json, requirements.txt) + - Install dependencies + - Copy source code last + +4. Add health checks: + ` + "```" + `dockerfile + HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1 + ` + "```" + ` + +5. Run as non-root user: + ` + "```" + `dockerfile + RUN adduser -D appuser + USER appuser + ` + "```" + ` + +## docker-compose.yml Structure + +` + "```" + `yaml +version: "3.9" +services: + app: + build: . + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgres://user:pass@db:5432/app + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + pgdata: +` + "```" + ` + +## Error Handling + +- If Docker is not installed, provide install instructions for the platform +- If port is already in use, suggest alternative ports +- If build fails, check for missing .dockerignore and suggest one`, + }, + { + Name: "security-audit", + Description: "Perform a security audit on code, dependencies, and configuration. Checks for OWASP Top 10 vulnerabilities, dependency vulnerabilities, and misconfigurations.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"security", "audit", "vulnerabilities", "owasp"}, + Category: "security", + Content: `# Security Audit + +Use this skill when the user needs a security review or vulnerability assessment. + +## Audit Checklist + +### Input Validation (OWASP A03:2021) +- All user input is validated and sanitized +- SQL queries use parameterized statements +- File paths are validated (no path traversal) +- Input length limits are enforced + +### Authentication and Authorization (OWASP A07:2021) +- Passwords are hashed with bcrypt/argon2 (never MD5/SHA1) +- JWT tokens have short expiry with refresh rotation +- Session management is secure +- RBAC or ABAC is properly implemented +- API endpoints have proper auth checks + +### Data Protection (OWASP A02:2021) +- Secrets are not in source code (use env vars or secret managers) +- Sensitive data is encrypted at rest and in transit +- PII is properly handled and not logged +- TLS is enforced for all connections + +### Dependency Security (OWASP A06:2021) +- Run ` + "`npm audit`" + `, ` + "`pip audit`" + `, or ` + "`go vuln check`" + ` +- Check for known CVEs in dependencies +- Keep dependencies up to date +- Use lock files for reproducible builds + +### Configuration Security +- Debug mode is disabled in production +- CORS is properly configured +- Rate limiting is in place +- Security headers are set (CSP, HSTS, X-Frame-Options) +- Error messages do not leak internal details + +## Automated Checks + +Run these tools if available: +- ` + "`gosec ./...`" + ` for Go security +- ` + "`bandit -r .`" + ` for Python security +- ` + "`npm audit`" + ` for Node.js vulnerabilities +- ` + "`trivy fs .`" + ` for container/Dockerfile scanning + +## Report Format + +1. Executive Summary (risk level, total findings) +2. Critical findings (immediate action required) +3. High findings (fix within 24h) +4. Medium findings (fix within sprint) +5. Low findings (address when convenient) +6. Recommendations`, + }, + { + Name: "mcp-setup", + Description: "Configure MCP (Model Context Protocol) servers for AI tools. Discovers, installs, and configures MCP servers across multiple editors.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"mcp", "ai", "configuration", "editors"}, + Category: "setup", + Dependencies: []SkillDependency{ + {Type: "tool", Name: "npx", Required: true}, + }, + Content: `# MCP Server Setup + +Use this skill when the user wants to configure MCP servers for their AI coding tools. + +## Supported Editors + +Muyue can generate MCP configs for: +- **Crush**: ` + "`~/.config/crush/crush.json`" + ` (key: ` + "`mcps`" + `) +- **Claude Code**: ` + "`~/.claude.json`" + ` (key: ` + "`mcpServers`" + `) +- **Cursor**: ` + "`~/.cursor/mcp.json`" + ` (key: ` + "`mcpServers`" + `, adds ` + "`type: stdio`" + `) +- **VS Code**: ` + "`~/.vscode/mcp.json`" + ` (key: ` + "`servers`" + `) +- **Windsurf**: ` + "`~/.windsurf/mcp.json`" + ` (key: ` + "`mcpServers`" + `) + +## Common MCP Servers + +| Server | Package | Required Env | +|--------|---------|-------------| +| filesystem | @modelcontextprotocol/server-filesystem | None | +| fetch | @modelcontextprotocol/server-fetch | None | +| github | @modelcontextprotocol/server-github | GITHUB_PERSONAL_ACCESS_TOKEN | +| brave-search | @modelcontextprotocol/server-brave-search | BRAVE_API_KEY | +| memory | @modelcontextprotocol/server-memory | None | +| postgres | @modelcontextprotocol/server-postgres | DATABASE_URL | +| sqlite | @modelcontextprotocol/server-sqlite | None | +| docker | @modelcontextprotocol/server-docker | None | + +## Setup Steps + +1. Ask which editors the user wants to configure +2. Ask which MCP servers they need +3. For servers requiring API keys, prompt for the key +4. Generate configs for each selected editor +5. Validate configs (check JSON is valid, commands exist) +6. Test connectivity if possible + +## Credential Management + +- API keys should be stored in the Muyue config (encrypted) +- When generating MCP configs, inject keys from the Muyue config +- Never hardcode API keys in config files in version control +- Suggest adding MCP config files to ` + "`.gitignore`" + ` + +## Troubleshooting + +- If npx fails, suggest ` + "`npm install -g`" + ` the package +- If a server does not start, check the command and args +- If auth fails, verify the API key is correct and active`, + }, + { + Name: "lsp-setup", + Description: "Configure Language Server Protocol servers for code intelligence. Detects project languages, installs LSPs, and generates editor configs.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"lsp", "language-server", "ide", "configuration"}, + Category: "setup", + Content: `# LSP Server Setup + +Use this skill when the user wants to set up language servers for code intelligence. + +## Supported Languages + +| Language | Server | Install Method | +|----------|--------|---------------| +| Go | gopls | ` + "`go install`" + ` | +| Python | pyright | ` + "`npm install -g`" + ` | +| TypeScript/JS | typescript-language-server | ` + "`npm install -g`" + ` | +| Rust | rust-analyzer | ` + "`rustup component add`" + ` | +| C/C++ | clangd | System package | +| Lua | lua-language-server | ` + "`npm install -g`" + ` | +| HTML | vscode-html-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` | +| CSS | vscode-css-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` | +| JSON | vscode-json-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` | +| YAML | yaml-language-server | ` + "`npm install -g`" + ` | +| Bash | bash-language-server | ` + "`npm install -g`" + ` | +| Docker | dockerfile-language-server | ` + "`npm install -g`" + ` | +| Vue | vue-language-server | ` + "`npm install -g`" + ` | +| Svelte | svelte-language-server | ` + "`npm install -g`" + ` | + +## Auto-Detection + +Detect project languages from: +- Config files: ` + "`go.mod`" + `, ` + "`package.json`" + `, ` + "`Cargo.toml`" + `, ` + "`pyproject.toml`" + ` +- Source file extensions: ` + "`*.go`" + `, ` + "`*.py`" + `, ` + "`*.ts`" + `, ` + "`*.rs`" + ` + +## Editor Config Generation + +### Neovim +Generate ` + "`lspconfig`" + ` setup snippet for each LSP. + +### Helix +Generate ` + "`languages.toml`" + ` entries with language-server mappings. + +### VS Code / Cursor +Generate ` + "`extensions.json`" + ` recommendations for each LSP. + +## Health Checks + +After installation, verify: +1. The binary is in PATH +2. The version matches expected +3. A basic ` + "`initialize`" + ` request succeeds (if applicable)`, + }, + { + Name: "workflow-design", + Description: "Design development workflows and automations. Creates CI/CD pipelines, git hooks, and development process documentation.", + Author: "muyue", + Version: "1.0.0", + Target: "both", + Tags: []string{"workflow", "ci-cd", "automation", "process"}, + Category: "workflow", + Content: `# Workflow Design + +Use this skill when the user wants to establish development workflows or CI/CD pipelines. + +## CI/CD Pipeline Design + +### GitHub Actions Template + +` + "```" + `yaml +name: CI +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.24" } + - run: go vet ./... + - run: golint ./... + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.24" } + - run: go test -race -coverprofile=coverage.out ./... + - run: go tool cover -func=coverage.out + + build: + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: go build -o bin/app ./cmd/app +` + "```" + ` + +## Git Hooks + +Use ` + "`pre-commit`" + ` framework: +- ` + "`pre-commit`" + `: lint, format check, trailing whitespace +- ` + "`commit-msg`" + `: validate conventional commit format +- ` + "`pre-push`" + `: run tests + +## Branch Protection Rules + +- Require PR reviews (at least 1 approval) +- Require status checks to pass +- Require up-to-date branch before merge +- Require linear history (rebase merge) + +## Development Process + +1. Pick a task from the backlog +2. Create a feature branch +3. Implement with tests +4. Run linter and tests locally +5. Push and create PR +6. Address review feedback +7. Merge when approved and CI passes +8. Delete feature branch + +## Error Handling + +- If CI fails, provide clear error output and suggested fixes +- If hooks fail, explain what failed and how to fix +- Suggest ` + "`--no-verify`" + ` only as a last resort, with a warning`, + }, } func InstallBuiltinSkills() error { diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 1953a5d..13270f6 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -1,27 +1,53 @@ package skills import ( + "encoding/json" "fmt" "os" "path/filepath" - "sort" + "regexp" "strings" "time" "gopkg.in/yaml.v3" ) +type SkillDependency struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + Required bool `yaml:"required,omitempty" json:"required,omitempty"` +} + 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:"-"` + 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"` +} + +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func (v ValidationError) Error() string { + return fmt.Sprintf("%s: %s", v.Field, v.Message) +} + +type SkillTestResult struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Message string `json:"message"` } func SkillsDir() (string, error) { @@ -66,10 +92,6 @@ func List() ([]Skill, error) { skills = append(skills, *skill) } - sort.Slice(skills, func(i, j int) bool { - return skills[i].Name < skills[j].Name - }) - return skills, nil } @@ -95,6 +117,10 @@ func Get(name string) (*Skill, error) { } func Create(skill *Skill) error { + if errs := Validate(skill); len(errs) > 0 { + return fmt.Errorf("validation failed: %v", errs) + } + dir, err := SkillsDir() if err != nil { return err @@ -129,6 +155,28 @@ func Delete(name string) error { return nil } +func Update(skill *Skill) error { + if errs := Validate(skill); len(errs) > 0 { + return fmt.Errorf("validation failed: %v", errs) + } + + dir, err := SkillsDir() + if err != nil { + return err + } + + skillDir := filepath.Join(dir, skill.Name) + skillPath := filepath.Join(skillDir, "SKILL.md") + + skill.UpdatedAt = time.Now() + content := renderSkill(skill) + if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil { + return err + } + + return Deploy(skill) +} + func Deploy(skill *Skill) error { home, err := os.UserHomeDir() if err != nil { @@ -188,6 +236,206 @@ func undeployFromTargets(name string) { os.RemoveAll(filepath.Join(home, ".claude", "skills", name)) } +func Validate(skill *Skill) []ValidationError { + var errs []ValidationError + + if skill.Name == "" { + errs = append(errs, ValidationError{Field: "name", Message: "name is required"}) + } + + if skill.Name != "" { + if matched, _ := regexp.MatchString(`^[a-z0-9][a-z0-9-]*$`, skill.Name); !matched { + errs = append(errs, ValidationError{Field: "name", Message: "name must be lowercase alphanumeric with dashes"}) + } + } + + if skill.Description == "" { + errs = append(errs, ValidationError{Field: "description", Message: "description is required"}) + } + + if skill.Content == "" { + errs = append(errs, ValidationError{Field: "content", Message: "content is required"}) + } + + if skill.Target != "" && skill.Target != "crush" && skill.Target != "claude" && skill.Target != "both" { + errs = append(errs, ValidationError{Field: "target", Message: "target must be crush, claude, or both"}) + } + + if skill.Version != "" { + if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+$`, skill.Version); !matched { + errs = append(errs, ValidationError{Field: "version", Message: "version must be semver (e.g. 1.0.0)"}) + } + } + + for i, dep := range skill.Dependencies { + if dep.Type != "mcp_server" && dep.Type != "lsp" && dep.Type != "tool" && dep.Type != "runtime" && dep.Type != "" { + errs = append(errs, ValidationError{ + Field: fmt.Sprintf("dependencies[%d].type", i), + Message: "dependency type must be mcp_server, lsp, tool, or runtime", + }) + } + if dep.Name == "" { + errs = append(errs, ValidationError{ + Field: fmt.Sprintf("dependencies[%d].name", i), + Message: "dependency name is required", + }) + } + } + + return errs +} + +func CheckDependencies(skill *Skill) []SkillDependency { + var missing []SkillDependency + for _, dep := range skill.Dependencies { + switch dep.Type { + case "mcp_server": + if !isMCPServerAvailable(dep.Name) { + missing = append(missing, dep) + } + case "lsp", "tool", "runtime": + if !isToolAvailable(dep.Name) { + missing = append(missing, dep) + } + } + } + return missing +} + +func isToolAvailable(name string) bool { + _, err := lookPath(name) + return err == nil +} + +func lookPath(name string) (string, error) { + pathEnv := os.Getenv("PATH") + home, _ := os.UserHomeDir() + if home != "" { + pathEnv = home + "/.local/bin:" + home + "/go/bin:" + pathEnv + } + for _, dir := range filepath.SplitList(pathEnv) { + candidate := filepath.Join(dir, name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + return "", fmt.Errorf("%s not found", name) +} + +func isMCPServerAvailable(name string) bool { + home, _ := os.UserHomeDir() + if home == "" { + return false + } + configPath := filepath.Join(home, ".config", "crush", "crush.json") + data, err := os.ReadFile(configPath) + if err != nil { + return false + } + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + return false + } + mcps, ok := cfg["mcps"].(map[string]interface{}) + if !ok { + return false + } + _, exists := mcps[name] + return exists +} + +func Export(name string, exportPath string) error { + skill, err := Get(name) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(exportPath), 0755); err != nil { + return err + } + + content := renderSkill(skill) + return os.WriteFile(exportPath, []byte(content), 0644) +} + +func Import(exportPath string) (*Skill, error) { + data, err := os.ReadFile(exportPath) + if err != nil { + return nil, fmt.Errorf("read export file: %w", err) + } + + skill, err := parseSkill(data) + if err != nil { + return nil, err + } + + name := filepath.Base(filepath.Dir(exportPath)) + if skill.Name == "" { + skill.Name = strings.TrimSuffix(filepath.Base(exportPath), ".md") + if skill.Name == "SKILL" { + skill.Name = filepath.Base(filepath.Dir(exportPath)) + } + } + + _ = name + if errs := Validate(skill); len(errs) > 0 { + return nil, fmt.Errorf("validation failed: %v", errs) + } + + return skill, nil +} + +func DryRun(name string, sampleTask string) SkillTestResult { + skill, err := Get(name) + if err != nil { + return SkillTestResult{Name: name, Passed: false, Message: fmt.Sprintf("skill not found: %s", err)} + } + + if skill.Content == "" { + return SkillTestResult{Name: name, Passed: false, Message: "skill has no content"} + } + + if len(skill.Dependencies) > 0 { + missing := CheckDependencies(skill) + if len(missing) > 0 { + var names []string + for _, d := range missing { + names = append(names, d.Name) + } + return SkillTestResult{ + Name: name, + Passed: false, + Message: fmt.Sprintf("missing dependencies: %s", strings.Join(names, ", ")), + } + } + } + + if sampleTask != "" { + tags := skill.Tags + taskLower := strings.ToLower(sampleTask) + matched := false + for _, tag := range tags { + if strings.Contains(taskLower, strings.ToLower(tag)) { + matched = true + break + } + } + if len(tags) > 0 && !matched { + return SkillTestResult{ + Name: name, + Passed: true, + Message: "skill loaded but sample task does not match skill tags", + } + } + } + + return SkillTestResult{ + Name: name, + Passed: true, + Message: "skill validated successfully", + } +} + func parseSkill(data []byte) (*Skill, error) { content := string(data) @@ -227,9 +475,25 @@ func renderSkill(skill *Skill) string { if skill.Target != "" { b.WriteString(fmt.Sprintf("target: %s\n", skill.Target)) } + if skill.Category != "" { + b.WriteString(fmt.Sprintf("category: %s\n", skill.Category)) + } if len(skill.Tags) > 0 { b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", "))) } + if len(skill.Languages) > 0 { + b.WriteString(fmt.Sprintf("languages: [%s]\n", strings.Join(skill.Languages, ", "))) + } + if len(skill.Dependencies) > 0 { + b.WriteString("dependencies:\n") + for _, dep := range skill.Dependencies { + req := "" + if dep.Required { + req = ", required: true" + } + b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req)) + } + } b.WriteString("---\n\n") b.WriteString(skill.Content) b.WriteString("\n") @@ -245,7 +509,7 @@ DESCRIPTION: %s TARGET: %s (crush = Crush with GLM, claude = Claude Code, both = both tools) The skill must follow this EXACT format: -1. YAML frontmatter with: name, description +1. YAML frontmatter with: name, description, tags, dependencies (if needed) 2. Markdown body with detailed instructions The skill should be practical, specific, and actionable. @@ -255,5 +519,10 @@ Include: - Examples where relevant - Error handling guidance +If the skill requires specific tools, MCP servers, or LSP servers, declare them as dependencies: + - type: mcp_server, name: + - type: lsp, name: + - type: tool, name: + Output ONLY the skill file content, starting with ---`, name, description, target) } diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 872cc80..17c0a18 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -113,7 +113,7 @@ func TestCreateAndGet(t *testing.T) { Description: "Test description", Content: "Test content body", Author: "tester", - Version: "0.1", + Version: "1.0.0", Target: "both", } @@ -198,3 +198,242 @@ func TestInstallBuiltinSkills(t *testing.T) { t.Error("Expected env-setup skill") } } + +func TestValidate(t *testing.T) { + skill := &Skill{ + Name: "valid-skill", + Description: "A valid skill", + Content: "## Steps\nDo things", + Version: "1.0.0", + Target: "both", + } + + errs := Validate(skill) + if len(errs) != 0 { + t.Errorf("Valid skill should have no errors, got %v", errs) + } +} + +func TestValidateMissingFields(t *testing.T) { + skill := &Skill{} + errs := Validate(skill) + if len(errs) == 0 { + t.Error("Empty skill should have validation errors") + } + + fields := map[string]bool{} + for _, e := range errs { + fields[e.Field] = true + } + if !fields["name"] { + t.Error("Should require name") + } + if !fields["description"] { + t.Error("Should require description") + } + if !fields["content"] { + t.Error("Should require content") + } +} + +func TestValidateBadVersion(t *testing.T) { + skill := &Skill{ + Name: "test-skill", + Description: "desc", + Content: "content", + Version: "not-semver", + } + errs := Validate(skill) + hasVersionErr := false + for _, e := range errs { + if e.Field == "version" { + hasVersionErr = true + } + } + if !hasVersionErr { + t.Error("Should reject non-semver version") + } +} + +func TestValidateBadTarget(t *testing.T) { + skill := &Skill{ + Name: "test", + Description: "desc", + Content: "content", + Target: "invalid", + } + errs := Validate(skill) + hasTargetErr := false + for _, e := range errs { + if e.Field == "target" { + hasTargetErr = true + } + } + if !hasTargetErr { + t.Error("Should reject invalid target") + } +} + +func TestValidateBadName(t *testing.T) { + skill := &Skill{ + Name: "INVALID", + Description: "desc", + Content: "content", + } + errs := Validate(skill) + hasNameErr := false + for _, e := range errs { + if e.Field == "name" { + hasNameErr = true + } + } + if !hasNameErr { + t.Error("Should reject uppercase name") + } +} + +func TestValidateDependencies(t *testing.T) { + skill := &Skill{ + Name: "test", + Description: "desc", + Content: "content", + Dependencies: []SkillDependency{ + {Type: "mcp_server", Name: "github", Required: true}, + {Type: "invalid_type", Name: "test"}, + }, + } + errs := Validate(skill) + hasDepErr := false + for _, e := range errs { + if e.Field == "dependencies[1].type" { + hasDepErr = true + } + } + if !hasDepErr { + t.Error("Should reject invalid dependency type") + } +} + +func TestExportImport(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", tmpDir) + + skill := &Skill{ + Name: "export-test", + Description: "Export test skill", + Content: "## Content", + Author: "tester", + Version: "1.0.0", + Target: "both", + Tags: []string{"test"}, + } + Create(skill) + + exportPath := filepath.Join(tmpDir, "export", "export-test.md") + if err := Export("export-test", exportPath); err != nil { + t.Fatalf("Export failed: %v", err) + } + + if _, err := os.Stat(exportPath); os.IsNotExist(err) { + t.Error("Export file should exist") + } + + imported, err := Import(exportPath) + if err != nil { + t.Fatalf("Import failed: %v", err) + } + if imported.Description != "Export test skill" { + t.Errorf("Expected 'Export test skill', got %s", imported.Description) + } +} + +func TestDryRun(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", tmpDir) + + skill := &Skill{ + Name: "dry-run-test", + Description: "Dry run test", + Content: "## Steps\nDo something", + Version: "1.0.0", + Target: "both", + Tags: []string{"test"}, + } + Create(skill) + + result := DryRun("dry-run-test", "test something") + if !result.Passed { + t.Errorf("DryRun should pass, got: %s", result.Message) + } +} + +func TestDryRunMissing(t *testing.T) { + result := DryRun("nonexistent", "") + if result.Passed { + t.Error("DryRun of nonexistent skill should fail") + } +} + +func TestUpdate(t *testing.T) { + tmpDir := t.TempDir() + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", tmpDir) + + skill := &Skill{ + Name: "update-test", + Description: "Original", + Content: "Original content", + Version: "1.0.0", + Target: "both", + } + Create(skill) + + skill.Description = "Updated" + skill.Content = "Updated content" + skill.Version = "2.0.0" + if err := Update(skill); err != nil { + t.Fatalf("Update failed: %v", err) + } + + got, err := Get("update-test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got.Description != "Updated" { + t.Errorf("Expected 'Updated', got %s", got.Description) + } +} + +func TestBuiltinSkillCount(t *testing.T) { + if len(builtinSkills) < 5 { + t.Errorf("Expected at least 5 builtin skills, got %d", len(builtinSkills)) + } + + expectedSkills := []string{"env-setup", "git-workflow", "api-design", "debug-assist", "code-review", "docker-setup", "security-audit", "mcp-setup", "lsp-setup", "workflow-design"} + for _, name := range expectedSkills { + found := false + for _, s := range builtinSkills { + if s.Name == name { + found = true + break + } + } + if !found { + t.Errorf("Expected builtin skill: %s", name) + } + } +} + +func TestBuiltinSkillsHaveDependencies(t *testing.T) { + hasDeps := 0 + for _, s := range builtinSkills { + if len(s.Dependencies) > 0 { + hasDeps++ + } + } + if hasDeps == 0 { + t.Error("At least some builtin skills should declare dependencies") + } +} diff --git a/internal/workflow/engine.go b/internal/workflow/engine.go new file mode 100644 index 0000000..1764020 --- /dev/null +++ b/internal/workflow/engine.go @@ -0,0 +1,362 @@ +package workflow + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/muyue/muyue/internal/agent" + "github.com/muyue/muyue/internal/config" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusDone Status = "done" + StatusFailed Status = "failed" + StatusSkipped Status = "skipped" + StatusAwaiting Status = "awaiting_approval" +) + +type StepType string + +const ( + TypeToolCall StepType = "tool_call" + TypeCondition StepType = "condition" + TypeParallel StepType = "parallel" + TypeApproval StepType = "approval" +) + +type Step struct { + ID string `json:"id"` + Name string `json:"name"` + Type StepType `json:"type"` + Tool string `json:"tool,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Status Status `json:"status"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Condition string `json:"condition,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` + ApproveRole string `json:"approve_role,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` +} + +type Workflow struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Steps []Step `json:"steps"` + Status Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Engine struct { + mu sync.RWMutex + workflows map[string]*Workflow + agentRegistry *agent.Registry + storePath string +} + +func NewEngine(registry *agent.Registry) (*Engine, error) { + dir, err := config.ConfigDir() + if err != nil { + dir = "/tmp/muyue" + } + + storePath := filepath.Join(dir, "workflows.json") + engine := &Engine{ + workflows: make(map[string]*Workflow), + agentRegistry: registry, + storePath: storePath, + } + + engine.load() + return engine, nil +} + +func (e *Engine) load() { + data, err := os.ReadFile(e.storePath) + if err != nil { + return + } + + var workflows []*Workflow + if err := json.Unmarshal(data, &workflows); err != nil { + return + } + + for _, w := range workflows { + e.workflows[w.ID] = w + } +} + +func (e *Engine) save() error { + dir := filepath.Dir(e.storePath) + os.MkdirAll(dir, 0755) + + e.mu.RLock() + workflows := make([]*Workflow, 0, len(e.workflows)) + for _, w := range e.workflows { + workflows = append(workflows, w) + } + e.mu.RUnlock() + + data, err := json.MarshalIndent(workflows, "", " ") + if err != nil { + return err + } + + return os.WriteFile(e.storePath, data, 0600) +} + +func (e *Engine) Create(name, description, wfType string, steps []Step) *Workflow { + wf := &Workflow{ + ID: fmt.Sprintf("wf-%d", time.Now().UnixNano()), + Name: name, + Description: description, + Type: wfType, + Steps: steps, + Status: StatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + for i := range wf.Steps { + if wf.Steps[i].ID == "" { + wf.Steps[i].ID = fmt.Sprintf("step-%d", i) + } + if wf.Steps[i].Status == "" { + wf.Steps[i].Status = StatusPending + } + } + + e.mu.Lock() + e.workflows[wf.ID] = wf + e.mu.Unlock() + + e.save() + return wf +} + +func (e *Engine) Get(id string) (*Workflow, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + wf, ok := e.workflows[id] + return wf, ok +} + +func (e *Engine) List() []*Workflow { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]*Workflow, 0, len(e.workflows)) + for _, w := range e.workflows { + result = append(result, w) + } + return result +} + +func (e *Engine) Delete(id string) error { + e.mu.Lock() + defer e.mu.Unlock() + if _, ok := e.workflows[id]; !ok { + return fmt.Errorf("workflow not found: %s", id) + } + delete(e.workflows, id) + return e.save() +} + +func (e *Engine) UpdateStep(workflowID, stepID string, update func(*Step)) error { + e.mu.Lock() + defer e.mu.Unlock() + + wf, ok := e.workflows[workflowID] + if !ok { + return fmt.Errorf("workflow not found: %s", workflowID) + } + + for i := range wf.Steps { + if wf.Steps[i].ID == stepID { + update(&wf.Steps[i]) + wf.UpdatedAt = time.Now() + e.save() + return nil + } + } + + return fmt.Errorf("step not found: %s", stepID) +} + +func (e *Engine) UpdateWorkflowStatus(workflowID string, status Status) error { + e.mu.Lock() + defer e.mu.Unlock() + + wf, ok := e.workflows[workflowID] + if !ok { + return fmt.Errorf("workflow not found: %s", workflowID) + } + + wf.Status = status + wf.UpdatedAt = time.Now() + return e.save() +} + +func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(step *Step, event string)) error { + wf, ok := e.Get(workflowID) + if !ok { + return fmt.Errorf("workflow not found: %s", workflowID) + } + + if err := e.UpdateWorkflowStatus(workflowID, StatusRunning); err != nil { + return err + } + + stepStatuses := make(map[string]Status) + for _, step := range wf.Steps { + stepStatuses[step.ID] = StatusPending + } + + resolveDeps := func(stepID string) bool { + step := wf.findStep(stepID) + if step == nil { + return false + } + for _, dep := range step.DependsOn { + if stepStatuses[dep] != StatusDone { + return false + } + } + return true + } + + executeStep := func(step *Step) error { + now := time.Now() + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusRunning + s.StartedAt = &now + }) + + if onStep != nil { + onStep(step, "started") + } + + var result string + var stepErr error + + switch step.Type { + case TypeToolCall: + if step.Tool == "" { + stepErr = fmt.Errorf("tool not specified for step %s", step.ID) + } else { + call := agent.ToolCall{ + ID: step.ID, + Name: step.Tool, + Arguments: step.Args, + } + resp, err := e.agentRegistry.Execute(ctx, call) + if err != nil { + stepErr = err + } else { + result = resp.Content + if resp.IsError { + stepErr = fmt.Errorf("%s", result) + } + } + } + + case TypeApproval: + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusAwaiting + }) + if onStep != nil { + onStep(step, "awaiting_approval") + } + return nil + + case TypeCondition: + result = fmt.Sprintf("condition '%s' evaluated", step.Condition) + + default: + stepErr = fmt.Errorf("unknown step type: %s", step.Type) + } + + endTime := time.Now() + if stepErr != nil { + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusFailed + s.Error = stepErr.Error() + s.EndedAt = &endTime + }) + if onStep != nil { + onStep(step, "failed") + } + } else { + e.UpdateStep(workflowID, step.ID, func(s *Step) { + s.Status = StatusDone + s.Result = result + s.EndedAt = &endTime + }) + stepStatuses[step.ID] = StatusDone + if onStep != nil { + onStep(step, "done") + } + } + + return stepErr + } + + hasFailures := false + + for _, step := range wf.Steps { + if step.Type == TypeParallel { + continue + } + + for !resolveDeps(step.ID) { + time.Sleep(100 * time.Millisecond) + } + + if err := executeStep(&step); err != nil { + hasFailures = true + break + } + } + + if hasFailures { + e.UpdateWorkflowStatus(workflowID, StatusFailed) + } else { + e.UpdateWorkflowStatus(workflowID, StatusDone) + } + + return nil +} + +func (w *Workflow) findStep(id string) *Step { + for i := range w.Steps { + if w.Steps[i].ID == id { + return &w.Steps[i] + } + } + return nil +} + +func (e *Engine) ApproveStep(workflowID, stepID string) error { + return e.UpdateStep(workflowID, stepID, func(s *Step) { + s.Status = StatusDone + }) +} + +func (e *Engine) SkipStep(workflowID, stepID string) error { + return e.UpdateStep(workflowID, stepID, func(s *Step) { + s.Status = StatusSkipped + }) +} \ No newline at end of file diff --git a/internal/workflow/planner.go b/internal/workflow/planner.go new file mode 100644 index 0000000..ccec148 --- /dev/null +++ b/internal/workflow/planner.go @@ -0,0 +1,172 @@ +package workflow + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/orchestrator" +) + +type Planner struct { + orchestrator *orchestrator.Orchestrator +} + +func NewPlanner(cfg *config.MuyueConfig) (*Planner, error) { + orb, err := orchestrator.New(cfg) + if err != nil { + return nil, err + } + orb.SetSystemPrompt(plannerSystemPrompt) + return &Planner{orchestrator: orb}, nil +} + +func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error) { + prompt := buildPlanPrompt(goal) + + messages := []orchestrator.Message{ + {Role: "user", Content: prompt}, + } + + resp, err := p.orchestrator.SendWithTools(messages) + if err != nil { + return nil, err + } + + if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { + return nil, fmt.Errorf("no plan generated") + } + + content := resp.Choices[0].Message.Content + plan, err := parsePlanResponse(content) + if err != nil { + return nil, err + } + + return plan, nil +} + +func buildPlanPrompt(goal string) string { + return fmt.Sprintf(`Tu es un planificateur de workflows pour Muyue. L'utilisateur veut accomplir la tĂąche suivante: + +"%s" + +Analyse cette tĂąche et gĂ©nĂšre un plan d'exĂ©cution en une sĂ©rie d'Ă©tapes. Chaque Ă©tape est un appel d'outil. + +Les outils disponibles sont: +- terminal: ExĂ©cuter une commande shell +- read_file: Lire un fichier +- list_files: Lister les fichiers d'un rĂ©pertoire +- search_files: Rechercher des fichiers par pattern +- grep_content: Rechercher du texte dans des fichiers +- get_config: Lire la configuration Muyue +- set_provider: Configurer un provider AI +- manage_ssh: GĂ©rer les connexions SSH +- web_fetch: RĂ©cupĂ©rer le contenu d'une URL + +RĂ©ponds UNIQUEMENT avec un JSON valide reprĂ©sentant un tableau d'Ă©tapes, sans texte avant ou aprĂšs: + +[ + {"name": "Nom de l'Ă©tape", "tool": "terminal", "args": {"command": "ls -la"}}, + {"name": "Lire le fichier config", "tool": "read_file", "args": {"path": "~/.muyue/config.json"}} +] + +RĂšgles: +- Chaque Ă©tape doit avoir: name, tool, args +- Les args varient selon le tool (voir les dĂ©finitions) +- Sois prĂ©cis dans les commandes +- SĂ©pare en Ă©tapes logiques +- Ne gĂ©nĂšre pas plus de 10 Ă©tapes`, goal) +} + +func parsePlanResponse(content string) ([]Step, error) { + content = strings.TrimSpace(content) + + var jsonStr string + if strings.HasPrefix(content, "```json") { + lines := strings.Split(content, "\n") + var jsonLines []string + for _, line := range lines[1:] { + if strings.HasPrefix(line, "```") { + break + } + jsonLines = append(jsonLines, line) + } + jsonStr = strings.Join(jsonLines, "\n") + } else if strings.HasPrefix(content, "```") { + lines := strings.Split(content, "\n") + var jsonLines []string + for _, line := range lines[1:] { + if strings.HasPrefix(line, "```") { + break + } + jsonLines = append(jsonLines, line) + } + jsonStr = strings.Join(jsonLines, "\n") + } else { + jsonStr = content + } + + var rawSteps []map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &rawSteps); err != nil { + return nil, fmt.Errorf("failed to parse plan JSON: %v\nContent: %s", err, content) + } + + steps := make([]Step, 0, len(rawSteps)) + for i, raw := range rawSteps { + step := Step{ + ID: fmt.Sprintf("step-%d", i), + Status: StatusPending, + } + + if name, ok := raw["name"].(string); ok { + step.Name = name + } else { + step.Name = fmt.Sprintf("Step %d", i+1) + } + + if tool, ok := raw["tool"].(string); ok { + step.Tool = tool + step.Type = TypeToolCall + } + + if args, ok := raw["args"].(map[string]interface{}); ok { + argsJSON, err := json.Marshal(args) + if err == nil { + step.Args = argsJSON + } + } + + if tool, ok := raw["type"].(string); ok { + switch tool { + case "approval": + step.Type = TypeApproval + case "condition": + step.Type = TypeCondition + if cond, ok := raw["condition"].(string); ok { + step.Condition = cond + } + default: + step.Type = TypeToolCall + } + } + + steps = append(steps, step) + } + + return steps, nil +} + +const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu gĂ©nĂšres des plans d'exĂ©cution sous forme de JSON. Chaque plan est une sĂ©quence d'Ă©tapes (steps) reprĂ©sentant des appels d'outils. + +Pour gĂ©nĂ©rer un plan: +1. Comprends l'objectif de l'utilisateur +2. Identifie les outils nĂ©cessaires +3. DĂ©compose en Ă©tapes logiques +4. SpĂ©cifie les paramĂštres de chaque outil + +RĂ©ponds toujours en JSON valide, sans texte additionnel.` + +var _ = plannerSystemPrompt \ No newline at end of file diff --git a/web/src/api/client.js b/web/src/api/client.js index d86dd89..91a8cc4 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -26,6 +26,17 @@ const api = { runScan: () => request('/scan', { method: 'POST' }), installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }), configureMCP: () => request('/mcp/configure', { method: 'POST' }), + configureMCPForEditor: (editor) => request('/mcp/configure', { method: 'POST', body: JSON.stringify({ editor }) }), + getMCPStatus: () => request('/mcp/status'), + getMCPRegistry: () => request('/mcp/registry'), + getLSPHealth: () => request('/lsp/health'), + autoInstallLSP: (projectDir) => request('/lsp/auto-install', { method: 'POST', body: JSON.stringify({ project_dir: projectDir || '' }) }), + generateLSPConfig: (editor, names) => request('/lsp/editor-config', { method: 'POST', body: JSON.stringify({ editor, names }) }), + validateSkill: (name) => request('/skills/validate', { method: 'POST', body: JSON.stringify({ name }) }), + testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }), + exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }), + importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }), + getDashboardStatus: () => request('/dashboard/status'), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), @@ -84,6 +95,66 @@ const api = { }).catch(reject) }) }, + sendShellChat: (message, context = {}, stream = true, onChunk) => { + const payload = { + message, + context: context.context || '', + history: context.history || [], + cwd: context.cwd || '', + platform: context.platform || '', + stream, + } + if (!stream) { + return request('/shell/chat', { method: 'POST', body: JSON.stringify(payload) }) + } + return new Promise((resolve, reject) => { + fetch(`${API_BASE}/shell/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).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 = '' + let toolCalls = [] + 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({ content: full, tool_calls: toolCalls }) + return + } + if (data.content) { + full += data.content + if (onChunk) onChunk(full, data) + } else if (data.tool_call) { + toolCalls.push(data.tool_call) + if (onChunk) onChunk(full, data, toolCalls) + } else if (data.tool_result) { + const idx = toolCalls.findIndex(tc => tc.tool_call_id === data.tool_result.id) + if (idx >= 0) { + toolCalls[idx].result = data.tool_result + } + if (onChunk) onChunk(full, data, toolCalls) + } + } catch {} + } + } + resolve({ content: full, tool_calls: toolCalls }) + }).catch(reject) + }) + }, } export default api diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index baf6ce0..11d6b4d 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -447,7 +447,14 @@ function PanelSkills({ skillList, t }) {
{s.name} {s.target || 'both'} + {s.version && {s.version}} + {s.category && {s.category}} {s.description} + {s.dependencies && s.dependencies.length > 0 && ( +
+ deps: {s.dependencies.map(d => d.name).join(', ')} +
+ )}
)) )} diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 3f3fec8..3efde8f 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -1,58 +1,438 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' -export default function Dashboard({ api }) { +const TOOL_ICONS = { + crush: '⚡', + claude: 'đŸ€–', + go: 'đŸ”·', + node: '🟱', + python: '🐍', + docker: '🐳', + git: '📚', + ssh: '🌐', + starship: '🚀', + rust: '🩀', +} + +function ToolCard({ tool, onInstall, installing }) { const { t } = useI18n() - const [notifications, setNotifications] = useState([]) + const [showInstall, setShowInstall] = useState(false) + + const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧' + const isInstalled = tool.installed || tool.status === 'installed' + const version = tool.version || '' + const hasUpdate = tool.hasUpdate || tool.updateAvailable return ( -
-
-
-
-
-
{t('studio.workflows')}
-
-
-
-
{t('studio.workflows')}
-
- {t('studio.noWorkflow')} -
-
-
-
{t('studio.activeAgents')}
-
- {t('studio.noWorkflow')} -
-
-
-
- -
-
-
{t('dashboard.activityLog')}
- {notifications.length > 0 && ( - {notifications.length} - )} -
- {notifications.length === 0 ? ( -
{t('dashboard.noUpdateData')}
- ) : ( -
- {notifications.map(n => ( -
- - {n.time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} - - {n.text} -
- ))} -
- )} -
+
+
{icon}
+
+
{tool.name || 'Unknown'}
+
+ {isInstalled ? ( + {t('dashboard.installed')} + ) : ( + {t('dashboard.missing')} + )} + {version && {version}}
+
+ {isInstalled && hasUpdate && ( + + ↑ {tool.latestVersion || 'new'} + + )} + {!isInstalled && ( + + )} +
) } + +function ActivityItem({ entry }) { + const time = entry.time + ? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : '' + const type = entry.type || entry.level || 'info' + const text = entry.message || entry.text || entry.content || '' + + const typeClass = { + ok: 'notif-ok', + success: 'notif-ok', + install: 'notif-ok', + update: 'notif-info', + info: 'notif-info', + warn: 'notif-warn', + warning: 'notif-warn', + error: 'notif-error', + fail: 'notif-error', + }[type] || 'notif-info' + + const icon = { + ok: '✓', success: '✓', install: '✓', update: '→', + info: 'â„č', warn: '⚠', warning: '⚠', error: '✗', fail: '✗', + }[type] || '‱' + + return ( +
+ {time} + {icon} + {text} +
+ ) +} + +function QuickActionButton({ icon, label, onClick, loading, disabled }) { + return ( + + ) +} + +export default function Dashboard({ api }) { + const { t } = useI18n() + const [activeTab, setActiveTab] = useState('tools') + const [tools, setTools] = useState([]) + const [updates, setUpdates] = useState([]) + const [systemInfo, setSystemInfo] = useState(null) + const [notifications, setNotifications] = useState([]) + const [loading, setLoading] = useState(false) + const [installing, setInstalling] = useState(false) + const [scanLoading, setScanLoading] = useState(false) + const [mcpLoading, setMcpLoading] = useState(false) + const [dashboardStatus, setDashboardStatus] = useState(null) + + const loadData = useCallback(async () => { + try { + const [toolsData, updatesData, systemData] = await Promise.all([ + api.getTools().catch(() => ({ tools: [] })), + api.getUpdates().catch(() => ({ updates: [] })), + api.getSystem().catch(() => null), + ]) + setTools(toolsData.tools || toolsData || []) + setUpdates(updatesData.updates || updatesData || []) + setSystemInfo(systemData) + api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {}) + } catch (err) { + console.error('Failed to load dashboard data:', err) + } + }, [api]) + + useEffect(() => { + loadData() + }, [loadData]) + + const addNotification = (message, type = 'info') => { + const entry = { id: Date.now(), time: new Date().toISOString(), message, type } + setNotifications(prev => [entry, ...prev].slice(0, 100)) + } + + const handleRescan = async () => { + setScanLoading(true) + addNotification(t('dashboard.rescanning'), 'info') + try { + await api.runScan() + await loadData() + addNotification(t('dashboard.scanComplete'), 'ok') + } catch (err) { + addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error') + } finally { + setScanLoading(false) + } + } + + const handleInstallMissing = async () => { + const missing = tools.filter(t => !t.installed && t.status !== 'installed') + if (missing.length === 0) return + setInstalling(true) + addNotification(t('dashboard.installing', { count: missing.length }), 'info') + try { + await api.installTools(missing.map(t => t.name)) + addNotification(t('dashboard.installStarted'), 'ok') + setTimeout(() => handleRescan(), 2000) + } catch (err) { + addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') + } finally { + setInstalling(false) + } + } + + const handleCheckUpdates = async () => { + setLoading(true) + addNotification(t('config.checking'), 'info') + try { + const data = await api.getUpdates() + setUpdates(data.updates || data || []) + const count = (data.updates || data || []).length + if (count > 0) { + addNotification(t('dashboard.updatesCount', { count }), 'warn') + } else { + addNotification(t('dashboard.allUpToDate'), 'ok') + } + } catch (err) { + addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error') + } finally { + setLoading(false) + } + } + + const handleConfigureMCP = async () => { + setMcpLoading(true) + addNotification(t('dashboard.configuringMCP'), 'info') + try { + await api.configureMCP() + addNotification(t('dashboard.mcpConfigured'), 'ok') + } catch (err) { + addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error') + } finally { + setMcpLoading(false) + } + } + + const handleInstallTool = async (name) => { + setInstalling(true) + addNotification(`${t('dashboard.installing')} ${name}...`, 'info') + try { + await api.installTools([name]) + addNotification(`${name} ${t('dashboard.installed')}`, 'ok') + setTimeout(() => loadData(), 2000) + } catch (err) { + addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') + } finally { + setInstalling(false) + } + } + + const installedCount = tools.filter(t => t.installed || t.status === 'installed').length + const missingCount = tools.length - installedCount + + return ( +
+
+ + + + +
+ +
+ {activeTab === 'tools' && ( +
+
+
{t('dashboard.systemOverview')}
+
+ {installedCount} {t('dashboard.installed')} + {missingCount > 0 && {missingCount} {t('dashboard.missing')}} +
+
+ {systemInfo && ( +
+ {systemInfo.os || systemInfo.platform || 'Unknown'} + · + {systemInfo.arch || 'Unknown'} + {systemInfo.shell && <>·{systemInfo.shell}} +
+ )} +
+ {tools.length === 0 && ( +
{t('dashboard.noTools')}
+ )} + {tools.map((tool, i) => ( + + ))} +
+
+ )} + + {activeTab === 'activity' && ( +
+
+
{t('dashboard.activityLog')}
+ +
+ {notifications.length === 0 ? ( +
{t('dashboard.noActivity')}
+ ) : ( +
+ {notifications.map(entry => ( + + ))} +
+ )} +
+ )} + + {activeTab === 'actions' && ( +
+
+
{t('dashboard.quickActions')}
+
+
+ + + + +
+ + {updates.length > 0 && ( +
+
+
{t('dashboard.updates')}
+ {updates.length} +
+
+ {updates.map((update, i) => ( +
+
+ {update.name || 'Unknown'} + + {update.current || update.version || '?'} → {update.latest || update.target || '?'} + +
+ +
+ ))} +
+
+ )} +
+ )} + + {activeTab === 'status' && ( +
+ {dashboardStatus ? ( + <> +
+
MCP Servers
+ {dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy +
+
+ {(dashboardStatus.mcp?.servers || []).map((s, i) => ( +
+
+
{s.name}
+
+ {s.healthy ? healthy : + s.installed ? installed : + not found} +
+
+
+ ))} +
+ +
+
LSP Servers
+ {dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed +
+
+ {(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => ( +
+
+
{s.name}
+
+ {s.language} +
+
+
+ ))} +
+ +
+
Skills
+ {dashboardStatus.skills?.total || 0} deployed + {(dashboardStatus.skills?.issues || []).length > 0 && ( + {(dashboardStatus.skills.issues || []).length} issues + )} +
+ {(dashboardStatus.skills?.issues || []).length > 0 && ( +
+ {(dashboardStatus.skills.issues || []).map((issue, i) => ( +
{issue}
+ ))} +
+ )} + + ) : ( +
Loading status...
+ )} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 2d0dbbb..df1beef 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -378,61 +378,49 @@ export default function Shell({ api }) { setAiMessages(prev => [...prev, { role: 'user', content: text }]) setAiInput('') setAiLoading(true) + + const currentTab = tabs.find(t => t.id === activeTab) + const context = { + cwd: currentTab?.cwd || '', + platform: navigator.platform || '', + } + try { - const res = await api.runCommand(`echo "AI: ${text}"`, '') - const output = res.output || t('shell.noResponse') - parseAndAddAiMessages(output) - } catch (err) { - setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) - } - setAiLoading(false) - } - - const parseAndAddAiMessages = (text) => { - const lines = text.split('\n') - let buffer = '' - let inBlock = false - - const flushBuffer = () => { - if (buffer.trim()) { - setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }]) - } - buffer = '' - } - - for (const line of lines) { - const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/) - if (toolMatch) { - flushBuffer() - try { - const toolData = JSON.parse(toolMatch[0].slice(10, -1)) + let accumulated = '' + await api.sendShellChat(text, context, true, (partial, event) => { + if (event && event.tool_call) { setAiMessages(prev => [...prev, { role: 'tool', - content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`, - args: toolData.task || toolData.args || '', + content: `${t('shell.toolLaunched')}: ${event.tool_call.name || 'tool'}`, + args: event.tool_call.args ? JSON.stringify(event.tool_call.args).slice(0, 100) : '', }]) - } catch { - setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }]) + return } - } else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) { - if (buffer.trim() && !inBlock) { - flushBuffer() + if (event && event.tool_result) { + const resultText = event.tool_result.result?.content || event.tool_result.error || 'completed' + setAiMessages(prev => [...prev, { + role: 'tool_result', + content: resultText, + isError: event.tool_result.result?.is_error, + }]) + return } - inBlock = true - const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '') - if (buffer) buffer += ' ' - buffer += cleaned - } else { - if (inBlock && buffer.trim()) { - setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }]) - buffer = '' - } - inBlock = false - if (buffer) buffer += '\n' - buffer += line + if (event && event.done) return + accumulated = partial + setAiMessages(prev => { + const filtered = prev.filter(m => !m._streaming) + return [...filtered, { role: 'ai', content: partial, _streaming: true }] + }) + }) + + setAiMessages(prev => prev.filter(m => !m._streaming)) + if (accumulated) { + setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: accumulated }]) } + } catch (err) { + setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) } - flushBuffer() + setAiLoading(false) } return ( diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 978873a..ba56892 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -22,7 +22,9 @@ const en = { dashboard: { systemOverview: 'System Overview', - tools: 'tools', + tools: 'Tools', + activity: 'Activity', + toolsCount: '{count} tools installed', installed: 'Installed', missing: 'Missing', quickActions: 'Quick Actions', @@ -39,9 +41,20 @@ const en = { installStarted: 'Install started. Rescanning...', done: 'Done.', scanComplete: 'Scan complete.', + scanFailed: 'Scan failed', updatesCount: '{count} updates available.', allUpToDate: 'All tools up to date.', mcpConfigured: 'MCP configured.', + mcpConfigFailed: 'MCP configuration failed', + status: 'Status', + clearLog: 'Clear', + noActivity: 'No recent activity.', + rescanning: 'Scanning...', + install: 'Install', + installFailed: 'Install failed', + checkUpdatesFailed: 'Check failed', + configuringMCP: 'Configuring MCP...', + mcpConfigFailed: 'MCP configuration failed', }, studio: { @@ -111,6 +124,7 @@ const en = { aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!', askAi: 'Ask AI assistant...', toolLaunched: 'Tool launched', + toolResult: 'Result', }, config: { diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index df89ecf..408c5a5 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -22,7 +22,9 @@ const fr = { dashboard: { systemOverview: 'Vue d\u2019ensemble du syst\u00e8me', - tools: 'outils', + tools: 'Outils', + activity: 'Activit\u00e9', + toolsCount: '{count} outils install\u00e9s', installed: 'Install\u00e9', missing: 'Manquant', quickActions: 'Actions rapides', @@ -39,9 +41,20 @@ const fr = { installStarted: 'Installation lanc\u00e9e. Rescan en cours...', done: 'Termin\u00e9.', scanComplete: 'Scan termin\u00e9.', + scanFailed: '\u00c9chec du scan', updatesCount: '{count} mises \u00e0 jour disponibles.', allUpToDate: 'Tous les outils sont \u00e0 jour.', mcpConfigured: 'MCP configur\u00e9.', + status: 'Statut', + noTools: 'Aucun outil d\u00e9tect\u00e9. Ex\u00e9cutez un scan.', + clearLog: 'Effacer', + noActivity: 'Aucune activit\u00e9 r\u00e9cente.', + rescanning: 'Scan en cours...', + install: 'Installer', + installFailed: '\u00c9chec de l\u2019installation', + checkUpdatesFailed: '\u00c9chec de la v\u00e9rification', + configuringMCP: 'Configuration MCP en cours...', + mcpConfigFailed: '\u00c9chec de la configuration MCP', }, studio: { @@ -111,6 +124,7 @@ const fr = { aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !', askAi: 'Interroger l\'assistant IA...', toolLaunched: 'Outil lanc\u00e9', + toolResult: 'R\u00e9sultat', }, config: { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 9834e41..7e855ac 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -565,6 +565,81 @@ input::placeholder { color: var(--text-disabled); } letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); } +/* ── Dashboard Tabs ── */ +.dashboard-tabs { + display: flex; gap: 4px; padding: 12px 20px 0; + border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0; +} +.dashboard-tab { + padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0; + border: 1px solid transparent; border-bottom: none; background: transparent; + color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer; + display: flex; align-items: center; gap: 6px; transition: all 0.15s; +} +.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); } +.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); } +.dashboard-tab .tab-icon { font-size: 14px; } +.dashboard-tab .tab-count { + background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono); +} +.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); } + +.dashboard-tools-panel { padding: 20px 24px; } +.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; } +.stat-ok { color: var(--success); font-family: var(--font-mono); } +.stat-missing { color: var(--error); font-family: var(--font-mono); } + +.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); } +.sys-info-item { font-family: var(--font-mono); } +.sys-info-sep { color: var(--text-disabled); } + +.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; } +.tool-card { + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s; +} +.tool-card:hover { border-color: var(--accent-dim); } +.tool-card.installed { border-left: 3px solid var(--success); } +.tool-card.missing { border-left: 3px solid var(--error); } +.tool-card-icon { font-size: 20px; flex-shrink: 0; } +.tool-card-info { flex: 1; min-width: 0; } +.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; } +.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; } +.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); } +.status-ok { color: var(--success); } +.status-missing { color: var(--error); } +.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; } +.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; } +.tool-update-badge:hover { background: var(--accent-dim); } + +.dashboard-activity-panel { padding: 20px 24px; } +.activity-log { display: flex; flex-direction: column; gap: 2px; } +.notif-icon { font-size: 12px; width: 16px; text-align: center; } + +.dashboard-actions-panel { padding: 20px 24px; } +.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; } +.quick-action-btn { + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer; + transition: all 0.2s; font-size: 13px; color: var(--text-secondary); +} +.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); } +.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.quick-action-icon { font-size: 18px; } +.quick-action-label { font-weight: 600; } + +.dashboard-updates-section { margin-top: 16px; } +.updates-list { display: flex; flex-direction: column; gap: 6px; } +.update-row { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card); + border: 1px solid var(--border); +} +.update-row:hover { border-color: var(--accent-dim); } +.update-info { display: flex; align-items: center; gap: 16px; } +.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; } +.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); } + .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg-surface); From 65804aae4ef763b48726ffbb8fd4969e7353e92f Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 22:37:45 +0200 Subject: [PATCH 09/15] fix(studio): improve chat context, thinking tags, streaming, and tool results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix cleanThinkingTags to use proper regex instead of naive ReplaceAll - Send conversation history (last 20 messages + summary) to AI instead of single message - Store tool results alongside tool calls so history shows complete execution info - Stream words instead of characters for smoother SSE rendering - Add stop button to cancel in-progress AI requests (AbortController) - Fix markdown rendering: add h2 support, use div for bullets - Add i18n keys for cancel/stop (EN + FR) 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- internal/api/handlers_chat.go | 89 +++++++++++++++++++++++++---- internal/api/handlers_shell_chat.go | 9 ++- web/src/api/client.js | 3 +- web/src/components/Studio.jsx | 64 ++++++++++++++++----- web/src/i18n/en.js | 2 + web/src/i18n/fr.js | 2 + web/src/styles/global.css | 6 ++ 7 files changed, 149 insertions(+), 26 deletions(-) diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index ddbba05..6378c6a 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "net/http" + "regexp" "strings" "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/orchestrator" ) +var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) + const maxToolIterations = 15 func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { @@ -68,12 +71,11 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche } ctx := context.Background() - messages := []orchestrator.Message{ - {Role: "user", Content: userMessage}, - } + messages := s.buildContextMessages(userMessage) var finalContent string var allToolCalls []map[string]interface{} + var allToolResults []map[string]interface{} for i := 0; i < maxToolIterations; i++ { resp, err := orb.SendWithTools(messages) @@ -86,8 +88,13 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche content := cleanThinkingTags(choice.Message.Content) if content != "" { - for _, ch := range strings.Split(content, "") { - writeSSE(map[string]interface{}{"content": ch}) + words := strings.Fields(content) + for i, w := range words { + chunk := w + if i < len(words)-1 { + chunk += " " + } + writeSSE(map[string]interface{}{"content": chunk}) } finalContent = content } @@ -133,6 +140,14 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche } writeSSE(map[string]interface{}{"tool_result": resultData}) + allToolResults = append(allToolResults, map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + "result": result.Content, + "is_error": result.IsError, + }) + messages = append(messages, orchestrator.Message{ Role: "tool", Content: result.Content, @@ -146,7 +161,11 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche storeContent := finalContent if len(allToolCalls) > 0 { - storeObj := map[string]interface{}{"content": storeContent, "tool_calls": allToolCalls} + storeObj := map[string]interface{}{ + "content": storeContent, + "tool_calls": allToolCalls, + "tool_results": allToolResults, + } storeJSON, _ := json.Marshal(storeObj) storeContent = string(storeJSON) } @@ -157,9 +176,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) { ctx := context.Background() - messages := []orchestrator.Message{ - {Role: "user", Content: userMessage}, - } + messages := s.buildContextMessages(userMessage) var finalContent string @@ -223,7 +240,59 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or } func cleanThinkingTags(content string) string { - return strings.ReplaceAll(content, " contextWindowMessages { + start = len(history) - contextWindowMessages + } + + messages := make([]orchestrator.Message, 0, len(history[start:])+1) + + summary := s.convStore.GetSummary() + if summary != "" { + messages = append(messages, orchestrator.Message{ + Role: "system", + Content: "RĂ©sumĂ© de la conversation prĂ©cĂ©dente:\n" + summary, + }) + } + + for _, m := range history[start:] { + content := m.Content + if m.Role == "assistant" { + var parsed struct { + Content string `json:"content"` + ToolCalls []struct { + ToolCallID string `json:"tool_call_id"` + Name string `json:"name"` + Args string `json:"args"` + } `json:"tool_calls"` + } + if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" { + content = parsed.Content + } + } + role := m.Role + if role == "system" { + continue + } + messages = append(messages, orchestrator.Message{ + Role: role, + Content: content, + }) + } + + messages = append(messages, orchestrator.Message{ + Role: "user", + Content: userMessage, + }) + + return messages } func (s *Server) autoSummarize() { diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index c33d829..60bb0d3 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -136,8 +136,13 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator. content := cleanThinkingTags(choice.Message.Content) if content != "" { - for _, ch := range strings.Split(content, "") { - writeSSE(map[string]interface{}{"content": ch}) + words := strings.Fields(content) + for i, w := range words { + chunk := w + if i < len(words)-1 { + chunk += " " + } + writeSSE(map[string]interface{}{"content": chunk}) } finalContent = content } diff --git a/web/src/api/client.js b/web/src/api/client.js index 91a8cc4..1aa75c5 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -52,7 +52,7 @@ const api = { saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }), getChatHistory: () => request('/chat/history'), clearChat: () => request('/chat/clear', { method: 'POST' }), - sendChat: (message, stream = true, onChunk) => { + sendChat: (message, stream = true, onChunk, signal) => { if (!stream) { return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) } @@ -61,6 +61,7 @@ const api = { 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 })) diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 8a124a1..21af5e3 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -59,8 +59,9 @@ function formatText(text) { .replace(/`([^`]+)`/g, '$1') .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') - .replace(/^\s*[-*] (.+)$/gm, '$1') - .replace(/^\s*(\d+)[.)] (.+)$/gm, '$1$2') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/^\s*[-*] (.+)$/gm, '
‱ $1
') + .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') } function ThinkingBlock({ content, done }) { @@ -151,11 +152,13 @@ function FeedItem({ msg }) { const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' let parsedToolCalls = null + let parsedToolResults = null let displayContent = msg.content try { const parsed = JSON.parse(msg.content) if (parsed && Array.isArray(parsed.tool_calls)) { parsedToolCalls = parsed.tool_calls + parsedToolResults = parsed.tool_results || null displayContent = parsed.content || '' } } catch {} @@ -186,9 +189,15 @@ function FeedItem({ msg }) { {timeStr && {timeStr}}
{msg.thinking && } - {parsedToolCalls && parsedToolCalls.map((tc, i) => ( - - ))} + {parsedToolCalls && parsedToolCalls.map((tc, i) => { + const resultData = parsedToolResults + ? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id) + : null + const result = resultData + ? { content: resultData.result, is_error: resultData.is_error } + : null + return + })} {cleanContent && (
{renderContent(cleanContent).map((part, i) => @@ -265,6 +274,7 @@ export default function Studio({ api }) { const [loaded, setLoaded] = useState(false) const messagesEnd = useRef(null) const textareaRef = useRef(null) + const abortRef = useRef(null) useEffect(() => { api.getChatHistory().then(data => { @@ -321,6 +331,9 @@ export default function Studio({ api }) { setStreamThinking('') setStreamToolCalls([]) + const controller = new AbortController() + abortRef.current = controller + try { let accumulated = '' let thinking = '' @@ -349,7 +362,7 @@ export default function Studio({ api }) { } accumulated = partial setStreaming(partial) - }) + }, controller.signal) const finalContent = accumulated || t('studio.noResponse') const aiMsg = { @@ -367,19 +380,37 @@ export default function Studio({ api }) { } setMessages(prev => [...prev, aiMsg]) } catch (err) { - setMessages(prev => [...prev, { - id: (Date.now() + 1).toString(), - role: 'system', - content: `${t('studio.error')}: ${err.message}`, - time: new Date().toISOString(), - }]) + if (err.name === 'AbortError') { + if (streaming) { + setMessages(prev => [...prev, { + id: (Date.now() + 1).toString(), + role: 'system', + content: t('studio.cancelled'), + time: new Date().toISOString(), + }]) + } + } else { + setMessages(prev => [...prev, { + id: (Date.now() + 1).toString(), + role: 'system', + content: `${t('studio.error')}: ${err.message}`, + time: new Date().toISOString(), + }]) + } } finally { setLoading(false) setStreaming('') setStreamThinking('') setStreamToolCalls([]) + abortRef.current = null } - }, [input, loading, api, t, handleClear]) + }, [input, loading, api, t, handleClear, streaming]) + + const handleStop = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort() + } + }, []) const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -432,6 +463,13 @@ export default function Studio({ api }) { + {loading && ( + + )}
{t('studio.inputHint')} · /clear diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index ba56892..0b8b066 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -90,6 +90,8 @@ const en = { you: 'You', mentioned: 'mentioned', cleared: 'Conversation cleared.', + cancelled: 'Request cancelled.', + stop: 'Stop', }, shell: { diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index 408c5a5..1e8f569 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -90,6 +90,8 @@ const fr = { you: 'Vous', mentioned: 'mentionn\u00e9', cleared: 'Conversation effac\u00e9e.', + cancelled: 'Requ\u00eate annul\u00e9e.', + stop: 'Stop', }, shell: { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 7e855ac..31dfa28 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -752,6 +752,12 @@ input::placeholder { color: var(--text-disabled); } } .studio-send-btn:hover:not(:disabled) { 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; } /* ── Studio Tool Blocks ── */ From 3948a4c656011113914f0a711a9d3bff08657a3f Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 22:58:05 +0200 Subject: [PATCH 10/15] refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat) - Add SendWithToolsStream for real-time streaming responses - Add /help, /plan, /export, /model commands in Studio - Fix XSS: sanitize HTML after markdown rendering - Add ConversationStoreMulti for multi-conversation support - Add Anthropic headers (x-api-key, anthropic-version) - Add fallback logging when provider switch occurs - Add API handler tests (handlers_test.go) - Polish Studio: max-height 200px, word-break on tool args - Update CLI version to show full info (version, go, platform) đŸ€– Generated with Crush Assisted-by: MiniMax-M2.5 via Crush --- cmd/muyue/commands/version.go | 4 +- go.mod | 1 + go.sum | 2 + internal/api/chat_engine.go | 249 +++++++++++++++++ internal/api/conversation_multi.go | 370 ++++++++++++++++++++++++++ internal/api/handlers_chat.go | 171 ++---------- internal/api/handlers_shell_chat.go | 217 +++++---------- internal/api/handlers_test.go | 66 +++++ internal/orchestrator/orchestrator.go | 154 ++++++++++- internal/version/version.go | 22 ++ web/src/components/Studio.jsx | 75 +++++- web/src/styles/global.css | 5 +- 12 files changed, 1024 insertions(+), 312 deletions(-) create mode 100644 internal/api/chat_engine.go create mode 100644 internal/api/conversation_multi.go create mode 100644 internal/api/handlers_test.go diff --git a/cmd/muyue/commands/version.go b/cmd/muyue/commands/version.go index 696b612..4d3baf3 100644 --- a/cmd/muyue/commands/version.go +++ b/cmd/muyue/commands/version.go @@ -9,7 +9,7 @@ import ( var versionCmd = &cobra.Command{ Use: "version", - Short: "Print version", + Short: "Print version info", RunE: runVersion, } @@ -18,6 +18,6 @@ func init() { } func runVersion(cmd *cobra.Command, args []string) error { - fmt.Printf("Muyue version %s\n", version.Version) + fmt.Print(version.FullInfo()) return nil } \ No newline at end of file diff --git a/go.mod b/go.mod index 7dc8717..b1ad938 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.3 require ( github.com/charmbracelet/huh v1.0.0 github.com/creack/pty/v2 v2.0.1 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 3ab332e..19f94e1 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/internal/api/chat_engine.go b/internal/api/chat_engine.go new file mode 100644 index 0000000..28feab8 --- /dev/null +++ b/internal/api/chat_engine.go @@ -0,0 +1,249 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/muyue/muyue/internal/agent" + "github.com/muyue/muyue/internal/orchestrator" +) + +const ( + MaxToolIterations = 15 +) + +// ChatEngine handles chat interactions with tool execution. +// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go. +type ChatEngine struct { + orchestrator *orchestrator.Orchestrator + registry *agent.Registry + tools json.RawMessage + onChunk func(map[string]interface{}) + stream bool +} + +// NewChatEngine creates a new ChatEngine instance. +func NewChatEngine(orb *orchestrator.Orchestrator, registry *agent.Registry, tools json.RawMessage) *ChatEngine { + return &ChatEngine{ + orchestrator: orb, + registry: registry, + tools: tools, + stream: false, + } +} + +// SetStream enables streaming mode for the chat engine. +func (ce *ChatEngine) SetStream(enabled bool) { + ce.stream = enabled +} + +// OnChunk sets the callback for SSE chunk writing. +func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) { + ce.onChunk = fn +} + +// RunWithTools executes the chat loop with tool calls. +// Returns final content, tool calls, tool results, and error. +func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) { + var finalContent string + var allToolCalls []map[string]interface{} + var allToolResults []map[string]interface{} + + for i := 0; i < MaxToolIterations; i++ { + var resp *orchestrator.ChatResponse + var err error + + if ce.stream { + // Use streaming version + resp, err = ce.orchestrator.SendWithToolsStream(messages, func(content string, toolCalls []orchestrator.ToolCallMsg) { + if ce.onChunk != nil && content != "" { + ce.onChunk(map[string]interface{}{"content": content}) + } + }) + } else { + resp, err = ce.orchestrator.SendWithTools(messages) + } + if err != nil { + if ce.onChunk != nil { + ce.onChunk(map[string]interface{}{"error": err.Error()}) + } + return finalContent, allToolCalls, allToolResults, err + } + + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + words := strings.Fields(content) + for _, w := range words { + chunk := w + if ce.onChunk != nil { + ce.onChunk(map[string]interface{}{"content": chunk}) + } + } + finalContent = content + } + + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + toolCallData := map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + } + allToolCalls = append(allToolCalls, toolCallData) + + if ce.onChunk != nil { + ce.onChunk(map[string]interface{}{"tool_call": toolCallData}) + } + + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := ce.registry.Execute(ctx, call) + if execErr != nil { + result = agent.ToolResponse{ + Content: execErr.Error(), + IsError: true, + } + } + + resultData := map[string]interface{}{ + "tool_call_id": tc.ID, + "content": result.Content, + "is_error": result.IsError, + } + allToolResults = append(allToolResults, map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + "result": result.Content, + "is_error": result.IsError, + }) + + if ce.onChunk != nil { + ce.onChunk(map[string]interface{}{"tool_result": resultData}) + } + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" + } + + return finalContent, allToolCalls, allToolResults, nil +} + +// RunNonStream executes chat without streaming content to client. +func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) { + var finalContent string + + for i := 0; i < MaxToolIterations; i++ { + resp, err := ce.orchestrator.SendWithTools(messages) + if err != nil { + return finalContent, err + } + + choice := resp.Choices[0] + content := cleanThinkingTags(choice.Message.Content) + + if content != "" { + finalContent = content + } + + if len(choice.Message.ToolCalls) == 0 { + break + } + + assistantMsg := orchestrator.Message{ + Role: "assistant", + Content: content, + ToolCalls: choice.Message.ToolCalls, + } + messages = append(messages, assistantMsg) + + for _, tc := range choice.Message.ToolCalls { + call := agent.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + } + + result, execErr := ce.registry.Execute(ctx, call) + if execErr != nil { + result = agent.ToolResponse{ + Content: execErr.Error(), + IsError: true, + } + } + + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + + finalContent = "" + } + + if finalContent == "" { + finalContent = "(tool calls completed, no text response)" + } + + return finalContent, nil +} + +// SSEWriter handles Server-Sent Events writing to HTTP response. +type SSEWriter struct { + w http.ResponseWriter + flusher http.Flusher +} + +// NewSSEWriter creates a new SSEWriter. +func NewSSEWriter(w http.ResponseWriter) *SSEWriter { + sse := &SSEWriter{w: w} + if f, ok := w.(http.Flusher); ok { + sse.flusher = f + } + return sse +} + +// Write sends an SSE message. +func (s *SSEWriter) Write(data map[string]interface{}) { + b, _ := json.Marshal(data) + s.w.Write([]byte("data: " + string(b) + "\n\n")) + if s.flusher != nil { + s.flusher.Flush() + } +} + +// SetupSSEHeaders sets up SSE response headers. +func SetupSSEHeaders(w http.ResponseWriter) { + 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", "*") + w.WriteHeader(http.StatusOK) +} \ No newline at end of file diff --git a/internal/api/conversation_multi.go b/internal/api/conversation_multi.go new file mode 100644 index 0000000..bb488e6 --- /dev/null +++ b/internal/api/conversation_multi.go @@ -0,0 +1,370 @@ +package api + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + "github.com/muyue/muyue/internal/config" +) + +// ConversationMeta represents metadata for a conversation (used for listing). +type ConversationMeta struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + MessageCount int `json:"message_count"` +} + +// ConversationStoreMulti manages multiple conversations. +type ConversationStoreMulti struct { + mu sync.RWMutex + dir string + currentID string + conversations map[string]*Conversation +} + +func NewConversationStoreMulti() *ConversationStoreMulti { + dir, err := config.ConfigDir() + if err != nil { + dir = "/tmp/muyue" + } + dir = filepath.Join(dir, "conversations") + + cs := &ConversationStoreMulti{ + dir: dir, + conversations: make(map[string]*Conversation), + } + cs.loadIndex() + return cs +} + +func (cs *ConversationStoreMulti) loadIndex() { + os.MkdirAll(cs.dir, 0755) + + // Load index file if exists + indexPath := filepath.Join(cs.dir, "index.json") + data, err := os.ReadFile(indexPath) + if err != nil { + // Create default conversation + cs.createDefault() + return + } + + var index struct { + CurrentID string `json:"current_id"` + Conversations []ConversationMeta `json:"conversations"` + } + if err := json.Unmarshal(data, &index); err != nil { + cs.createDefault() + return + } + + cs.currentID = index.CurrentID + if cs.currentID == "" { + cs.createDefault() + return + } + + // Load all conversations + for _, meta := range index.Conversations { + convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", meta.ID)) + data, err := os.ReadFile(convPath) + if err != nil { + continue + } + var conv Conversation + if err := json.Unmarshal(data, &conv); err != nil { + continue + } + cs.conversations[meta.ID] = &conv + } + + // Ensure current conversation exists + if _, ok := cs.conversations[cs.currentID]; !ok { + cs.createDefault() + } +} + +func (cs *ConversationStoreMulti) createDefault() { + cs.currentID = uuid.New().String() + cs.conversations[cs.currentID] = &Conversation{ + Messages: []FeedMessage{}, + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + } + cs.saveIndex() +} + +func (cs *ConversationStoreMulti) saveIndex() error { + var metas []ConversationMeta + for id, conv := range cs.conversations { + title := "Nouvelle conversation" + if len(conv.Messages) > 0 { + // Use first user message as title + for _, m := range conv.Messages { + if m.Role == "user" { + if len(m.Content) > 50 { + title = m.Content[:50] + "..." + } else { + title = m.Content + } + break + } + } + } + metas = append(metas, ConversationMeta{ + ID: id, + Title: title, + CreatedAt: conv.CreatedAt, + UpdatedAt: conv.UpdatedAt, + MessageCount: len(conv.Messages), + }) + } + + index := struct { + CurrentID string `json:"current_id"` + Conversations []ConversationMeta `json:"conversations"` + }{ + CurrentID: cs.currentID, + Conversations: metas, + } + + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(cs.dir, "index.json"), data, 0600) +} + +func (cs *ConversationStoreMulti) saveCurrent() error { + conv, ok := cs.conversations[cs.currentID] + if !ok { + return fmt.Errorf("no current conversation") + } + + conv.UpdatedAt = time.Now().Format(time.RFC3339) + data, err := json.MarshalIndent(conv, "", " ") + if err != nil { + return err + } + + convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", cs.currentID)) + if err := os.WriteFile(convPath, data, 0600); err != nil { + return err + } + + return cs.saveIndex() +} + +// Current returns the current conversation store. +func (cs *ConversationStoreMulti) Current() *ConversationStore { + cs.mu.RLock() + defer cs.mu.RUnlock() + + conv, ok := cs.conversations[cs.currentID] + if !ok { + return &ConversationStore{ + conv: &Conversation{ + Messages: []FeedMessage{}, + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + }, + } + } + + return &ConversationStore{ + conv: conv, + } +} + +// Get returns the current conversation messages. +func (cs *ConversationStoreMulti) Get() []FeedMessage { + cs.mu.RLock() + defer cs.mu.RUnlock() + + conv, ok := cs.conversations[cs.currentID] + if !ok { + return []FeedMessage{} + } + + out := make([]FeedMessage, len(conv.Messages)) + copy(out, conv.Messages) + return out +} + +// Add adds a message to the current conversation. +func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage { + cs.mu.Lock() + defer cs.mu.Unlock() + + conv, ok := cs.conversations[cs.currentID] + if !ok { + cs.currentID = uuid.New().String() + conv = &Conversation{ + Messages: []FeedMessage{}, + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + } + cs.conversations[cs.currentID] = conv + } + + msg := FeedMessage{ + ID: generateMsgID(), + Role: role, + Content: content, + Time: time.Now().Format(time.RFC3339), + } + conv.Messages = append(conv.Messages, msg) + + go cs.saveCurrent() // Fire and forget + + return msg +} + +// Clear clears the current conversation. +func (cs *ConversationStoreMulti) Clear() { + cs.mu.Lock() + defer cs.mu.Unlock() + + conv, ok := cs.conversations[cs.currentID] + if !ok { + return + } + + conv.Messages = []FeedMessage{} + conv.Summary = "" + conv.CreatedAt = time.Now().Format(time.RFC3339) + conv.UpdatedAt = time.Now().Format(time.RFC3339) + + cs.saveCurrent() +} + +// List returns all conversations. +func (cs *ConversationStoreMulti) List() []ConversationMeta { + cs.mu.RLock() + defer cs.mu.RUnlock() + + var metas []ConversationMeta + for id, conv := range cs.conversations { + title := "Nouvelle conversation" + if len(conv.Messages) > 0 { + for _, m := range conv.Messages { + if m.Role == "user" { + if len(m.Content) > 50 { + title = m.Content[:50] + "..." + } else { + title = m.Content + } + break + } + } + } + metas = append(metas, ConversationMeta{ + ID: id, + Title: title, + CreatedAt: conv.CreatedAt, + UpdatedAt: conv.UpdatedAt, + MessageCount: len(conv.Messages), + }) + } + + return metas +} + +// Create creates a new conversation and switches to it. +func (cs *ConversationStoreMulti) Create() string { + cs.mu.Lock() + defer cs.mu.Unlock() + + id := uuid.New().String() + cs.conversations[id] = &Conversation{ + Messages: []FeedMessage{}, + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + } + cs.currentID = id + cs.saveIndex() + + return id +} + +// Switch switches to a different conversation. +func (cs *ConversationStoreMulti) Switch(id string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + if _, ok := cs.conversations[id]; !ok { + return fmt.Errorf("conversation not found: %s", id) + } + + cs.currentID = id + cs.saveIndex() + + return nil +} + +// GetByID returns a conversation by ID. +func (cs *ConversationStoreMulti) GetByID(id string) (*Conversation, error) { + cs.mu.RLock() + defer cs.mu.RUnlock() + + conv, ok := cs.conversations[id] + if !ok { + return nil, fmt.Errorf("conversation not found: %s", id) + } + + return conv, nil +} + +// Delete deletes a conversation. +func (cs *ConversationStoreMulti) Delete(id string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + if _, ok := cs.conversations[id]; !ok { + return fmt.Errorf("conversation not found: %s", id) + } + + delete(cs.conversations, id) + + // Delete file + convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", id)) + os.Remove(convPath) + + // If deleted current, switch to another + if cs.currentID == id { + if len(cs.conversations) > 0 { + for newID := range cs.conversations { + cs.currentID = newID + break + } + } else { + // Create new default + cs.currentID = uuid.New().String() + cs.conversations[cs.currentID] = &Conversation{ + Messages: []FeedMessage{}, + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + } + } + } + + cs.saveIndex() + + return nil +} + +// CurrentID returns the current conversation ID. +func (cs *ConversationStoreMulti) CurrentID() string { + cs.mu.RLock() + defer cs.mu.RUnlock() + + return cs.currentID +} \ No newline at end of file diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index 6378c6a..aef47ba 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -13,8 +13,6 @@ import ( var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) -const maxToolIterations = 15 - func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeError(w, "POST only", http.StatusMethodNotAllowed) @@ -55,108 +53,31 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) { - 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", "*") - w.WriteHeader(http.StatusOK) + SetupSSEHeaders(w) flusher, canFlush := w.(http.Flusher) - writeSSE := func(data map[string]interface{}) { - b, _ := json.Marshal(data) - w.Write([]byte("data: " + string(b) + "\n\n")) - if canFlush { - flusher.Flush() - } - } + + sseWriter := NewSSEWriter(w) + ctx := context.Background() messages := s.buildContextMessages(userMessage) - var finalContent string - var allToolCalls []map[string]interface{} - var allToolResults []map[string]interface{} - - for i := 0; i < maxToolIterations; i++ { - resp, err := orb.SendWithTools(messages) - if err != nil { - writeSSE(map[string]interface{}{"error": err.Error()}) + engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) + engine.OnChunk(func(data map[string]interface{}) { + if data == nil { return } - - choice := resp.Choices[0] - content := cleanThinkingTags(choice.Message.Content) - - if content != "" { - words := strings.Fields(content) - for i, w := range words { - chunk := w - if i < len(words)-1 { - chunk += " " - } - writeSSE(map[string]interface{}{"content": chunk}) - } - finalContent = content + sseWriter.Write(data) + if canFlush { + flusher.Flush() } + }) - if len(choice.Message.ToolCalls) == 0 { - break - } - - assistantMsg := orchestrator.Message{ - Role: "assistant", - Content: content, - ToolCalls: choice.Message.ToolCalls, - } - messages = append(messages, assistantMsg) - - for _, tc := range choice.Message.ToolCalls { - toolCallData := map[string]interface{}{ - "tool_call_id": tc.ID, - "name": tc.Function.Name, - "args": tc.Function.Arguments, - } - allToolCalls = append(allToolCalls, toolCallData) - writeSSE(map[string]interface{}{"tool_call": toolCallData}) - - call := agent.ToolCall{ - ID: tc.ID, - Name: tc.Function.Name, - Arguments: json.RawMessage(tc.Function.Arguments), - } - - result, execErr := s.agentRegistry.Execute(ctx, call) - if execErr != nil { - result = agent.ToolResponse{ - Content: execErr.Error(), - IsError: true, - } - } - - resultData := map[string]interface{}{ - "tool_call_id": tc.ID, - "content": result.Content, - "is_error": result.IsError, - } - writeSSE(map[string]interface{}{"tool_result": resultData}) - - allToolResults = append(allToolResults, map[string]interface{}{ - "tool_call_id": tc.ID, - "name": tc.Function.Name, - "args": tc.Function.Arguments, - "result": result.Content, - "is_error": result.IsError, - }) - - messages = append(messages, orchestrator.Message{ - Role: "tool", - Content: result.Content, - ToolCallID: tc.ID, - Name: tc.Function.Name, - }) - } - - finalContent = "" + finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages) + if err != nil { + sseWriter.Write(map[string]interface{}{"error": err.Error()}) + return } storeContent := finalContent @@ -171,68 +92,18 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche } s.convStore.Add("assistant", storeContent) - writeSSE(map[string]interface{}{"done": "true"}) + sseWriter.Write(map[string]interface{}{"done": "true"}) } func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) { ctx := context.Background() messages := s.buildContextMessages(userMessage) - var finalContent string - - for i := 0; i < maxToolIterations; i++ { - resp, err := orb.SendWithTools(messages) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - choice := resp.Choices[0] - content := cleanThinkingTags(choice.Message.Content) - - if content != "" { - finalContent = content - } - - if len(choice.Message.ToolCalls) == 0 { - break - } - - assistantMsg := orchestrator.Message{ - Role: "assistant", - Content: content, - ToolCalls: choice.Message.ToolCalls, - } - messages = append(messages, assistantMsg) - - for _, tc := range choice.Message.ToolCalls { - call := agent.ToolCall{ - ID: tc.ID, - Name: tc.Function.Name, - Arguments: json.RawMessage(tc.Function.Arguments), - } - - result, execErr := s.agentRegistry.Execute(ctx, call) - if execErr != nil { - result = agent.ToolResponse{ - Content: execErr.Error(), - IsError: true, - } - } - - messages = append(messages, orchestrator.Message{ - Role: "tool", - Content: result.Content, - ToolCallID: tc.ID, - Name: tc.Function.Name, - }) - } - - finalContent = "" - } - - if finalContent == "" { - finalContent = "(tool calls completed, no text response)" + engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) + finalContent, err := engine.RunNonStream(ctx, messages) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return } s.convStore.Add("assistant", finalContent) diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index 60bb0d3..e3c633a 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -6,7 +6,6 @@ import ( "net/http" "strings" - "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/orchestrator" ) @@ -35,6 +34,22 @@ type ToolCallInfo struct { Error string `json:"error,omitempty"` } +func toString(v interface{}) string { + if v == nil { + return "" + } + s, _ := v.(string) + return s +} + +func toBool(v interface{}) bool { + if v == nil { + return false + } + b, _ := v.(bool) + return b +} + func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeError(w, "POST only", http.StatusMethodNotAllowed) @@ -102,109 +117,59 @@ Tu peux appeler des outils pour exĂ©cuter des commandes, lire des fichiers, etc. } func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { - 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", "*") - w.WriteHeader(http.StatusOK) + SetupSSEHeaders(w) flusher, canFlush := w.(http.Flusher) - - writeSSE := func(data map[string]interface{}) { - b, _ := json.Marshal(data) - w.Write([]byte("data: " + string(b) + "\n\n")) - if canFlush { - flusher.Flush() - } - } + sseWriter := NewSSEWriter(w) ctx := context.Background() messages := []orchestrator.Message{ {Role: "user", Content: req.Message}, } - var finalContent string - var toolCalls []ToolCallInfo + engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) - for i := 0; i < maxShellToolIterations; i++ { - resp, err := orb.SendWithTools(messages) - if err != nil { - writeSSE(map[string]interface{}{"error": err.Error()}) + var toolCalls []ToolCallInfo + engine.OnChunk(func(data map[string]interface{}) { + if data == nil { return } - - choice := resp.Choices[0] - content := cleanThinkingTags(choice.Message.Content) - - if content != "" { - words := strings.Fields(content) - for i, w := range words { - chunk := w - if i < len(words)-1 { - chunk += " " - } - writeSSE(map[string]interface{}{"content": chunk}) - } - finalContent = content + sseWriter.Write(data) + if canFlush { + flusher.Flush() } - - if len(choice.Message.ToolCalls) == 0 { - break - } - - assistantMsg := orchestrator.Message{ - Role: "assistant", - Content: content, - ToolCalls: choice.Message.ToolCalls, - } - messages = append(messages, assistantMsg) - - for _, tc := range choice.Message.ToolCalls { - toolCallData := map[string]interface{}{ - "tool_call_id": tc.ID, - "name": tc.Function.Name, - "args": tc.Function.Arguments, - } - writeSSE(map[string]interface{}{"tool_call": toolCallData}) - + if tc, ok := data["tool_call"].(map[string]interface{}); ok { argsMap := make(map[string]interface{}) - json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) - - tcInfo := ToolCallInfo{ - ID: tc.ID, - Name: tc.Function.Name, + if args, ok := tc["args"].(string); ok { + json.Unmarshal([]byte(args), &argsMap) + } + toolCalls = append(toolCalls, ToolCallInfo{ + ID: toString(tc["tool_call_id"]), + Name: toString(tc["name"]), Args: argsMap, - } - - call := agent.ToolCall{ - ID: tc.ID, - Name: tc.Function.Name, - Arguments: json.RawMessage(tc.Function.Arguments), - } - - result, execErr := s.agentRegistry.Execute(ctx, call) - if execErr != nil { - tcInfo.Error = execErr.Error() - writeSSE(map[string]interface{}{"tool_result": tcInfo}) - } else { - tcInfo.Result = &toolResponseData{ - Content: result.Content, - IsError: result.IsError, - Meta: result.Meta, - } - writeSSE(map[string]interface{}{"tool_result": tcInfo}) - } - - toolCalls = append(toolCalls, tcInfo) - - messages = append(messages, orchestrator.Message{ - Role: "tool", - Content: result.Content, - ToolCallID: tc.ID, - Name: tc.Function.Name, }) } + if tr, ok := data["tool_result"].(map[string]interface{}); ok { + tcID := toString(tr["tool_call_id"]) + for i := range toolCalls { + if toolCalls[i].ID == tcID { + if err, ok := tr["is_error"].(bool); ok && err { + toolCalls[i].Error = toString(tr["content"]) + } else { + toolCalls[i].Result = &toolResponseData{ + Content: toString(tr["content"]), + IsError: toBool(tr["is_error"]), + } + } + break + } + } + } + }) - finalContent = "" + finalContent, _, _, err := engine.RunWithTools(ctx, messages) + if err != nil { + sseWriter.Write(map[string]interface{}{"error": err.Error()}) + return } if finalContent == "" && len(toolCalls) > 0 { @@ -215,7 +180,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator. Content: finalContent, ToolCalls: toolCalls, }) - writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)}) + sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)}) } func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { @@ -224,80 +189,20 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat {Role: "user", Content: req.Message}, } - var finalContent string - var toolCalls []ToolCallInfo + engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) - for i := 0; i < maxShellToolIterations; i++ { - resp, err := orb.SendWithTools(messages) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - choice := resp.Choices[0] - content := cleanThinkingTags(choice.Message.Content) - - if content != "" { - finalContent = content - } - - if len(choice.Message.ToolCalls) == 0 { - break - } - - assistantMsg := orchestrator.Message{ - Role: "assistant", - Content: content, - ToolCalls: choice.Message.ToolCalls, - } - messages = append(messages, assistantMsg) - - for _, tc := range choice.Message.ToolCalls { - argsMap := make(map[string]interface{}) - json.Unmarshal([]byte(tc.Function.Arguments), &argsMap) - - tcInfo := ToolCallInfo{ - ID: tc.ID, - Name: tc.Function.Name, - Args: argsMap, - } - - call := agent.ToolCall{ - ID: tc.ID, - Name: tc.Function.Name, - Arguments: json.RawMessage(tc.Function.Arguments), - } - - result, execErr := s.agentRegistry.Execute(ctx, call) - if execErr != nil { - tcInfo.Error = execErr.Error() - } else { - tcInfo.Result = &toolResponseData{ - Content: result.Content, - IsError: result.IsError, - Meta: result.Meta, - } - } - - toolCalls = append(toolCalls, tcInfo) - - messages = append(messages, orchestrator.Message{ - Role: "tool", - Content: result.Content, - ToolCallID: tc.ID, - Name: tc.Function.Name, - }) - } - - finalContent = "" + finalContent, err := engine.RunNonStream(ctx, messages) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return } - if finalContent == "" && len(toolCalls) > 0 { + if finalContent == "" { finalContent = "(tool calls completed, no text response)" } writeJSON(w, ShellChatResponse{ Content: finalContent, - ToolCalls: toolCalls, + ToolCalls: nil, }) } \ No newline at end of file diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..6ee285c --- /dev/null +++ b/internal/api/handlers_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "context" + "encoding/json" + "testing" + + "github.com/muyue/muyue/internal/agent" +) + +func TestHandleToolCall(t *testing.T) { + // Test unknown tool returns error + registry := agent.NewRegistry() + + // Register a test tool + testTool, _ := agent.NewTool[struct{ Command string }]("test_tool", "Test tool", func(ctx context.Context, params struct{ Command string }) (agent.ToolResponse, error) { + return agent.TextResponse("executed: " + params.Command), nil + }) + registry.Register(testTool) + + // Test executing known tool + resp, err := registry.Execute(context.Background(), agent.ToolCall{ + ID: "test-id", + Name: "test_tool", + Arguments: json.RawMessage(`{"Command": "hello"}`), + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if resp.IsError { + t.Errorf("expected no error, got error response") + } + + // Test executing unknown tool + resp, err = registry.Execute(context.Background(), agent.ToolCall{ + ID: "test-id", + Name: "unknown_tool", + Arguments: json.RawMessage(`{}`), + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !resp.IsError { + t.Errorf("expected error for unknown tool") + } +} + +func TestCleanThinkingTags(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello world", "hello world"}, + {"thinkinghello", "hello"}, + {"THINKINGhello", "hello"}, + {"hello thinking world", "hello world"}, + {"no tags here", "no tags here"}, + } + + for _, tc := range tests { + result := cleanThinkingTags(tc.input) + if result != tc.expected { + t.Errorf("cleanThinkingTags(%q) = %q, want %q", tc.input, result, tc.expected) + } + } +} \ No newline at end of file diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index ee8b171..7c70887 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "regexp" "strings" @@ -76,6 +77,11 @@ var sharedHTTPClient = &http.Client{ Timeout: 120 * time.Second, } +// requestClient creates an HTTP client with the specified timeout. +func requestClient(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout} +} + func New(cfg *config.MuyueConfig) (*Orchestrator, error) { var provider *config.AIProvider for i := range cfg.AI.Providers { @@ -300,6 +306,142 @@ func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) return chatResp, nil } +// ChunkCallback is called for each streaming chunk. +type ChunkCallback func(content string, toolCalls []ToolCallMsg) + +// SendWithToolsStream sends messages with streaming responses. +// The callback receives chunks of content and tool_calls as they arrive. +func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) { + fullMessages := make([]Message, 0, len(messages)+1) + if o.systemPrompt != "" { + fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) + } + fullMessages = append(fullMessages, messages...) + + reqBody := ChatRequest{ + Model: o.provider.Model, + Messages: fullMessages, + Stream: true, + Tools: o.tools, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + provider := o.provider + baseURL := provider.BaseURL + if baseURL == "" { + baseURL = getProviderBaseURL(provider.Name) + } + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+provider.APIKey) + + resp, err := o.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var fullContent strings.Builder + var accumulatedToolCalls []ToolCallMsg + var totalTokens int + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chatResp ChatResponse + if err := json.Unmarshal([]byte(data), &chatResp); err != nil { + continue + } + + if len(chatResp.Choices) > 0 { + chunk := chatResp.Choices[0].Delta.Content + if chunk != "" { + fullContent.WriteString(chunk) + onChunk(chunk, nil) + } + + // Handle delta tool calls + if len(chatResp.Choices[0].Delta.ToolCalls) > 0 { + for _, tc := range chatResp.Choices[0].Delta.ToolCalls { + // Find or create the tool call in accumulated list + found := false + for i := range accumulatedToolCalls { + if accumulatedToolCalls[i].ID == tc.ID { + // Append arguments + accumulatedToolCalls[i].Function.Arguments += tc.Function.Arguments + found = true + break + } + } + if !found { + accumulatedToolCalls = append(accumulatedToolCalls, tc) + } + } + onChunk("", accumulatedToolCalls) + } + + // Capture usage from final chunk + if chatResp.Usage.TotalTokens > 0 { + totalTokens = chatResp.Usage.TotalTokens + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read stream: %w", err) + } + + // Build final response + finalResp := &ChatResponse{ + Usage: struct { + TotalTokens int `json:"total_tokens"` + }{TotalTokens: totalTokens}, + Choices: []struct { + Message struct { + Content string `json:"content"` + ToolCalls []ToolCallMsg `json:"tool_calls"` + } `json:"message"` + Delta struct { + Content string `json:"content"` + ToolCalls []ToolCallMsg `json:"tool_calls"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + }{}, + } + + finalContent := cleanAIResponse(fullContent.String()) + finalResp.Choices[0].Message.Content = finalContent + finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls + + return finalResp, nil +} + func cleanAIResponse(content string) string { content = thinkRegex.ReplaceAllString(content, "") lines := strings.Split(content, "\n") @@ -368,7 +510,9 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str } var lastErr error + var triedProviders []string for _, prov := range providerOrder { + triedProviders = append(triedProviders, prov.Name) baseURL := baseURLOverride if baseURL == "" { baseURL = prov.BaseURL @@ -392,7 +536,14 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+prov.APIKey) + + // Provider-specific headers + if prov.Name == "anthropic" { + req.Header.Set("x-api-key", prov.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + } else { + req.Header.Set("Authorization", "Bearer "+prov.APIKey) + } resp, err := o.client.Do(req) if err != nil { @@ -427,5 +578,6 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str return &chatResp, prov.Name, nil } + log.Printf("[orchestrator] fallback from %v to next provider", triedProviders) return nil, "", lastErr } diff --git a/internal/version/version.go b/internal/version/version.go index 265f33b..5c5e1cc 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,11 +1,33 @@ package version +import ( + "fmt" + "runtime" +) + const ( Name = "muyue" Version = "0.3.2" Author = "La LĂ©gion de Muyue" ) +var ( + // BuildDate is set at build time + BuildDate = "" +) + func FullVersion() string { return Name + " v" + Version } + +// FullInfo returns full version information. +func FullInfo() string { + info := fmt.Sprintf("%-12s %s\n", "Version:", Version) + info += fmt.Sprintf("%-12s %s\n", "Author:", Author) + info += fmt.Sprintf("%-12s %s\n", "Go:", runtime.Version()) + info += fmt.Sprintf("%-12s %s\n", "Platform:", runtime.GOOS+"/"+runtime.GOARCH) + if BuildDate != "" { + info += fmt.Sprintf("%-12s %s\n", "Build:", BuildDate) + } + return info +} diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 21af5e3..fd4899d 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -53,8 +53,12 @@ function renderContent(text) { } function formatText(text) { - return text + // First escape HTML entities + let html = text .replace(/&/g, '&').replace(//g, '>') + + // Apply markdown transformations (now with escaped brackets) + html = html .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/^### (.+)$/gm, '

$1

') @@ -62,6 +66,14 @@ function formatText(text) { .replace(/^# (.+)$/gm, '

$1

') .replace(/^\s*[-*] (.+)$/gm, '
‱ $1
') .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') + + // Sanitize: remove event handlers and dangerous protocols + html = html + .replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers + .replace(/javascript:/gi, '') + .replace(/data:/gi, '') + + return html } function ThinkingBlock({ content, done }) { @@ -324,6 +336,65 @@ export default function Studio({ api }) { return } + if (text === '/help') { + const helpMsg = [ + '## Commandes Studio', + '', + '- `/clear` - Effacer la conversation', + '- `/help` - Afficher cette aide', + '- `/plan ` - Demander un plan structurĂ©', + '- `/export` - Exporter la conversation en Markdown', + '- `/model` - Afficher le provider et modĂšle actifs', + '', + '## Tools disponibles', + '- Terminal - ExĂ©cuter des commandes', + '- read_file - Lire des fichiers', + '- list_files - Lister des fichiers', + '- search_files - Rechercher des fichiers', + '- grep_content - Rechercher dans le contenu', + '- get_config - Lire la configuration', + '- web_fetch - RĂ©cupĂ©rer une page web', + ].join('\n') + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }]) + return + } + + if (text === '/model') { + api.getProviders().then(data => { + const active = data.providers?.find(p => p.active) + const modelMsg = active ? `Provider: ${active.name}\nModĂšle: ${active.model}` : 'Aucun provider actif configurĂ©' + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }]) + }).catch(() => { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de rĂ©cupĂ©rer les providers', time: new Date().toISOString() }]) + }) + return + } + + if (text.startsWith('/plan ')) { + const objective = text.slice(6).trim() + if (!objective) { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Usage: `/plan `\nEx: `/plan crĂ©er un fichier de test`', time: new Date().toISOString() }]) + return + } + setInput(`CrĂ©e un plan structurĂ© en Ă©tapes numĂ©rotĂ©es pour: ${objective}. Chaque Ă©tape devrait avoir une estimation de complexitĂ© et de temps.`) + handleSend() + return + } + + if (text === '/export') { + api.getChatHistory().then(data => { + let markdown = '# Conversation Export\n\n' + data.messages?.forEach((msg, i) => { + const roleLabel = msg.role === 'user' ? 'đŸ‘€' : (msg.role === 'assistant' ? 'đŸ€–' : '⚙') + markdown += `## [${i + 1}] ${roleLabel} ${msg.role}\n${msg.content}\n\n---\n\n` + }) + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Conversation exportĂ©e:\n```markdown\n' + markdown + '```', time: new Date().toISOString() }]) + }).catch(() => { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible d\'exporter la conversation', time: new Date().toISOString() }]) + }) + return + } + const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() } setMessages(prev => [...prev, userMsg]) setLoading(true) @@ -472,7 +543,7 @@ export default function Studio({ api }) { )}
- {t('studio.inputHint')} · /clear + {t('studio.inputHint')} · /clear /help /plan /export /model
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 31dfa28..f25c2f4 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -684,6 +684,8 @@ input::placeholder { color: var(--text-disabled); } 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; transition: all 0.3s ease; + max-height: 200px; + overflow-y: auto; } .feed-thinking-block.active { border-left-color: var(--warning); @@ -826,7 +828,8 @@ input::placeholder { color: var(--text-disabled); } font-size: 12px; font-family: var(--font-mono); color: var(--text-tertiary); - white-space: nowrap; + white-space: pre-wrap; + word-break: break-all; overflow: hidden; text-overflow: ellipsis; border-bottom: 1px solid var(--border); From 7682717093dbfaeb44c0f3639fda9bbd290517bb Mon Sep 17 00:00:00 2001 From: Augustin Date: Thu, 23 Apr 2026 19:24:23 +0200 Subject: [PATCH 11/15] feat(dashboard): add quota monitoring, process list, and command history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New API endpoints: /providers/quota, /recent-commands, /running-processes - New grid-based dashboard layout with cards for tools, quota, processes, commands - Improved OnboardingWizard with required API key validation and scanning feedback - Auto-initialize config on first run 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/api/handlers_info.go | 194 +++++++++ internal/api/server.go | 25 +- web/src/api/client.js | 3 + web/src/components/Dashboard.jsx | 541 +++++++----------------- web/src/components/OnboardingWizard.jsx | 173 +++++--- web/src/styles/global.css | 193 +++++---- 6 files changed, 592 insertions(+), 537 deletions(-) diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 43ec284..33a0b78 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -2,8 +2,14 @@ package api import ( "encoding/json" + "fmt" + "io" "net/http" "os" + "os/exec" + "path/filepath" + "strings" + "time" "github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/mcp" @@ -415,3 +421,191 @@ func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) { editors := scanner.ScanEditors() writeJSON(w, map[string]interface{}{"editors": editors}) } + +func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) { + type providerQuota struct { + Name string `json:"name"` + Active bool `json:"active"` + Healthy bool `json:"healthy"` + Data map[string]interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + } + var results []providerQuota + client := &http.Client{Timeout: 8 * time.Second} + for _, p := range s.config.AI.Providers { + q := providerQuota{Name: p.Name, Active: p.Active} + switch p.Name { + case "minimax": + if p.APIKey == "" { + q.Error = "no API key" + results = append(results, q) + continue + } + req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil) + req.Header.Set("Authorization", "Bearer "+p.APIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + q.Error = err.Error() + } else { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var data map[string]interface{} + if json.Unmarshal(body, &data) == nil { + if models, ok := data["model_remains"].([]interface{}); ok { + filtered := make([]map[string]interface{}, 0) + for _, m := range models { + if mm, ok := m.(map[string]interface{}); ok { + usage, _ := mm["current_interval_usage_count"].(float64) + total, _ := mm["current_interval_total_count"].(float64) + if total > 0 { + filtered = append(filtered, map[string]interface{}{ + "model": mm["model_name"], + "used": usage, + "total": total, + "remaining": total - usage, + "weekly_used": mm["current_weekly_usage_count"], + "weekly_total": mm["current_weekly_total_count"], + }) + } + } + } + q.Data = map[string]interface{}{"models": filtered} + q.Healthy = true + } + } + } + case "zai": + if p.APIKey == "" { + q.Error = "no API key" + results = append(results, q) + continue + } + req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil) + req.Header.Set("Authorization", "Bearer "+p.APIKey) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + q.Error = err.Error() + } else { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var data map[string]interface{} + if json.Unmarshal(body, &data) == nil { + q.Data = data + q.Healthy = true + } + } + default: + q.Error = "quota not supported" + } + results = append(results, q) + } + writeJSON(w, map[string]interface{}{"providers": results}) +} + +func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) { + home, _ := os.UserHomeDir() + type cmdEntry struct { + Cmd string `json:"cmd"` + Shell string `json:"shell"` + } + + var entries []cmdEntry + + for _, histFile := range []string{".bash_history", ".zsh_history"} { + path := filepath.Join(home, histFile) + data, err := os.ReadFile(path) + if err != nil { + continue + } + shell := "bash" + if strings.Contains(histFile, "zsh") { + shell = "zsh" + } + lines := strings.Split(string(data), "\n") + start := len(lines) - 25 + if start < 0 { + start = 0 + } + for i := len(lines) - 1; i >= start; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, ": ") { + parts := strings.SplitN(line, ";", 2) + if len(parts) == 2 { + line = strings.TrimSpace(parts[1]) + } else { + continue + } + } + if line == "" { + continue + } + entries = append(entries, cmdEntry{Cmd: line, Shell: shell}) + } + } + + max := 20 + if len(entries) > max { + entries = entries[:max] + } + + writeJSON(w, map[string]interface{}{"commands": entries}) +} + +func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) { + type proc struct { + PID int `json:"pid"` + Name string `json:"name"` + Command string `json:"command"` + CPU string `json:"cpu"` + Mem string `json:"mem"` + } + var procs []proc + + editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"} + langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"} + interesting := append(editors, langs...) + interesting = append(interesting, "muyue") + + cmd := exec.Command("ps", "aux") + out, err := cmd.Output() + if err != nil { + writeJSON(w, map[string]interface{}{"processes": procs}) + return + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 11 { + continue + } + fullCmd := strings.Join(fields[10:], " ") + name := filepath.Base(fields[10]) + matched := false + for _, pattern := range interesting { + if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) { + matched = true + break + } + } + if !matched { + continue + } + var pid int + fmt.Sscanf(fields[1], "%d", &pid) + procs = append(procs, proc{ + PID: pid, + Name: name, + Command: fullCmd, + CPU: fields[2], + Mem: fields[3], + }) + } + + writeJSON(w, map[string]interface{}{"processes": procs}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 1890282..02a0a1c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "log" "net/http" "strings" @@ -23,9 +24,26 @@ type Server struct { func NewServer(cfg *config.MuyueConfig) *Server { s := &Server{ - config: cfg, - mux: http.NewServeMux(), + mux: http.NewServeMux(), } + // Auto-initialize config if nil or if no config file exists on disk + if cfg == nil || !config.Exists() { + defaultCfg := config.Default() + if cfg != nil { + // Preserve any user-provided settings from cfg + defaultCfg.Profile = cfg.Profile + defaultCfg.AI = cfg.AI + defaultCfg.Tools = cfg.Tools + defaultCfg.BMAD = cfg.BMAD + defaultCfg.Terminal = cfg.Terminal + } + // Save initial config to establish the file for first-time usage + if err := config.Save(defaultCfg); err != nil { + log.Printf("config: initial save failed: %v", err) + } + cfg = defaultCfg + } + s.config = cfg s.scanResult = scanner.ScanSystem() s.convStore = NewConversationStore() s.agentRegistry = agent.DefaultRegistry() @@ -95,6 +113,9 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/skills/export", s.handleSkillExport) s.mux.HandleFunc("/api/skills/import", s.handleSkillImport) s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus) + s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) + s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands) + s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/api/client.js b/web/src/api/client.js index 1aa75c5..68b3710 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -37,6 +37,9 @@ const api = { exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }), importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }), getDashboardStatus: () => request('/dashboard/status'), + getProvidersQuota: () => request('/providers/quota'), + getRecentCommands: () => request('/recent-commands'), + getRunningProcesses: () => request('/running-processes'), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 3efde8f..fc6d171 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -1,438 +1,181 @@ import { useState, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' -const TOOL_ICONS = { - crush: '⚡', - claude: 'đŸ€–', - go: 'đŸ”·', - node: '🟱', - python: '🐍', - docker: '🐳', - git: '📚', - ssh: '🌐', - starship: '🚀', - rust: '🩀', -} - -function ToolCard({ tool, onInstall, installing }) { - const { t } = useI18n() - const [showInstall, setShowInstall] = useState(false) - - const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧' - const isInstalled = tool.installed || tool.status === 'installed' - const version = tool.version || '' - const hasUpdate = tool.hasUpdate || tool.updateAvailable - - return ( -
-
{icon}
-
-
{tool.name || 'Unknown'}
-
- {isInstalled ? ( - {t('dashboard.installed')} - ) : ( - {t('dashboard.missing')} - )} - {version && {version}} -
-
-
- {isInstalled && hasUpdate && ( - - ↑ {tool.latestVersion || 'new'} - - )} - {!isInstalled && ( - - )} -
-
- ) -} - -function ActivityItem({ entry }) { - const time = entry.time - ? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) - : '' - const type = entry.type || entry.level || 'info' - const text = entry.message || entry.text || entry.content || '' - - const typeClass = { - ok: 'notif-ok', - success: 'notif-ok', - install: 'notif-ok', - update: 'notif-info', - info: 'notif-info', - warn: 'notif-warn', - warning: 'notif-warn', - error: 'notif-error', - fail: 'notif-error', - }[type] || 'notif-info' - - const icon = { - ok: '✓', success: '✓', install: '✓', update: '→', - info: 'â„č', warn: '⚠', warning: '⚠', error: '✗', fail: '✗', - }[type] || '‱' - - return ( -
- {time} - {icon} - {text} -
- ) -} - -function QuickActionButton({ icon, label, onClick, loading, disabled }) { - return ( - - ) -} - export default function Dashboard({ api }) { const { t } = useI18n() - const [activeTab, setActiveTab] = useState('tools') const [tools, setTools] = useState([]) - const [updates, setUpdates] = useState([]) const [systemInfo, setSystemInfo] = useState(null) - const [notifications, setNotifications] = useState([]) - const [loading, setLoading] = useState(false) - const [installing, setInstalling] = useState(false) - const [scanLoading, setScanLoading] = useState(false) - const [mcpLoading, setMcpLoading] = useState(false) const [dashboardStatus, setDashboardStatus] = useState(null) + const [quota, setQuota] = useState(null) + const [recentCmds, setRecentCmds] = useState([]) + const [processes, setProcesses] = useState([]) + const [updates, setUpdates] = useState([]) const loadData = useCallback(async () => { try { - const [toolsData, updatesData, systemData] = await Promise.all([ + const [toolsData, systemData, dashData, quotaData, cmdData, procData, updatesData] = await Promise.all([ api.getTools().catch(() => ({ tools: [] })), - api.getUpdates().catch(() => ({ updates: [] })), api.getSystem().catch(() => null), + api.getDashboardStatus().catch(() => null), + api.getProvidersQuota().catch(() => null), + api.getRecentCommands().catch(() => ({ commands: [] })), + api.getRunningProcesses().catch(() => ({ processes: [] })), + api.getUpdates().catch(() => ({ updates: [] })), ]) setTools(toolsData.tools || toolsData || []) + setSystemInfo(systemData?.system || systemData) + setDashboardStatus(dashData) + setQuota(quotaData?.providers || []) + setRecentCmds(cmdData.commands || []) + setProcesses(procData.processes || []) setUpdates(updatesData.updates || updatesData || []) - setSystemInfo(systemData) - api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {}) } catch (err) { - console.error('Failed to load dashboard data:', err) + console.error('Dashboard load error:', err) } }, [api]) - useEffect(() => { - loadData() - }, [loadData]) - - const addNotification = (message, type = 'info') => { - const entry = { id: Date.now(), time: new Date().toISOString(), message, type } - setNotifications(prev => [entry, ...prev].slice(0, 100)) - } - - const handleRescan = async () => { - setScanLoading(true) - addNotification(t('dashboard.rescanning'), 'info') - try { - await api.runScan() - await loadData() - addNotification(t('dashboard.scanComplete'), 'ok') - } catch (err) { - addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error') - } finally { - setScanLoading(false) - } - } - - const handleInstallMissing = async () => { - const missing = tools.filter(t => !t.installed && t.status !== 'installed') - if (missing.length === 0) return - setInstalling(true) - addNotification(t('dashboard.installing', { count: missing.length }), 'info') - try { - await api.installTools(missing.map(t => t.name)) - addNotification(t('dashboard.installStarted'), 'ok') - setTimeout(() => handleRescan(), 2000) - } catch (err) { - addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') - } finally { - setInstalling(false) - } - } - - const handleCheckUpdates = async () => { - setLoading(true) - addNotification(t('config.checking'), 'info') - try { - const data = await api.getUpdates() - setUpdates(data.updates || data || []) - const count = (data.updates || data || []).length - if (count > 0) { - addNotification(t('dashboard.updatesCount', { count }), 'warn') - } else { - addNotification(t('dashboard.allUpToDate'), 'ok') - } - } catch (err) { - addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error') - } finally { - setLoading(false) - } - } - - const handleConfigureMCP = async () => { - setMcpLoading(true) - addNotification(t('dashboard.configuringMCP'), 'info') - try { - await api.configureMCP() - addNotification(t('dashboard.mcpConfigured'), 'ok') - } catch (err) { - addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error') - } finally { - setMcpLoading(false) - } - } - - const handleInstallTool = async (name) => { - setInstalling(true) - addNotification(`${t('dashboard.installing')} ${name}...`, 'info') - try { - await api.installTools([name]) - addNotification(`${name} ${t('dashboard.installed')}`, 'ok') - setTimeout(() => loadData(), 2000) - } catch (err) { - addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error') - } finally { - setInstalling(false) - } - } + useEffect(() => { loadData() }, [loadData]) const installedCount = tools.filter(t => t.installed || t.status === 'installed').length - const missingCount = tools.length - installedCount + const sys = systemInfo || {} + + const minimax = (quota || []).find(p => p.name === 'minimax') + const zai = (quota || []).find(p => p.name === 'zai') return ( -
-
- - - - +
+ {/* System */} +
+
+ {sys.os || sys.platform || 'System'} · {sys.arch || ''} + +
+
+ {tools.slice(0, 12).map((tool, i) => { + const ok = tool.installed || tool.status === 'installed' + return ( + + {ok ? '●' : '○'} {tool.name} + + ) + })} + {tools.length > 12 && +{tools.length - 12}} +
-
- {activeTab === 'tools' && ( -
-
-
{t('dashboard.systemOverview')}
-
- {installedCount} {t('dashboard.installed')} - {missingCount > 0 && {missingCount} {t('dashboard.missing')}} + {/* API Quota */} +
+
+ API Quota +
+
+ {minimax && minimax.data?.models?.map((m, i) => ( +
+ {String(m.model).replace('MiniMax-', '')} +
+
+ {m.remaining}/{m.total}
- {systemInfo && ( -
- {systemInfo.os || systemInfo.platform || 'Unknown'} - · - {systemInfo.arch || 'Unknown'} - {systemInfo.shell && <>·{systemInfo.shell}} -
- )} -
- {tools.length === 0 && ( -
{t('dashboard.noTools')}
- )} - {tools.map((tool, i) => ( - - ))} + ))} + {minimax && minimax.data?.models?.length === 0 && ( +
+ MiniMax + {minimax.error || 'no data'}
-
- )} + )} + {zai && ( +
+ Z.AI + {zai.healthy ? '✓ active' : zai.error || '—'} +
+ )} + {!minimax && !zai && No providers} +
+
- {activeTab === 'activity' && ( -
-
-
{t('dashboard.activityLog')}
- + {/* Running Processes */} +
+
+ Running Processes + {processes.length} +
+
+ {processes.length === 0 && No relevant processes} + {processes.slice(0, 8).map((p, i) => ( +
+ {p.name} + cpu {p.cpu}% · mem {p.mem}%
- {notifications.length === 0 ? ( -
{t('dashboard.noActivity')}
- ) : ( -
- {notifications.map(entry => ( - + ))} +
+
+ + {/* Recent Commands */} +
+
+ Recent Commands +
+
+ {recentCmds.length === 0 && No history} + {recentCmds.slice(0, 8).map((c, i) => ( +
+ {c.shell} + {c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd} +
+ ))} +
+
+ + {/* Status (MCP/LSP/Skills) */} +
+
+ Services +
+ {dashboardStatus ? ( +
+
+ MCP + {dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy +
+
+ LSP + {dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed +
+
+ Skills + {dashboardStatus.skills?.total || 0} deployed +
+ {(dashboardStatus.skills?.issues || []).length > 0 && ( +
+ {(dashboardStatus.skills.issues || []).slice(0, 3).map((issue, i) => ( +
⚠ {issue}
))}
)}
- )} - - {activeTab === 'actions' && ( -
-
-
{t('dashboard.quickActions')}
-
-
- - - - -
- - {updates.length > 0 && ( -
-
-
{t('dashboard.updates')}
- {updates.length} -
-
- {updates.map((update, i) => ( -
-
- {update.name || 'Unknown'} - - {update.current || update.version || '?'} → {update.latest || update.target || '?'} - -
- -
- ))} -
-
- )} -
- )} - - {activeTab === 'status' && ( -
- {dashboardStatus ? ( - <> -
-
MCP Servers
- {dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy -
-
- {(dashboardStatus.mcp?.servers || []).map((s, i) => ( -
-
-
{s.name}
-
- {s.healthy ? healthy : - s.installed ? installed : - not found} -
-
-
- ))} -
- -
-
LSP Servers
- {dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed -
-
- {(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => ( -
-
-
{s.name}
-
- {s.language} -
-
-
- ))} -
- -
-
Skills
- {dashboardStatus.skills?.total || 0} deployed - {(dashboardStatus.skills?.issues || []).length > 0 && ( - {(dashboardStatus.skills.issues || []).length} issues - )} -
- {(dashboardStatus.skills?.issues || []).length > 0 && ( -
- {(dashboardStatus.skills.issues || []).map((issue, i) => ( -
{issue}
- ))} -
- )} - - ) : ( -
Loading status...
- )} -
+ ) : ( + Loading... )}
+ + {/* Updates */} + {updates.length > 0 && ( +
+
+ Updates Available + {updates.length} +
+
+ {updates.slice(0, 5).map((u, i) => ( +
+ {u.name} + {u.current || '?'} → {u.latest || '?'} +
+ ))} +
+
+ )}
) -} \ No newline at end of file +} diff --git a/web/src/components/OnboardingWizard.jsx b/web/src/components/OnboardingWizard.jsx index 0434c74..09d8443 100644 --- a/web/src/components/OnboardingWizard.jsx +++ b/web/src/components/OnboardingWizard.jsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react' import { useI18n, LANGUAGES } from '../i18n' import { getLayoutList } from '../i18n/keyboards' @@ -32,6 +32,8 @@ export default function OnboardingWizard({ api, onComplete }) { const [validating, setValidating] = useState(false) const [keyValid, setKeyValid] = useState(false) const [scanning, setScanning] = useState(false) + const [scanMessage, setScanMessage] = useState('') + const scanAbortRef = useRef(null) const current = STEPS[step] const layouts = getLayoutList() @@ -50,7 +52,7 @@ export default function OnboardingWizard({ api, onComplete }) { case 'name': return answers.name.trim().length > 0 case 'language': return !!answers.language case 'keyboard': return !!answers.keyboard - case 'apikey': return true + case 'apikey': return keyValid && !scanning case 'editor': return true case 'done': return true default: return true @@ -61,14 +63,84 @@ export default function OnboardingWizard({ api, onComplete }) { if (step > 0) setStep(step - 1) } + const cycleOption = (key, list, dir) => { + const idx = list.findIndex(item => item.id === answers[key]) + const next = (idx + dir + list.length) % list.length + setAnswers(a => ({ ...a, [key]: list[next].id })) + } + + const cycleOptionEditor = (dir) => { + const idx = editorList.findIndex(ed => ed === answers.editor) + const next = (idx + dir + editorList.length) % editorList.length + setAnswers(a => ({ ...a, editor: editorList[next] })) + } + + const handleScanViaChat = async (apikey) => { + setScanning(true) + setScanMessage('Recherche des éditeurs sur votre systÚme...') + setError(null) + try { + const detected = [] + const fallback = async () => { + setScanMessage('Utilisation du scan local...') + const data = await api.getEditors() + return (data.editors || []).map(e => e.name) + } + const prompt = 'Liste tous les éditeurs de texte et IDE installés sur ce systÚme. Exécute les commandes nécessaires pour les détecter (which, command -v, etc.). Réponds UNIQUEMENT avec les noms séparés par des virgules, sans aucune autre explication. Exemples: vim, nvim, code, emacs, nano, helix, subl, zed' + const ctrl = new AbortController() + scanAbortRef.current = ctrl + const full = await api.sendChat(prompt, true, (text, data) => { + if (data.tool_call) setScanMessage('Exécution: ' + (data.tool_call.name || '...')) + else if (data.tool_result) setScanMessage('Analyse des résultats...') + else if (data.content) setScanMessage('Réception: ' + text.slice(0, 60) + (text.length > 60 ? '...' : '')) + }, ctrl.signal) + const names = full.split(/[,\n]/).map(s => s.replace(/[^a-zA-Z0-9._-]/g, '')).filter(Boolean) + if (names.length > 0) { + detected.push(...names) + } else { + detected.push(...(await fallback())) + } + const merged = [...new Set([...detected.map(n => n.toLowerCase()), ...BASE_EDITORS])] + setEditorList(merged) + setScanMessage('') + } catch (err) { + try { + setScanMessage('Fallback: scan local...') + const data = await api.getEditors() + const detected = (data.editors || []).map(e => e.name) + const merged = [...new Set([...detected, ...BASE_EDITORS])] + setEditorList(merged) + } catch {} + setScanMessage('') + } + setScanning(false) + } + useEffect(() => { const handler = (e) => { if (e.key === 'Escape') { goPrev(); return } + if (current.key === 'language') { + if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('language', LANGUAGES, 1); return } + if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('language', LANGUAGES, -1); return } + } + if (current.key === 'keyboard') { + if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('keyboard', layouts, 1); return } + if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('keyboard', layouts, -1); return } + } + if (current.key === 'editor') { + if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOptionEditor(1); return } + if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOptionEditor(-1); return } + } + if (e.key === 'Tab') { e.preventDefault(); const input = document.querySelector('.onboarding-input'); if (input) input.focus(); return } if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [step, current]) + }, [step, current, answers, editorList]) + + useEffect(() => { + return () => { if (scanAbortRef.current) scanAbortRef.current.abort() } + }, []) useEffect(() => { if (current.key === 'done' && !saving) { @@ -88,6 +160,14 @@ export default function OnboardingWizard({ api, onComplete }) { base_url: 'https://api.minimax.io/v1', }) setKeyValid(true) + await api.saveProvider({ + name: 'minimax', + api_key: answers.apikey, + model: 'MiniMax-M2.7', + base_url: 'https://api.minimax.io/v1', + active: true, + }) + handleScanViaChat(answers.apikey) } catch (err) { setError(err.message || 'Clé invalide') setKeyValid(false) @@ -95,22 +175,7 @@ export default function OnboardingWizard({ api, onComplete }) { setValidating(false) } - const handleScanEditors = async () => { - setScanning(true) - setError(null) - try { - const data = await api.getEditors() - const detected = (data.editors || []).map(e => e.name) - const merged = [...new Set([...detected, ...BASE_EDITORS])] - setEditorList(merged) - if (detected.length === 0) { - setError('Aucun éditeur détecté') - } - } catch (err) { - setError(err.message || 'Erreur lors du scan') - } - setScanning(false) - } + const handleSave = async () => { setSaving(true) @@ -154,9 +219,10 @@ export default function OnboardingWizard({ api, onComplete }) {
- {STEPS.map((_, i) => ( -
- ))} + {STEPS.filter(s => s.key !== 'done').map(s => { + const i = STEPS.indexOf(s) + return
+ })}
@@ -221,7 +287,7 @@ export default function OnboardingWizard({ api, onComplete }) {
Clé API MiniMax
- Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard. + Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
{error && !keyValid &&
{error}
} - {keyValid &&
ClĂ© valide ✓
} + {keyValid && !scanning &&
ClĂ© valide ✓ — Appuyez sur EntrĂ©e pour continuer
} + {scanning && ( +
+ + {scanMessage} +
+ )} + {requiredError &&
Veuillez valider votre clé API pour continuer
}
-
- {answers.apikey.trim() && !keyValid && !error && ( -
Cliquez "Valider la clé" ou "Passer"
+ {!keyValid && !error && answers.apikey.trim() && ( +
Entrez votre clé puis cliquez "Valider la clé"
)}
)} @@ -258,27 +324,19 @@ export default function OnboardingWizard({ api, onComplete }) { {current.key === 'editor' && (
Quel éditeur utilisez-vous ?
-
-
- {editorList.map(ed => ( -
setAnswers(a => ({ ...a, editor: ed }))} - > - {ed} -
- ))} -
- +
+ {scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur ou tapez-en un autre ci-dessous.'} +
+
+ {editorList.map(ed => ( +
setAnswers(a => ({ ...a, editor: ed }))} + > + {ed} +
+ ))}
setAnswers(a => ({ ...a, editor: e.target.value }))} autoFocus /> - {error &&
{error}
}
)} @@ -394,6 +451,10 @@ export default function OnboardingWizard({ api, onComplete }) { .onboarding-hint { font-size: 12px; color: var(--text-tertiary); margin-top: 4px; } + .onboarding-scanning { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--accent); margin-top: 4px; + } .spin-icon { animation: spin 1s linear infinite; } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index f25c2f4..0213d00 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -525,10 +525,122 @@ input::placeholder { color: var(--text-disabled); } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; } +/* ── Dashboard Grid ── */ +.dash-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 16px; + height: 100%; + overflow: hidden; +} +.dash-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 14px 16px; + display: flex; flex-direction: column; gap: 8px; + overflow: hidden; +} +.dash-span-2 { grid-column: span 2; } +.dash-card-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 4px; +} +.dash-label { + font-size: 11px; font-weight: 700; color: var(--accent); + text-transform: uppercase; letter-spacing: 0.5px; +} +.dash-count { + font-size: 10px; font-family: var(--font-mono); + background: var(--bg-input); padding: 1px 6px; border-radius: 10px; +} +.dash-count.warn { background: var(--accent-bg); color: var(--accent); } + +/* Tools row */ +.dash-tools-row { + display: flex; flex-wrap: wrap; gap: 6px; +} +.dash-tool-tag { + font-size: 11px; font-family: var(--font-mono); + padding: 3px 8px; border-radius: var(--radius); + background: var(--bg-surface); +} +.dash-tool-tag.ok { color: var(--success); } +.dash-tool-tag.missing { color: var(--error); } + +/* Quota */ +.dash-quota-list { display: flex; flex-direction: column; gap: 6px; } +.dash-quota-row { display: flex; align-items: center; gap: 8px; } +.dash-quota-name { + font-size: 11px; font-weight: 600; color: var(--text-primary); + min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.dash-bar { + flex: 1; height: 4px; background: var(--bg-input); border-radius: 2px; overflow: hidden; +} +.dash-bar-fill { + height: 100%; background: var(--accent); border-radius: 2px; + transition: width 0.3s; +} +.dash-quota-val { + font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); + white-space: nowrap; +} + +/* Processes */ +.dash-proc-list { display: flex; flex-direction: column; gap: 4px; } +.dash-proc-row { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 0; +} +.dash-proc-name { + font-size: 11px; font-weight: 600; color: var(--text-primary); + font-family: var(--font-mono); +} +.dash-proc-res { + font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); +} + +/* Commands */ +.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; } +.dash-cmd-row { + display: flex; align-items: center; gap: 6px; + padding: 3px 0; overflow: hidden; +} +.dash-cmd-shell { + font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled); + background: var(--bg-input); padding: 1px 4px; border-radius: 3px; + text-transform: uppercase; flex-shrink: 0; +} +.dash-cmd-text { + font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +/* Services */ +.dash-services { display: flex; flex-direction: column; gap: 6px; } +.dash-svc-row { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 0; +} +.dash-svc-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.dash-svc-val { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); } +.dash-svc-issues { margin-top: 4px; } +.dash-svc-issue { font-size: 10px; color: var(--warning); padding: 2px 0; } + +/* Updates */ +.dash-updates-list { display: flex; flex-direction: column; gap: 4px; } +.dash-update-row { + display: flex; justify-content: space-between; align-items: center; +} +.dash-update-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.dash-update-ver { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); } + +.dash-empty { font-size: 11px; color: var(--text-disabled); } + +/* Legacy dashboard kept for reference */ .dashboard-layout { display: flex; flex-direction: column; height: 100%; } .dashboard-content { flex: 1; overflow-y: auto; } .dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; } - .dashboard-section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s; @@ -540,11 +652,8 @@ input::placeholder { color: var(--text-disabled); } font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; } - .dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } - .dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; } - .dashboard-notifications { padding: 0; } .notif-row { display: flex; align-items: flex-start; gap: 12px; @@ -557,7 +666,6 @@ input::placeholder { color: var(--text-disabled); } .notif-ok .notif-text { color: var(--success); } .notif-warn .notif-text { color: var(--warning); } .notif-error .notif-text { color: var(--error); } - .dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; } .workflow-section { } .section-label { @@ -565,81 +673,6 @@ input::placeholder { color: var(--text-disabled); } letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); } -/* ── Dashboard Tabs ── */ -.dashboard-tabs { - display: flex; gap: 4px; padding: 12px 20px 0; - border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0; -} -.dashboard-tab { - padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0; - border: 1px solid transparent; border-bottom: none; background: transparent; - color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer; - display: flex; align-items: center; gap: 6px; transition: all 0.15s; -} -.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); } -.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); } -.dashboard-tab .tab-icon { font-size: 14px; } -.dashboard-tab .tab-count { - background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono); -} -.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); } - -.dashboard-tools-panel { padding: 20px 24px; } -.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; } -.stat-ok { color: var(--success); font-family: var(--font-mono); } -.stat-missing { color: var(--error); font-family: var(--font-mono); } - -.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); } -.sys-info-item { font-family: var(--font-mono); } -.sys-info-sep { color: var(--text-disabled); } - -.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; } -.tool-card { - background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s; -} -.tool-card:hover { border-color: var(--accent-dim); } -.tool-card.installed { border-left: 3px solid var(--success); } -.tool-card.missing { border-left: 3px solid var(--error); } -.tool-card-icon { font-size: 20px; flex-shrink: 0; } -.tool-card-info { flex: 1; min-width: 0; } -.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; } -.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; } -.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); } -.status-ok { color: var(--success); } -.status-missing { color: var(--error); } -.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; } -.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; } -.tool-update-badge:hover { background: var(--accent-dim); } - -.dashboard-activity-panel { padding: 20px 24px; } -.activity-log { display: flex; flex-direction: column; gap: 2px; } -.notif-icon { font-size: 12px; width: 16px; text-align: center; } - -.dashboard-actions-panel { padding: 20px 24px; } -.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; } -.quick-action-btn { - background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer; - transition: all 0.2s; font-size: 13px; color: var(--text-secondary); -} -.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); } -.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.quick-action-icon { font-size: 18px; } -.quick-action-label { font-weight: 600; } - -.dashboard-updates-section { margin-top: 16px; } -.updates-list { display: flex; flex-direction: column; gap: 6px; } -.update-row { - display: flex; align-items: center; justify-content: space-between; - padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card); - border: 1px solid var(--border); -} -.update-row:hover { border-color: var(--accent-dim); } -.update-info { display: flex; align-items: center; gap: 16px; } -.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; } -.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); } - .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg-surface); From 79d082180c0cd2816e33033584995d4987da9f9b Mon Sep 17 00:00:00 2001 From: Augustin Date: Thu, 23 Apr 2026 19:46:16 +0200 Subject: [PATCH 12/15] feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh - Add live CPU/RAM/Network SVG graphs with rolling 30-point history - Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev - Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring - Add backend /api/recent-commands reading bash/zsh history - Add backend /api/running-processes filtering editors/IDEs/languages - Add sudo/root indicator (⚡ ROOT) in footer when running as root - Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side) - Add Ctrl+R shortcut on dashboard for metrics-only refresh - Make API key mandatory in onboarding, auto-scan editors via AI chat - Remove manual editor input, only show AI-detected editors - Bump version to 0.3.3 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- internal/api/handlers_info.go | 109 ++++++++++++++++++++++ internal/api/server.go | 1 + internal/version/version.go | 2 +- web/src/api/client.js | 1 + web/src/components/App.jsx | 30 +++--- web/src/components/Dashboard.jsx | 119 ++++++++++++++---------- web/src/components/OnboardingWizard.jsx | 16 +--- web/src/styles/global.css | 14 +++ 8 files changed, 218 insertions(+), 74 deletions(-) diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index 33a0b78..35aea93 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -23,6 +23,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { "name": version.Name, "version": version.Version, "author": version.Author, + "sudo": os.Geteuid() == 0, }) } @@ -609,3 +610,111 @@ func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) writeJSON(w, map[string]interface{}{"processes": procs}) } + +type sysMetrics struct { + CPUPercent float64 `json:"cpu_percent"` + MemPercent float64 `json:"mem_percent"` + MemUsedMB float64 `json:"mem_used_mb"` + MemTotalMB float64 `json:"mem_total_mb"` + NetRxKBs float64 `json:"net_rx_kbs"` + NetTxKBs float64 `json:"net_tx_kbs"` +} + +var ( + lastCPU [2]float64 + lastNet [2]float64 + lastNetTs time.Time + lastCPUSet bool +) + +func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) { + m := sysMetrics{} + + // CPU from /proc/stat + if data, err := os.ReadFile("/proc/stat"); err == nil { + line := strings.Split(string(data), "\n")[0] + fields := strings.Fields(line) + if len(fields) >= 5 { + var idle, total float64 + for i := 1; i < len(fields) && i <= 4; i++ { + var v float64 + fmt.Sscanf(fields[i], "%f", &v) + total += v + if i == 4 { + idle = v + } + } + if lastCPUSet { + dIdle := idle - lastCPU[0] + dTotal := total - lastCPU[1] + if dTotal > 0 { + m.CPUPercent = (1 - dIdle/dTotal) * 100 + } + } + lastCPU = [2]float64{idle, total} + lastCPUSet = true + } + } + + // Memory from /proc/meminfo + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + var memTotal, memAvailable float64 + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + var v float64 + fmt.Sscanf(fields[1], "%f", &v) + switch fields[0] { + case "MemTotal:": + memTotal = v + case "MemAvailable:": + memAvailable = v + } + } + if memTotal > 0 { + m.MemTotalMB = memTotal / 1024 + m.MemUsedMB = (memTotal - memAvailable) / 1024 + m.MemPercent = (memTotal - memAvailable) / memTotal * 100 + } + } + + // Network from /proc/net/dev + if data, err := os.ReadFile("/proc/net/dev"); err == nil { + var rxBytes, txBytes float64 + for _, line := range strings.Split(string(data), "\n")[2:] { + fields := strings.Fields(line) + if len(fields) < 10 { + continue + } + iface := strings.TrimSuffix(fields[0], ":") + if iface == "lo" { + continue + } + var rx, tx float64 + fmt.Sscanf(fields[1], "%f", &rx) + fmt.Sscanf(fields[9], "%f", &tx) + rxBytes += rx + txBytes += tx + } + now := time.Now() + if !lastNetTs.IsZero() { + elapsed := now.Sub(lastNetTs).Seconds() + if elapsed > 0 { + m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed + m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed + if m.NetRxKBs < 0 { + m.NetRxKBs = 0 + } + if m.NetTxKBs < 0 { + m.NetTxKBs = 0 + } + } + } + lastNet = [2]float64{rxBytes, txBytes} + lastNetTs = now + } + + writeJSON(w, m) +} diff --git a/internal/api/server.go b/internal/api/server.go index 02a0a1c..57e4dfb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -116,6 +116,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands) s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses) + s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/version/version.go b/internal/version/version.go index 5c5e1cc..e5377aa 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.3.2" + Version = "0.3.3" Author = "La LĂ©gion de Muyue" ) diff --git a/web/src/api/client.js b/web/src/api/client.js index 68b3710..9affabf 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -40,6 +40,7 @@ const api = { getProvidersQuota: () => request('/providers/quota'), getRecentCommands: () => request('/recent-commands'), getRunningProcesses: () => request('/running-processes'), + getSystemMetrics: () => request('/system/metrics'), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index aca54b0..5d9f4db 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react' import api from '../api/client' import { getTheme, applyTheme } from '../themes' @@ -13,6 +13,9 @@ export default function App() { const [activeTab, setActiveTab] = useState('dash') const [info, setInfo] = useState({}) const [clock, setClock] = useState(new Date()) + const [isSudo, setIsSudo] = useState(false) + const [dashRefreshKey, setDashRefreshKey] = useState(0) + const dashRefreshRef = useRef(null) const [updates, setUpdates] = useState([]) const [tools, setTools] = useState([]) const [config, setConfig] = useState(null) @@ -27,7 +30,7 @@ export default function App() { ], [t]) useEffect(() => { - api.getInfo().then(setInfo).catch(() => {}) + api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {}) api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) api.getConfig().then(d => { @@ -60,6 +63,11 @@ export default function App() { if (map[e.code]) { e.preventDefault() setActiveTab(map[e.code]) + return + } + if (e.ctrlKey && e.code === 'KeyR') { + e.preventDefault() + if (dashRefreshRef.current) dashRefreshRef.current() } } window.addEventListener('keydown', onKey) @@ -72,27 +80,21 @@ export default function App() { const installed = tools.filter(tool => tool.installed).length const WINDOW_SHORTCUTS = useMemo(() => ({ - dash: [ - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, - ], + dash: [], studio: [ { keys: layout.keys.enter, desc: t('statusbar.sendMessage') }, { keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') }, - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, ], shell: [ { keys: layout.keys.enter, desc: t('statusbar.runCommand') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, - ], - config: [ - { keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') }, ], + config: [], }), [layout, t]) const renderContent = () => { switch (activeTab) { - case 'dash': return + case 'dash': return case 'studio': return case 'shell': return case 'config': return @@ -147,6 +149,12 @@ export default function App() {
+ {isSudo && ⚡ ROOT} + {activeTab === 'dash' && ( + + {layout.keys.ctrl}+R refresh + + )}
diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index fc6d171..9be3e84 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -1,66 +1,105 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useI18n } from '../i18n' -export default function Dashboard({ api }) { +const MAX_POINTS = 30 + +function MiniGraph({ data, max, color, label, unit }) { + if (!data || data.length < 2) return
collecting...
+ const m = max || Math.max(...data, 1) + const w = 100 + const h = 32 + const points = data.map((v, i) => { + const x = (i / (data.length - 1)) * w + const y = h - (v / m) * h + return `${x},${y}` + }).join(' ') + const last = data[data.length - 1] + return ( +
+
+ {label} + {last.toFixed(1)}{unit} +
+ + + +
+ ) +} + +export default function Dashboard({ api, refreshRef }) { const { t } = useI18n() - const [tools, setTools] = useState([]) - const [systemInfo, setSystemInfo] = useState(null) const [dashboardStatus, setDashboardStatus] = useState(null) const [quota, setQuota] = useState(null) const [recentCmds, setRecentCmds] = useState([]) const [processes, setProcesses] = useState([]) - const [updates, setUpdates] = useState([]) + const [metrics, setMetrics] = useState(null) + const cpuRef = useRef([]) + const memRef = useRef([]) + const netRxRef = useRef([]) + const netTxRef = useRef([]) const loadData = useCallback(async () => { try { - const [toolsData, systemData, dashData, quotaData, cmdData, procData, updatesData] = await Promise.all([ - api.getTools().catch(() => ({ tools: [] })), - api.getSystem().catch(() => null), + const [dashData, quotaData, cmdData, procData, metricsData] = await Promise.all([ api.getDashboardStatus().catch(() => null), api.getProvidersQuota().catch(() => null), api.getRecentCommands().catch(() => ({ commands: [] })), api.getRunningProcesses().catch(() => ({ processes: [] })), - api.getUpdates().catch(() => ({ updates: [] })), + api.getSystemMetrics().catch(() => null), ]) - setTools(toolsData.tools || toolsData || []) - setSystemInfo(systemData?.system || systemData) setDashboardStatus(dashData) setQuota(quotaData?.providers || []) setRecentCmds(cmdData.commands || []) setProcesses(procData.processes || []) - setUpdates(updatesData.updates || updatesData || []) + if (metricsData) { + setMetrics(metricsData) + cpuRef.current = [...cpuRef.current, metricsData.cpu_percent].slice(-MAX_POINTS) + memRef.current = [...memRef.current, metricsData.mem_percent].slice(-MAX_POINTS) + netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS) + netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS) + } } catch (err) { console.error('Dashboard load error:', err) } }, [api]) - useEffect(() => { loadData() }, [loadData]) - - const installedCount = tools.filter(t => t.installed || t.status === 'installed').length - const sys = systemInfo || {} + useEffect(() => { + loadData() + if (refreshRef) refreshRef.current = loadData + const iv = setInterval(loadData, 5000) + return () => clearInterval(iv) + }, [loadData, refreshRef]) const minimax = (quota || []).find(p => p.name === 'minimax') const zai = (quota || []).find(p => p.name === 'zai') return (
- {/* System */} -
+ {/* CPU / RAM / Network Graphs */} +
- {sys.os || sys.platform || 'System'} · {sys.arch || ''} - + CPU + {metrics ? metrics.cpu_percent.toFixed(0) : '—'}%
-
- {tools.slice(0, 12).map((tool, i) => { - const ok = tool.installed || tool.status === 'installed' - return ( - - {ok ? '●' : '○'} {tool.name} - - ) - })} - {tools.length > 12 && +{tools.length - 12}} + +
+ +
+
+ RAM + {metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)} MB` : '—'}
+ +
+ +
+
+ Network + {metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)} KB/s` : '—'} +
+ +
{/* API Quota */} @@ -127,7 +166,7 @@ export default function Dashboard({ api }) {
- {/* Status (MCP/LSP/Skills) */} + {/* Services */}
Services @@ -158,24 +197,6 @@ export default function Dashboard({ api }) { Loading... )}
- - {/* Updates */} - {updates.length > 0 && ( -
-
- Updates Available - {updates.length} -
-
- {updates.slice(0, 5).map((u, i) => ( -
- {u.name} - {u.current || '?'} → {u.latest || '?'} -
- ))} -
-
- )}
) } diff --git a/web/src/components/OnboardingWizard.jsx b/web/src/components/OnboardingWizard.jsx index 09d8443..7d7e9df 100644 --- a/web/src/components/OnboardingWizard.jsx +++ b/web/src/components/OnboardingWizard.jsx @@ -100,16 +100,14 @@ export default function OnboardingWizard({ api, onComplete }) { } else { detected.push(...(await fallback())) } - const merged = [...new Set([...detected.map(n => n.toLowerCase()), ...BASE_EDITORS])] - setEditorList(merged) + setEditorList([...new Set(detected.map(n => n.toLowerCase()))]) setScanMessage('') } catch (err) { try { setScanMessage('Fallback: scan local...') const data = await api.getEditors() const detected = (data.editors || []).map(e => e.name) - const merged = [...new Set([...detected, ...BASE_EDITORS])] - setEditorList(merged) + setEditorList([...new Set(detected)]) } catch {} setScanMessage('') } @@ -325,7 +323,7 @@ export default function OnboardingWizard({ api, onComplete }) {
Quel éditeur utilisez-vous ?
- {scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur ou tapez-en un autre ci-dessous.'} + {scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
{editorList.map(ed => ( @@ -338,14 +336,6 @@ export default function OnboardingWizard({ api, onComplete }) {
))}
- setAnswers(a => ({ ...a, editor: e.target.value }))} - autoFocus - />
)} diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 0213d00..82cabb0 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -169,6 +169,12 @@ input::placeholder { color: var(--text-disabled); } color: var(--text-disabled); } .statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; } +.statusbar-sudo { + font-size: 10px; font-weight: 700; font-family: var(--font-mono); + padding: 1px 6px; border-radius: 3px; + background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3); + text-transform: uppercase; letter-spacing: 0.5px; +} .statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; } .statusbar-shortcut kbd { display: inline-block; padding: 1px 5px; border-radius: 3px; @@ -637,6 +643,14 @@ input::placeholder { color: var(--text-disabled); } .dash-empty { font-size: 11px; color: var(--text-disabled); } +/* Graph */ +.dash-graph-wrap { display: flex; flex-direction: column; gap: 2px; } +.dash-graph-header { display: flex; justify-content: space-between; align-items: center; } +.dash-graph-label { font-size: 9px; color: var(--text-disabled); text-transform: uppercase; } +.dash-graph-value { font-size: 10px; font-family: var(--font-mono); font-weight: 600; } +.dash-graph-svg { width: 100%; height: 32px; } +.dash-graph-empty { font-size: 10px; color: var(--text-disabled); text-align: center; padding: 8px 0; } + /* Legacy dashboard kept for reference */ .dashboard-layout { display: flex; flex-direction: column; height: 100%; } .dashboard-content { flex: 1; overflow-y: auto; } From bb03c9fe2d75e5fec1bf18afc405d93af440d602 Mon Sep 17 00:00:00 2001 From: Augustin Date: Thu, 23 Apr 2026 19:55:10 +0200 Subject: [PATCH 13/15] feat(dashboard): add background graphs to cards and improve layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BgGraph component for subtle background SVG graphs - Add gradient fills to MiniGraph components - Track process count over time - Calculate total API quota usage - Improve card styling with overlay content 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/Dashboard.jsx | 203 +++++++++++++++++-------------- web/src/styles/global.css | 11 ++ 2 files changed, 122 insertions(+), 92 deletions(-) diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 9be3e84..ce2ad94 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -3,6 +3,32 @@ import { useI18n } from '../i18n' const MAX_POINTS = 30 +function BgGraph({ data, max, color }) { + if (!data || data.length < 2) return null + const m = max || Math.max(...data, 1) + const w = 120 + const h = 60 + const points = data.map((v, i) => { + const x = (i / (data.length - 1)) * w + const y = h - (v / m) * h + return `${x},${y}` + }) + const area = `${points.join(' ')} ${w},${h} 0,${h}` + const line = points.join(' ') + return ( + + + + + + + + + + + ) +} + function MiniGraph({ data, max, color, label, unit }) { if (!data || data.length < 2) return
collecting...
const m = max || Math.max(...data, 1) @@ -21,6 +47,13 @@ function MiniGraph({ data, max, color, label, unit }) { {last.toFixed(1)}{unit}
+ + + + + + +
@@ -29,7 +62,6 @@ function MiniGraph({ data, max, color, label, unit }) { export default function Dashboard({ api, refreshRef }) { const { t } = useI18n() - const [dashboardStatus, setDashboardStatus] = useState(null) const [quota, setQuota] = useState(null) const [recentCmds, setRecentCmds] = useState([]) const [processes, setProcesses] = useState([]) @@ -38,17 +70,16 @@ export default function Dashboard({ api, refreshRef }) { const memRef = useRef([]) const netRxRef = useRef([]) const netTxRef = useRef([]) + const procCountRef = useRef([]) const loadData = useCallback(async () => { try { - const [dashData, quotaData, cmdData, procData, metricsData] = await Promise.all([ - api.getDashboardStatus().catch(() => null), + const [quotaData, cmdData, procData, metricsData] = await Promise.all([ api.getProvidersQuota().catch(() => null), api.getRecentCommands().catch(() => ({ commands: [] })), api.getRunningProcesses().catch(() => ({ processes: [] })), api.getSystemMetrics().catch(() => null), ]) - setDashboardStatus(dashData) setQuota(quotaData?.providers || []) setRecentCmds(cmdData.commands || []) setProcesses(procData.processes || []) @@ -59,6 +90,7 @@ export default function Dashboard({ api, refreshRef }) { netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS) netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS) } + procCountRef.current = [...procCountRef.current, procData.processes?.length || 0].slice(-MAX_POINTS) } catch (err) { console.error('Dashboard load error:', err) } @@ -73,80 +105,99 @@ export default function Dashboard({ api, refreshRef }) { const minimax = (quota || []).find(p => p.name === 'minimax') const zai = (quota || []).find(p => p.name === 'zai') + const totalQuotaUsed = minimax?.data?.models?.reduce((s, m) => s + (m.used || 0), 0) || 0 + const totalQuotaMax = minimax?.data?.models?.reduce((s, m) => s + (m.total || 0), 0) || 1 return (
- {/* CPU / RAM / Network Graphs */} -
-
- CPU - {metrics ? metrics.cpu_percent.toFixed(0) : '—'}% + {/* CPU */} +
+ +
+
+ CPU + {metrics ? metrics.cpu_percent.toFixed(0) : '—'}% +
+
-
-
-
- RAM - {metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)} MB` : '—'} + {/* RAM */} +
+ +
+
+ RAM + {metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'} +
+
-
-
-
- Network - {metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)} KB/s` : '—'} + {/* Network */} +
+ +
+
+ Network + {metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)}` : '—'} +
+ +
- -
{/* API Quota */} -
-
- API Quota -
-
- {minimax && minimax.data?.models?.map((m, i) => ( -
- {String(m.model).replace('MiniMax-', '')} -
-
+
+ 0 ? [totalQuotaUsed / totalQuotaMax * 100, ...(cpuRef.current.length > 0 ? [] : [0])] : []} max={100} color="#f472b6" /> +
+
+ API Quota +
+
+ {minimax && minimax.data?.models?.map((m, i) => ( +
+ {String(m.model).replace('MiniMax-', '')} +
+
+
+ {m.remaining}/{m.total}
- {m.remaining}/{m.total} -
- ))} - {minimax && minimax.data?.models?.length === 0 && ( -
- MiniMax - {minimax.error || 'no data'} -
- )} - {zai && ( -
- Z.AI - {zai.healthy ? '✓ active' : zai.error || '—'} -
- )} - {!minimax && !zai && No providers} + ))} + {minimax && minimax.data?.models?.length === 0 && ( +
+ MiniMax + {minimax.error || 'no data'} +
+ )} + {zai && ( +
+ Z.AI + {zai.healthy ? '✓ active' : zai.error || '—'} +
+ )} + {!minimax && !zai && No providers} +
{/* Running Processes */} -
-
- Running Processes - {processes.length} -
-
- {processes.length === 0 && No relevant processes} - {processes.slice(0, 8).map((p, i) => ( -
- {p.name} - cpu {p.cpu}% · mem {p.mem}% -
- ))} +
+ +
+
+ Processes + {processes.length} +
+
+ {processes.length === 0 && No relevant processes} + {processes.slice(0, 6).map((p, i) => ( +
+ {p.name} + cpu {p.cpu}% · mem {p.mem}% +
+ ))} +
@@ -165,38 +216,6 @@ export default function Dashboard({ api, refreshRef }) { ))}
- - {/* Services */} -
-
- Services -
- {dashboardStatus ? ( -
-
- MCP - {dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy -
-
- LSP - {dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed -
-
- Skills - {dashboardStatus.skills?.total || 0} deployed -
- {(dashboardStatus.skills?.issues || []).length > 0 && ( -
- {(dashboardStatus.skills.issues || []).slice(0, 3).map((issue, i) => ( -
⚠ {issue}
- ))} -
- )} -
- ) : ( - Loading... - )} -
) } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 82cabb0..94113c5 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -541,11 +541,22 @@ input::placeholder { color: var(--text-disabled); } overflow: hidden; } .dash-card { + position: relative; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; overflow: hidden; } +.dash-card-graph { padding: 0; } +.dash-bg-graph { + position: absolute; inset: 0; width: 100%; height: 100%; + opacity: 0.35; pointer-events: none; +} +.dash-card-content { + position: relative; z-index: 1; + padding: 14px 16px; + display: flex; flex-direction: column; gap: 8px; +} .dash-span-2 { grid-column: span 2; } .dash-card-head { display: flex; align-items: center; justify-content: space-between; From e8f6dc4b4d475eb75c29efec0c8933489b3af0d8 Mon Sep 17 00:00:00 2001 From: Augustin Date: Thu, 23 Apr 2026 20:42:43 +0200 Subject: [PATCH 14/15] feat(chat): add auto-summarization with token tracking UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /summarize command, token usage bar, and summary endpoint. Add JSON tags to config/platform/scanner structs for API serialization. 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/api/handlers_chat.go | 20 +++++++- internal/api/server.go | 1 + internal/config/config.go | 88 +++++++++++++++++------------------ internal/platform/platform.go | 12 ++--- internal/scanner/scanner.go | 30 ++++++------ web/src/api/client.js | 1 + web/src/components/Studio.jsx | 51 +++++++++++++++++++- web/src/styles/global.css | 5 ++ 8 files changed, 139 insertions(+), 69 deletions(-) diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index aef47ba..58cb135 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -206,8 +206,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) { } messages := s.convStore.Get() writeJSON(w, map[string]interface{}{ - "messages": messages, - "tokens": s.convStore.ApproxTokenCount(), + "messages": messages, + "tokens": s.convStore.ApproxTokenCount(), + "max_tokens": maxTokensApprox, + "summarize_at": summarizeThreshold, + "summary": s.convStore.GetSummary(), }) } @@ -219,3 +222,16 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) { s.convStore.Clear() writeJSON(w, map[string]string{"status": "ok"}) } + +func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + s.autoSummarize() + writeJSON(w, map[string]interface{}{ + "status": "ok", + "tokens": s.convStore.ApproxTokenCount(), + "summary": s.convStore.GetSummary(), + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index 57e4dfb..2e6f9fb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -85,6 +85,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) + s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize) s.mux.HandleFunc("/api/tool/call", s.handleToolCall) s.mux.HandleFunc("/api/tools/list", s.handleToolList) s.mux.HandleFunc("/api/shell/chat", s.handleShellChat) diff --git a/internal/config/config.go b/internal/config/config.go index a8b5036..cb26ec0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,66 +12,66 @@ import ( ) type Profile struct { - Name string `yaml:"name"` - Pseudo string `yaml:"pseudo"` - Email string `yaml:"email"` - Languages []string `yaml:"languages"` + Name string `yaml:"name" json:"name"` + Pseudo string `yaml:"pseudo" json:"pseudo"` + Email string `yaml:"email" json:"email"` + Languages []string `yaml:"languages" json:"languages"` Preferences struct { - Editor string `yaml:"editor"` - Shell string `yaml:"shell"` - Theme string `yaml:"theme"` - DefaultAI string `yaml:"default_ai"` - AutoUpdate bool `yaml:"auto_update"` - CheckOnStart bool `yaml:"check_on_start"` - Language string `yaml:"language"` - KeyboardLayout string `yaml:"keyboard_layout"` - } `yaml:"preferences"` + Editor string `yaml:"editor" json:"editor"` + Shell string `yaml:"shell" json:"shell"` + Theme string `yaml:"theme" json:"theme"` + DefaultAI string `yaml:"default_ai" json:"default_ai"` + AutoUpdate bool `yaml:"auto_update" json:"auto_update"` + CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"` + Language string `yaml:"language" json:"language"` + KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"` + } `yaml:"preferences" json:"preferences"` } type AIProvider struct { - Name string `yaml:"name"` - APIKey string `yaml:"api_key,omitempty"` - BaseURL string `yaml:"base_url,omitempty"` - Model string `yaml:"model"` - Active bool `yaml:"active"` + Name string `yaml:"name" json:"name"` + APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` + BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` + Model string `yaml:"model" json:"model"` + Active bool `yaml:"active" json:"active"` } type ToolConfig struct { - Name string `yaml:"name"` - Installed bool `yaml:"installed"` - Version string `yaml:"version"` - AutoUpdate bool `yaml:"auto_update"` + Name string `yaml:"name" json:"name"` + Installed bool `yaml:"installed" json:"installed"` + Version string `yaml:"version" json:"version"` + AutoUpdate bool `yaml:"auto_update" json:"auto_update"` } type SSHConnection struct { - Name string `yaml:"name"` - Host string `yaml:"host"` - Port int `yaml:"port"` - User string `yaml:"user"` - Password string `yaml:"password,omitempty"` - KeyPath string `yaml:"key_path,omitempty"` + Name string `yaml:"name" json:"name"` + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port"` + User string `yaml:"user" json:"user"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"` } type MuyueConfig struct { - Version string `yaml:"version"` - Profile Profile `yaml:"profile"` + Version string `yaml:"version" json:"version"` + Profile Profile `yaml:"profile" json:"profile"` AI struct { - Providers []AIProvider `yaml:"providers"` - } `yaml:"ai"` - Tools []ToolConfig `yaml:"tools"` + Providers []AIProvider `yaml:"providers" json:"providers"` + } `yaml:"ai" json:"ai"` + Tools []ToolConfig `yaml:"tools" json:"tools"` BMAD struct { - Installed bool `yaml:"installed"` - Version string `yaml:"version"` - Global bool `yaml:"global"` - } `yaml:"bmad"` + Installed bool `yaml:"installed" json:"installed"` + Version string `yaml:"version" json:"version"` + Global bool `yaml:"global" json:"global"` + } `yaml:"bmad" json:"bmad"` Terminal struct { - CustomPrompt bool `yaml:"custom_prompt"` - PromptTheme string `yaml:"prompt_theme"` - SSH []SSHConnection `yaml:"ssh"` - FontSize int `yaml:"font_size"` - FontFamily string `yaml:"font_family"` - Theme string `yaml:"theme"` - } `yaml:"terminal"` + CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"` + PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"` + SSH []SSHConnection `yaml:"ssh" json:"ssh"` + FontSize int `yaml:"font_size" json:"font_size"` + FontFamily string `yaml:"font_family" json:"font_family"` + Theme string `yaml:"theme" json:"theme"` + } `yaml:"terminal" json:"terminal"` } type TerminalTheme struct { diff --git a/internal/platform/platform.go b/internal/platform/platform.go index c34c488..6aa8cb5 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -24,12 +24,12 @@ const ( ) type SystemInfo struct { - OS OS - Arch Arch - IsWSL bool - Shell string - Terminal string - PackageManager string + OS OS `json:"os"` + Arch Arch `json:"arch"` + IsWSL bool `json:"is_wsl"` + Shell string `json:"shell"` + Terminal string `json:"terminal"` + PackageManager string `json:"package_manager"` } func Detect() SystemInfo { diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 9721c7b..9898def 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -14,27 +14,27 @@ import ( ) type ToolStatus struct { - Name string `yaml:"name"` - Installed bool `yaml:"installed"` - Version string `yaml:"version"` - Path string `yaml:"path"` - Latest string `yaml:"latest"` - NeedsUpdate bool `yaml:"needs_update"` - Category string `yaml:"category"` + Name string `yaml:"name" json:"name"` + Installed bool `yaml:"installed" json:"installed"` + Version string `yaml:"version" json:"version"` + Path string `yaml:"path" json:"path"` + Latest string `yaml:"latest" json:"latest"` + NeedsUpdate bool `yaml:"needs_update" json:"needs_update"` + Category string `yaml:"category" json:"category"` } type RuntimeStatus struct { - Name string `yaml:"name"` - Installed bool `yaml:"installed"` - Version string `yaml:"version"` + Name string `yaml:"name" json:"name"` + Installed bool `yaml:"installed" json:"installed"` + Version string `yaml:"version" json:"version"` } type ScanResult struct { - System platform.SystemInfo `yaml:"system"` - Tools []ToolStatus `yaml:"tools"` - Runtimes []RuntimeStatus `yaml:"runtimes"` - ShellSetup bool `yaml:"shell_setup"` - GitConfigured bool `yaml:"git_configured"` + System platform.SystemInfo `yaml:"system" json:"system"` + Tools []ToolStatus `yaml:"tools" json:"tools"` + Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"` + ShellSetup bool `yaml:"shell_setup" json:"shell_setup"` + GitConfigured bool `yaml:"git_configured" json:"git_configured"` } var ( diff --git a/web/src/api/client.js b/web/src/api/client.js index 9affabf..6536001 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -56,6 +56,7 @@ const api = { saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }), getChatHistory: () => request('/chat/history'), clearChat: () => request('/chat/clear', { method: 'POST' }), + summarizeChat: () => request('/chat/summarize', { method: 'POST' }), sendChat: (message, stream = true, onChunk, signal) => { if (!stream) { return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index fd4899d..63cff2a 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -284,6 +284,7 @@ export default function Studio({ api }) { const [streamThinking, setStreamThinking] = useState('') const [streamToolCalls, setStreamToolCalls] = useState([]) const [loaded, setLoaded] = useState(false) + const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 }) const messagesEnd = useRef(null) const textareaRef = useRef(null) const abortRef = useRef(null) @@ -297,6 +298,11 @@ export default function Studio({ api }) { { id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() }, ]) } + setTokenInfo({ + used: data.tokens || 0, + max: data.max_tokens || 100000, + summarizeAt: data.summarize_at || 80000, + }) setLoaded(true) }).catch(() => { setMessages([ @@ -317,6 +323,28 @@ export default function Studio({ api }) { } }, [input]) + const refreshTokens = useCallback(async () => { + try { + const data = await api.getChatHistory() + setTokenInfo({ + used: data.tokens || 0, + max: data.max_tokens || 100000, + summarizeAt: data.summarize_at || 80000, + }) + } catch {} + }, [api]) + + const handleSummarize = useCallback(async () => { + setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'RĂ©sumĂ© de la conversation en cours...', time: new Date().toISOString() }]) + try { + const data = await api.summarizeChat() + setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 })) + setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation rĂ©sumĂ©e automatiquement. Le contexte a Ă©tĂ© compressĂ©.', time: new Date().toISOString() }]) + } catch (err) { + setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de rĂ©sumĂ©: ${err.message}`, time: new Date().toISOString() }]) + } + }, [api]) + const handleClear = useCallback(async () => { try { await api.clearChat() @@ -341,6 +369,7 @@ export default function Studio({ api }) { '## Commandes Studio', '', '- `/clear` - Effacer la conversation', + '- `/summarize` - RĂ©sumer la conversation prĂ©cĂ©dente', '- `/help` - Afficher cette aide', '- `/plan ` - Demander un plan structurĂ©', '- `/export` - Exporter la conversation en Markdown', @@ -359,6 +388,11 @@ export default function Studio({ api }) { return } + if (text === '/summarize') { + handleSummarize() + return + } + if (text === '/model') { api.getProviders().then(data => { const active = data.providers?.find(p => p.active) @@ -474,8 +508,9 @@ export default function Studio({ api }) { setStreamThinking('') setStreamToolCalls([]) abortRef.current = null + refreshTokens() } - }, [input, loading, api, t, handleClear, streaming]) + }, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize]) const handleStop = useCallback(() => { if (abortRef.current) { @@ -515,6 +550,18 @@ export default function Studio({ api }) {
+
+
+
= tokenInfo.summarizeAt ? 'warn' : ''}`} + style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }} + /> +
+ + {(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens + {tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'} + +