All checks were successful
PR Check / check (pull_request) Successful in 56s
Two user-reported pain points: 1. First-run setup proposed only MiniMax (no MiMo step). Onboarding now offers both keys side-by-side under a single "apikey" step, with per-key Validate buttons. At least one must be valid to proceed; the rest of the providers (OpenAI/Anthropic/Z.AI/Ollama) are not shown in the wizard — they're configured later via the Config tab. Active provider = MiniMax if valid, else MiMo. 2. Windows install instructions failed: Move-Item to C:\Windows requires admin. Replaced with a no-admin 4-line snippet that installs to %LOCALAPPDATA%\Muyue and calls a new subcommand `muyue install-shortcuts` to create Desktop + Start Menu .lnk files and add the install dir to the user PATH. Shortcut creation uses WScript.Shell COM via PowerShell — keeps Go binary dependency-free. Folder paths resolved through [Environment]::GetFolderPath so OneDrive/redirected profiles work too. - cmd/muyue/commands/install_shortcuts.go: new file - web/src/components/OnboardingWizard.jsx: 2-key apikey step - .gitea/workflows/ci-main.yml: updated install snippet - internal/version/version.go: 0.7.2 → 0.7.3 - CHANGELOG.md: v0.7.3 entry
532 lines
20 KiB
JavaScript
532 lines
20 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
|
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
|
|
import { useI18n, LANGUAGES } from '../i18n'
|
|
import { getLayoutList } from '../i18n/keyboards'
|
|
|
|
const STEPS = [
|
|
{ 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 BASE_EDITORS = ['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',
|
|
apikey: '',
|
|
apikey_mimo: '',
|
|
editor: '',
|
|
})
|
|
const [keyValidMimo, setKeyValidMimo] = useState(false)
|
|
const [errorMimo, setErrorMimo] = useState(null)
|
|
const [validatingMimo, setValidatingMimo] = useState(false)
|
|
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 [scanMessage, setScanMessage] = useState('')
|
|
const scanAbortRef = useRef(null)
|
|
|
|
const current = STEPS[step]
|
|
const layouts = getLayoutList()
|
|
|
|
const goNext = () => {
|
|
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 'apikey': return (keyValid || keyValidMimo) && !scanning
|
|
case 'editor': return true
|
|
case 'done': return true
|
|
default: return true
|
|
}
|
|
})()
|
|
|
|
const goPrev = () => {
|
|
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()))
|
|
}
|
|
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)
|
|
setEditorList([...new Set(detected)])
|
|
} 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, answers, editorList])
|
|
|
|
useEffect(() => {
|
|
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (current.key === 'done' && !saving) {
|
|
handleSave()
|
|
}
|
|
}, [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)
|
|
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)
|
|
}
|
|
setValidating(false)
|
|
}
|
|
|
|
const handleValidateKeyMimo = async () => {
|
|
if (!answers.apikey_mimo.trim()) return
|
|
setValidatingMimo(true)
|
|
setErrorMimo(null)
|
|
try {
|
|
await api.validateProvider({
|
|
name: 'mimo',
|
|
api_key: answers.apikey_mimo,
|
|
model: 'mimo-v2.5-pro',
|
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
|
})
|
|
setKeyValidMimo(true)
|
|
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
|
|
await api.saveProvider({
|
|
name: 'mimo',
|
|
api_key: answers.apikey_mimo,
|
|
model: 'mimo-v2.5-pro',
|
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
|
active: !keyValid,
|
|
})
|
|
} catch (err) {
|
|
setErrorMimo(err.message || 'Clé invalide')
|
|
setKeyValidMimo(false)
|
|
}
|
|
setValidatingMimo(false)
|
|
}
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
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,
|
|
})
|
|
}
|
|
if (answers.apikey_mimo.trim()) {
|
|
await api.saveProvider({
|
|
name: 'mimo',
|
|
api_key: answers.apikey_mimo,
|
|
model: 'mimo-v2.5-pro',
|
|
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
|
|
active: !answers.apikey.trim(),
|
|
})
|
|
}
|
|
onComplete()
|
|
} catch (err) {
|
|
setError(err.message || 'Erreur lors de la sauvegarde')
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="onboarding-overlay">
|
|
<div className="onboarding-card">
|
|
<div className="onboarding-header">
|
|
<Sparkles size={20} style={{ color: 'var(--accent)' }} />
|
|
<span> Muyue Setup</span>
|
|
</div>
|
|
|
|
<div className="onboarding-progress">
|
|
{STEPS.filter(s => s.key !== 'done').map(s => {
|
|
const i = STEPS.indexOf(s)
|
|
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
|
|
})}
|
|
</div>
|
|
|
|
<div className="onboarding-body">
|
|
{current.key === 'welcome' && (
|
|
<div className="onboarding-step">
|
|
<div className="onboarding-title">Bienvenue ! 👋</div>
|
|
<div className="onboarding-desc">
|
|
Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{current.key === 'name' && (
|
|
<div className="onboarding-step">
|
|
<div className="onboarding-title">Comment vous appelez-vous ?</div>
|
|
<input
|
|
className="onboarding-input"
|
|
placeholder="Votre nom..."
|
|
value={answers.name}
|
|
onChange={e => { setAnswers(a => ({ ...a, name: e.target.value })); setRequiredError(false) }}
|
|
autoFocus
|
|
/>
|
|
{requiredError && <div className="onboarding-required">Veuillez entrer votre nom</div>}
|
|
</div>
|
|
)}
|
|
|
|
{current.key === 'language' && (
|
|
<div className="onboarding-step">
|
|
<div className="onboarding-title">Quelle langue préférez-vous ?</div>
|
|
<div className="onboarding-chips">
|
|
{LANGUAGES.map(lang => (
|
|
<div
|
|
key={lang.id}
|
|
className={`chip ${answers.language === lang.id ? 'active' : ''}`}
|
|
onClick={() => setAnswers(a => ({ ...a, language: lang.id }))}
|
|
>
|
|
{lang.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{current.key === 'keyboard' && (
|
|
<div className="onboarding-step">
|
|
<div className="onboarding-title">Disposition du clavier ?</div>
|
|
<div className="onboarding-chips">
|
|
{layouts.map(l => (
|
|
<div
|
|
key={l.id}
|
|
className={`chip ${answers.keyboard === l.id ? 'active' : ''}`}
|
|
onClick={() => setAnswers(a => ({ ...a, keyboard: l.id }))}
|
|
>
|
|
{l.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{current.key === 'apikey' && (
|
|
<div className="onboarding-step">
|
|
<div className="onboarding-title">Clés API</div>
|
|
<div className="onboarding-desc">
|
|
Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
|
|
<input
|
|
className="onboarding-input"
|
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
|
|
type="password"
|
|
value={answers.apikey}
|
|
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
|
|
autoFocus
|
|
/>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<button
|
|
className="sm primary"
|
|
onClick={handleValidateKey}
|
|
disabled={validating || !answers.apikey.trim()}
|
|
>
|
|
{validating ? 'Validation...' : 'Valider MiniMax'}
|
|
</button>
|
|
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
|
|
{error && !keyValid && <span className="onboarding-required">{error}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
|
|
<input
|
|
className="onboarding-input"
|
|
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
|
|
type="password"
|
|
value={answers.apikey_mimo}
|
|
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
|
|
/>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<button
|
|
className="sm primary"
|
|
onClick={handleValidateKeyMimo}
|
|
disabled={validatingMimo || !answers.apikey_mimo.trim()}
|
|
>
|
|
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
|
|
</button>
|
|
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
|
|
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{scanning && (
|
|
<div className="onboarding-scanning" style={{ marginTop: 8 }}>
|
|
<Loader size={14} className="spin-icon" />
|
|
<span>{scanMessage}</span>
|
|
</div>
|
|
)}
|
|
{requiredError && (
|
|
<div className="onboarding-required" style={{ marginTop: 8 }}>
|
|
Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
|
|
</div>
|
|
)}
|
|
{(keyValid || keyValidMimo) && !scanning && (
|
|
<div className="onboarding-valid" style={{ marginTop: 8 }}>
|
|
Au moins une clé est valide — appuyez sur Suivant pour continuer.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{current.key === 'editor' && (
|
|
<div className="onboarding-step">
|
|
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
|
|
<div className="onboarding-desc">
|
|
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
|
|
</div>
|
|
<div className="onboarding-chips">
|
|
{editorList.map(ed => (
|
|
<div
|
|
key={ed}
|
|
className={`chip ${answers.editor === ed ? 'active' : ''}`}
|
|
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
|
|
>
|
|
{ed}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{current.key === 'done' && (
|
|
<div className="onboarding-step">
|
|
{saving ? (
|
|
<>
|
|
<div className="onboarding-title">Configuration en cours...</div>
|
|
<div className="onboarding-desc">Sauvegarde de vos préférences.</div>
|
|
</>
|
|
) : error ? (
|
|
<>
|
|
<div className="onboarding-title" style={{ color: 'var(--error)' }}>Erreur</div>
|
|
<div className="onboarding-desc" style={{ color: 'var(--error)' }}>{error}</div>
|
|
<button className="primary" style={{ alignSelf: 'flex-start', marginTop: 8 }} onClick={() => handleSave()}>Réessayer</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="onboarding-title">C'est parti ! 🚀</div>
|
|
<div className="onboarding-desc">
|
|
Votre profil est configuré. Vous pouvez toujours ajuster les paramètres dans l'onglet Configuration.
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="onboarding-footer">
|
|
{step > 0 && step < STEPS.length - 1 && (
|
|
<button className="ghost" onClick={goPrev}>
|
|
<ArrowLeft size={14} /> Précédent
|
|
</button>
|
|
)}
|
|
<div style={{ flex: 1 }} />
|
|
{step < STEPS.length - 1 && (
|
|
<button className="primary" onClick={goNext}>
|
|
Suivant <ArrowRight size={14} />
|
|
</button>
|
|
)}
|
|
{step === STEPS.length - 1 && !saving && !error && (
|
|
<button className="primary" onClick={handleSave}>
|
|
Commencer
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<style>{`
|
|
.onboarding-overlay {
|
|
position: fixed; inset: 0; z-index: 500;
|
|
background: rgba(10,10,12,0.85);
|
|
display: flex; align-items: center; justify-content: center;
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
.onboarding-card {
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
width: 480px; max-width: 90vw;
|
|
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
|
overflow: hidden;
|
|
}
|
|
.onboarding-header {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 16px 20px; font-size: 14px; font-weight: 700;
|
|
color: var(--accent); border-bottom: 1px solid var(--border);
|
|
background: var(--bg-surface);
|
|
}
|
|
.onboarding-progress {
|
|
display: flex; gap: 6px; padding: 14px 20px;
|
|
background: var(--bg-surface);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.onboarding-dot {
|
|
width: 32px; height: 4px; border-radius: 2px;
|
|
background: var(--bg-input); transition: all 0.3s;
|
|
}
|
|
.onboarding-dot.active { background: var(--accent); }
|
|
.onboarding-dot.done { background: var(--accent-dim); }
|
|
.onboarding-body { padding: 28px 24px; min-height: 200px; }
|
|
.onboarding-step { display: flex; flex-direction: column; gap: 16px; }
|
|
.onboarding-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
|
.onboarding-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; }
|
|
.onboarding-input {
|
|
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
|
|
border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary);
|
|
font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
|
|
}
|
|
.onboarding-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
|
.onboarding-chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.onboarding-footer {
|
|
display: flex; justify-content: flex-end; gap: 8px;
|
|
padding: 16px 20px; border-top: 1px solid var(--border);
|
|
background: var(--bg-surface);
|
|
}
|
|
.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;
|
|
}
|
|
.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;
|
|
}
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
)
|
|
}
|