feat(onboarding): add minimax api key step and AI-powered editor scan

- 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 <crush@charm.land>
This commit is contained in:
Augustin
2026-04-22 21:04:27 +02:00
parent b6147ddb12
commit 65df15498b
5 changed files with 214 additions and 41 deletions

View File

@@ -117,3 +117,8 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem() s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"}) 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})
}

View File

@@ -1,18 +1,22 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
) )
type Server struct { type Server struct {
config *config.MuyueConfig config *config.MuyueConfig
scanResult *scanner.ScanResult scanResult *scanner.ScanResult
mux *http.ServeMux mux *http.ServeMux
convStore *ConversationStore convStore *ConversationStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
} }
func NewServer(cfg *config.MuyueConfig) *Server { func NewServer(cfg *config.MuyueConfig) *Server {
@@ -22,6 +26,10 @@ func NewServer(cfg *config.MuyueConfig) *Server {
} }
s.scanResult = scanner.ScanSystem() s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore() s.convStore = NewConversationStore()
s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
s.routes() s.routes()
return s return s
} }
@@ -38,6 +46,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/updates", s.handleUpdates) s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall) s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan) 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/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal) s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS) s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)

View File

@@ -14,13 +14,13 @@ import (
) )
type ToolStatus struct { type ToolStatus struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Installed bool `yaml:"installed"` Installed bool `yaml:"installed"`
Version string `yaml:"version"` Version string `yaml:"version"`
Path string `yaml:"path"` Path string `yaml:"path"`
Latest string `yaml:"latest"` Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"` NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"` Category string `yaml:"category"`
} }
type RuntimeStatus struct { type RuntimeStatus struct {
@@ -30,15 +30,15 @@ type RuntimeStatus struct {
} }
type ScanResult struct { type ScanResult struct {
System platform.SystemInfo `yaml:"system"` System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"` Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"` Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"` ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"` GitConfigured bool `yaml:"git_configured"`
} }
var ( var (
cacheMu sync.RWMutex cacheMu sync.RWMutex
cacheResult *ScanResult cacheResult *ScanResult
cacheTime time.Time cacheTime time.Time
cacheTTL = 5 * time.Minute cacheTTL = 5 * time.Minute
@@ -193,6 +193,43 @@ func checkGitConfig() bool {
return true 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+`) var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func (s *ScanResult) Summary() string { func (s *ScanResult) Summary() string {

View File

@@ -22,6 +22,7 @@ const api = {
getLSP: () => request('/lsp'), getLSP: () => request('/lsp'),
getMCP: () => request('/mcp'), getMCP: () => request('/mcp'),
getUpdates: () => request('/updates'), getUpdates: () => request('/updates'),
getEditors: () => request('/editors'),
runScan: () => request('/scan', { method: 'POST' }), runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }), installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }), configureMCP: () => request('/mcp/configure', { method: 'POST' }),
@@ -73,6 +74,8 @@ const api = {
if (onChunk) onChunk(full, data) if (onChunk) onChunk(full, data)
} else if (data.thinking !== undefined || data.thinking_end) { } else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data) if (onChunk) onChunk(full, data)
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data)
} }
} catch {} } catch {}
} }

View File

@@ -1,18 +1,19 @@
import { useState, useEffect } from 'react' 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 { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards' import { getLayoutList } from '../i18n/keyboards'
const STEPS = [ const STEPS = [
{ key: 'welcome', title: 'welcome', field: null }, { key: 'welcome', title: 'welcome' },
{ key: 'name', title: 'name', field: 'name' }, { key: 'name', title: 'name' },
{ key: 'language', title: 'language', field: 'language' }, { key: 'language', title: 'language' },
{ key: 'keyboard', title: 'keyboard', field: 'keyboard' }, { key: 'keyboard', title: 'keyboard' },
{ key: 'editor', title: 'editor', field: 'editor' }, { key: 'apikey', title: 'apikey' },
{ key: 'done', title: 'done', field: null }, { 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 }) { export default function OnboardingWizard({ api, onComplete }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n() const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
@@ -21,11 +22,16 @@ export default function OnboardingWizard({ api, onComplete }) {
name: '', name: '',
language: 'fr', language: 'fr',
keyboard: 'azerty', keyboard: 'azerty',
apikey: '',
editor: '', editor: '',
}) })
const [editorList, setEditorList] = useState(BASE_EDITORS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [requiredError, setRequiredError] = useState(false) 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 current = STEPS[step]
const layouts = getLayoutList() const layouts = getLayoutList()
@@ -44,6 +50,7 @@ export default function OnboardingWizard({ api, onComplete }) {
case 'name': return answers.name.trim().length > 0 case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard case 'keyboard': return !!answers.keyboard
case 'apikey': return true
case 'editor': return true case 'editor': return true
case 'done': return true case 'done': return true
default: return true default: return true
@@ -57,7 +64,7 @@ export default function OnboardingWizard({ api, onComplete }) {
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return } 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) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
@@ -69,19 +76,68 @@ export default function OnboardingWizard({ api, onComplete }) {
} }
}, [step]) }, [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 () => { const handleSave = async () => {
setSaving(true) setSaving(true)
setError(null) setError(null)
try { try {
await api.saveProfile({ const profile = {
name: answers.name, name: answers.name,
pseudo: answers.name.split(' ')[0] || 'user', pseudo: answers.name.split(' ')[0] || 'user',
editor: answers.editor, editor: answers.editor,
}) }
if (answers.apikey.trim()) {
profile.apikey = answers.apikey
}
await api.saveProfile(profile)
await api.savePreferences({ await api.savePreferences({
language: answers.language, language: answers.language,
keyboard_layout: answers.keyboard, 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() onComplete()
} catch (err) { } catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde') setError(err.message || 'Erreur lors de la sauvegarde')
@@ -129,7 +185,7 @@ export default function OnboardingWizard({ api, onComplete }) {
{current.key === 'language' && ( {current.key === 'language' && (
<div className="onboarding-step"> <div className="onboarding-step">
<div className="onboarding-title">Quelle langue pr\u00e9f\u00e9rez-vous ?</div> <div className="onboarding-title">Quelle langue préférez-vous ?</div>
<div className="onboarding-chips"> <div className="onboarding-chips">
{LANGUAGES.map(lang => ( {LANGUAGES.map(lang => (
<div <div
@@ -161,28 +217,78 @@ export default function OnboardingWizard({ api, onComplete }) {
</div> </div>
)} )}
{current.key === 'apikey' && (
<div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div>
<div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
</div>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx"
type="password"
value={answers.apikey}
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
autoFocus
/>
{error && !keyValid && <div className="onboarding-required">{error}</div>}
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
className="sm primary"
onClick={handleValidateKey}
disabled={validating || !answers.apikey.trim()}
>
{validating ? 'Validation...' : 'Valider la clé'}
</button>
<button
className="sm ghost"
onClick={goNext}
disabled={!answers.apikey.trim()}
>
Passer
</button>
</div>
{answers.apikey.trim() && !keyValid && !error && (
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
)}
</div>
)}
{current.key === 'editor' && ( {current.key === 'editor' && (
<div className="onboarding-step"> <div className="onboarding-step">
<div className="onboarding-title">Quel \u00e9diteur utilisez-vous ?</div> <div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
<div className="onboarding-chips"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{EDITOR_SUGGESTIONS.map(ed => ( <div className="onboarding-chips" style={{ flex: 1 }}>
<div {editorList.map(ed => (
key={ed} <div
className={`chip ${answers.editor === ed ? 'active' : ''}`} key={ed}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))} className={`chip ${answers.editor === ed ? 'active' : ''}`}
> onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
{ed} >
</div> {ed}
))} </div>
))}
</div>
<button
className="sm ghost"
onClick={handleScanEditors}
disabled={scanning}
title="Détecter les éditeurs installés"
style={{ marginLeft: 8, flexShrink: 0 }}
>
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
</button>
</div> </div>
<input <input
className="onboarding-input" className="onboarding-input"
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
placeholder="Autre (vim, nvim, vscode...)" placeholder="Autre éditeur..."
value={answers.editor} value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))} onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus autoFocus
/> />
{error && <div className="onboarding-required">{error}</div>}
</div> </div>
)} )}
@@ -282,6 +388,19 @@ export default function OnboardingWizard({ api, onComplete }) {
.onboarding-required { .onboarding-required {
font-size: 12px; color: var(--error); margin-top: 4px; 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); }
}
`}</style> `}</style>
</div> </div>
) )