feat(onboarding): add minimax api key step and AI-powered editor scan
Some checks failed
Beta Release / beta (push) Failing after 22s
Some checks failed
Beta Release / beta (push) Failing after 22s
- 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:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
<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">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
@@ -161,28 +217,78 @@ export default function OnboardingWizard({ api, onComplete }) {
|
||||
</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' && (
|
||||
<div className="onboarding-step">
|
||||
<div className="onboarding-title">Quel \u00e9diteur utilisez-vous ?</div>
|
||||
<div className="onboarding-chips">
|
||||
{EDITOR_SUGGESTIONS.map(ed => (
|
||||
<div
|
||||
key={ed}
|
||||
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
||||
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
||||
>
|
||||
{ed}
|
||||
</div>
|
||||
))}
|
||||
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="onboarding-chips" style={{ flex: 1 }}>
|
||||
{editorList.map(ed => (
|
||||
<div
|
||||
key={ed}
|
||||
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
||||
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
||||
>
|
||||
{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>
|
||||
<input
|
||||
className="onboarding-input"
|
||||
style={{ marginTop: 12 }}
|
||||
placeholder="Autre (vim, nvim, vscode...)"
|
||||
placeholder="Autre éditeur..."
|
||||
value={answers.editor}
|
||||
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <div className="onboarding-required">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user