All checks were successful
Beta Release / beta (push) Successful in 38s
- 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 <crush@charm.land>
538 lines
20 KiB
JavaScript
538 lines
20 KiB
JavaScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
|
import { useI18n, LANGUAGES } from '../i18n'
|
|
import { getLayoutList } from '../i18n/keyboards'
|
|
|
|
const PANELS = [
|
|
{ id: 'profile', icon: User },
|
|
{ id: 'providers', icon: Brain },
|
|
{ id: 'updates', icon: RefreshCw },
|
|
{ id: 'locale', icon: Globe },
|
|
{ id: 'skills', icon: Wrench },
|
|
{ id: 'system', icon: Monitor },
|
|
]
|
|
|
|
export default function Config({ api }) {
|
|
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
|
const [activePanel, setActivePanel] = useState('profile')
|
|
const [config, setConfig] = useState(null)
|
|
const [providers, setProviders] = useState([])
|
|
const [skillList, setSkillList] = useState([])
|
|
const [updates, setUpdates] = useState([])
|
|
const [tools, setTools] = useState([])
|
|
const [checking, setChecking] = useState(false)
|
|
const [updating, setUpdating] = useState(null)
|
|
const [editProfile, setEditProfile] = useState(false)
|
|
const [editProvider, setEditProvider] = useState(null)
|
|
const [profileForm, setProfileForm] = useState({})
|
|
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
|
|
const [toast, setToast] = useState(null)
|
|
|
|
|
|
const layouts = getLayoutList()
|
|
|
|
const loadData = useCallback(() => {
|
|
api.getConfig().then(d => {
|
|
setConfig(d)
|
|
setProfileForm({
|
|
name: d.profile?.name || '',
|
|
pseudo: d.profile?.pseudo || '',
|
|
email: d.profile?.email || '',
|
|
editor: d.profile?.preferences?.editor || '',
|
|
shell: d.profile?.preferences?.shell || '',
|
|
})
|
|
|
|
}).catch(() => {})
|
|
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
|
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
|
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
|
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
|
|
}, [api])
|
|
|
|
useEffect(() => { loadData() }, [loadData])
|
|
|
|
const showToast = (msg) => {
|
|
setToast(msg)
|
|
setTimeout(() => setToast(null), 2500)
|
|
}
|
|
|
|
const handleCheckUpdates = async () => {
|
|
setChecking(true)
|
|
try {
|
|
await api.runScan()
|
|
const d = await api.getUpdates()
|
|
setUpdates(d.updates || [])
|
|
const td = await api.getTools()
|
|
setTools(td.tools || [])
|
|
showToast(t('config.upToDate'))
|
|
} catch (err) {
|
|
showToast(`${t('config.error')}: ${err.message}`)
|
|
}
|
|
setChecking(false)
|
|
}
|
|
|
|
const handleUpdateTool = async (tool) => {
|
|
setUpdating(tool)
|
|
try {
|
|
await api.runUpdate(tool)
|
|
await handleCheckUpdates()
|
|
showToast(`${tool} ✓`)
|
|
} catch (err) {
|
|
showToast(`${t('config.error')}: ${err.message}`)
|
|
}
|
|
setUpdating(null)
|
|
}
|
|
|
|
const handleUpdateAll = async () => {
|
|
setUpdating('__all__')
|
|
try {
|
|
await api.runUpdate('')
|
|
await handleCheckUpdates()
|
|
showToast(t('config.saved'))
|
|
} catch (err) {
|
|
showToast(`${t('config.error')}: ${err.message}`)
|
|
}
|
|
setUpdating(null)
|
|
}
|
|
|
|
const handleSaveProfile = async () => {
|
|
try {
|
|
await api.saveProfile(profileForm)
|
|
setEditProfile(false)
|
|
loadData()
|
|
showToast(t('config.saved'))
|
|
} catch (err) {
|
|
showToast(`${t('config.error')}: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
const handleSaveProvider = async (name) => {
|
|
const form = providerForm[name]
|
|
if (!form) return
|
|
try {
|
|
await api.saveProvider({ name, ...form })
|
|
setEditProvider(null)
|
|
loadData()
|
|
showToast(t('config.saved'))
|
|
} catch (err) {
|
|
showToast(`${t('config.error')}: ${err.message}`)
|
|
}
|
|
}
|
|
|
|
const openProviderEdit = (p) => {
|
|
setProviderForm(prev => ({
|
|
...prev,
|
|
[p.name]: {
|
|
name: p.name,
|
|
api_key: p.apiKey || '',
|
|
model: p.model || '',
|
|
base_url: p.baseURL || '',
|
|
},
|
|
}))
|
|
setEditProvider(p.name)
|
|
}
|
|
|
|
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
|
const installedCount = tools.filter(tool => tool.installed).length
|
|
const missingCount = tools.filter(tool => !tool.installed).length
|
|
|
|
return (
|
|
<div className="config-window">
|
|
{toast && <div className="config-toast">{toast}</div>}
|
|
|
|
<div className="config-tabs-bar">
|
|
{PANELS.map(p => {
|
|
const Icon = p.icon
|
|
return (
|
|
<div
|
|
key={p.id}
|
|
className={`nav-tab ${activePanel === p.id ? 'active' : ''}`}
|
|
onClick={() => setActivePanel(p.id)}
|
|
>
|
|
<span className="tab-icon"><Icon size={15} /></span>
|
|
{t(`config.panels.${p.id}`)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="config-panel-area">
|
|
<div className="config-panel-body">
|
|
{activePanel === 'profile' && (
|
|
<PanelProfile
|
|
config={config} editProfile={editProfile}
|
|
profileForm={profileForm} setProfileForm={setProfileForm}
|
|
setEditProfile={setEditProfile} handleSaveProfile={handleSaveProfile}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{activePanel === 'providers' && (
|
|
<PanelProviders
|
|
providers={providers} editProvider={editProvider}
|
|
providerForm={providerForm} setProviderForm={setProviderForm}
|
|
setEditProvider={setEditProvider} openProviderEdit={openProviderEdit}
|
|
handleSaveProvider={handleSaveProvider} api={api} loadData={loadData}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{activePanel === 'updates' && (
|
|
<PanelUpdates
|
|
updates={updates} tools={tools}
|
|
checking={checking} updating={updating}
|
|
needsUpdateCount={needsUpdateCount}
|
|
installedCount={installedCount} missingCount={missingCount}
|
|
handleCheckUpdates={handleCheckUpdates}
|
|
handleUpdateTool={handleUpdateTool}
|
|
handleUpdateAll={handleUpdateAll}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{activePanel === 'locale' && (
|
|
<PanelLocale
|
|
language={keyboard} layouts={layouts}
|
|
setLanguage={setLanguage} setKeyboard={setKeyboard}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{activePanel === 'skills' && (
|
|
<PanelSkills skillList={skillList} t={t} />
|
|
)}
|
|
{activePanel === 'system' && (
|
|
<PanelSystem api={api} t={t} />
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
|
return (
|
|
<div className="config-card">
|
|
{config?.profile && !editProfile ? (
|
|
<>
|
|
<div className="config-card-row">
|
|
<span className="config-card-label">{t('config.name')}</span>
|
|
<span className="config-card-value">{config.profile.name || '—'}</span>
|
|
</div>
|
|
<div className="config-card-row">
|
|
<span className="config-card-label">{t('config.pseudo')}</span>
|
|
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
|
|
</div>
|
|
<div className="config-card-row">
|
|
<span className="config-card-label">{t('config.email')}</span>
|
|
<span className="config-card-value">{config.profile.email || '—'}</span>
|
|
</div>
|
|
<div className="config-card-row">
|
|
<span className="config-card-label">{t('config.editor')}</span>
|
|
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
|
|
</div>
|
|
<div className="config-card-row">
|
|
<span className="config-card-label">{t('config.shell')}</span>
|
|
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
|
|
</div>
|
|
<div className="config-card-row">
|
|
<span className="config-card-label">{t('config.languages')}</span>
|
|
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
|
|
</div>
|
|
<div className="config-card-actions">
|
|
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
|
|
</div>
|
|
</>
|
|
) : editProfile ? (
|
|
<>
|
|
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
|
|
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
|
|
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
|
|
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
|
|
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
|
|
<div className="config-card-actions">
|
|
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
|
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="empty-state">{t('config.loadingProfile')}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
|
const [validating, setValidating] = useState(null)
|
|
const [validationStatus, setValidationStatus] = useState(null)
|
|
|
|
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
|
setValidating(name)
|
|
setValidationStatus(null)
|
|
try {
|
|
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
|
setValidationStatus({ provider: name, valid: true })
|
|
} catch (err) {
|
|
const msg = err.message || ''
|
|
if (msg.includes('invalid_api_key')) {
|
|
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
|
|
} else {
|
|
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
|
|
}
|
|
}
|
|
setValidating(null)
|
|
}
|
|
|
|
return (
|
|
<div className="config-providers-list">
|
|
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
|
{providers.map((p, i) => {
|
|
const isEditing = editProvider === p.name
|
|
const isValidationTarget = validationStatus?.provider === p.name
|
|
return (
|
|
<div key={i} className="config-card provider-card-v2">
|
|
<div className="provider-card-top">
|
|
<div className="provider-card-identity">
|
|
<span className="provider-card-name">{p.name}</span>
|
|
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
|
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
|
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
|
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="provider-card-form">
|
|
<div className="provider-setup-token-row">
|
|
<div className="provider-setup-token-input">
|
|
<label className="config-form-label">{t('config.apiKey')}</label>
|
|
<input
|
|
className="config-form-input"
|
|
type="password"
|
|
placeholder={t('config.tokenPlaceholder')}
|
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
|
onChange={e => {
|
|
if (!isEditing) openProviderEdit(p)
|
|
setProviderForm(prev => ({
|
|
...prev,
|
|
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
|
|
}))
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="provider-setup-token-actions">
|
|
<button
|
|
className="sm primary"
|
|
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
|
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
|
>
|
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
|
</button>
|
|
{isValidationTarget && validationStatus?.valid && (
|
|
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
|
<span className="mono">{p.model || '—'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
|
return (
|
|
<>
|
|
<div className="config-card">
|
|
<div className="config-update-controls">
|
|
<div className="config-update-stats">
|
|
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
|
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
|
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
|
</div>
|
|
<div className="config-update-buttons">
|
|
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
|
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
|
|
</button>
|
|
{needsUpdateCount > 0 && (
|
|
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
|
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{updates.length === 0 ? (
|
|
<div className="config-card">
|
|
<div className="empty-state">{t('config.noUpdates')}</div>
|
|
</div>
|
|
) : (
|
|
<div className="config-update-list">
|
|
{updates.map((u, i) => (
|
|
<div key={i} className="config-update-row">
|
|
<div className="config-update-info">
|
|
<span className="config-update-name">{u.tool}</span>
|
|
<span className="config-update-versions">
|
|
{u.needsUpdate ? (
|
|
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
|
) : (
|
|
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
{u.needsUpdate && (
|
|
<button
|
|
className="sm"
|
|
onClick={() => handleUpdateTool(u.tool)}
|
|
disabled={updating === u.tool}
|
|
>
|
|
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
|
|
return (
|
|
<div className="config-card">
|
|
<div className="config-card-group">
|
|
<span className="config-card-group-label">{t('config.language')}</span>
|
|
<div className="chip-row">
|
|
{LANGUAGES.map(lang => (
|
|
<div
|
|
key={lang.id}
|
|
className={`chip ${language === lang.id ? 'active' : ''}`}
|
|
onClick={() => setLanguage(lang.id)}
|
|
>
|
|
{lang.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="config-card-group">
|
|
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
|
<div className="chip-row">
|
|
{layouts.map(l => (
|
|
<div
|
|
key={l.id}
|
|
className={`chip ${keyboard === l.id ? 'active' : ''}`}
|
|
onClick={() => setKeyboard(l.id)}
|
|
>
|
|
{l.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PanelSkills({ skillList, t }) {
|
|
return (
|
|
<div className="config-card">
|
|
{skillList.length === 0 ? (
|
|
<div className="empty-state">
|
|
{t('config.noSkills')}
|
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
|
</div>
|
|
) : (
|
|
skillList.map((s, i) => (
|
|
<div key={i} className="config-skill-row">
|
|
<span className="config-skill-name">{s.name}</span>
|
|
<span className="badge neutral">{s.target || 'both'}</span>
|
|
<span className="config-skill-desc">{s.description}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 && <div className="config-toast">{toast}</div>}
|
|
<div className="config-card">
|
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
|
{t('config.starshipApplied')}
|
|
</div>
|
|
<button className="sm primary" onClick={handleApplyStarship}>
|
|
{t('config.applyStarship')}
|
|
</button>
|
|
</div>
|
|
<div className="config-card" style={{ marginTop: 12 }}>
|
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
|
</div>
|
|
{resetConfirm ? (
|
|
<div>
|
|
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
|
{t('config.resetConfirm')}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
|
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
|
{t('config.resetConfig')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function FormInput({ label, value, onChange, type = 'text' }) {
|
|
return (
|
|
<div className="config-form-field">
|
|
<label className="config-form-label">{label}</label>
|
|
<input
|
|
className="config-form-input"
|
|
type={type}
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|