Compare commits

...

1 Commits

Author SHA1 Message Date
Augustin
f3cb306053 refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard
All checks were successful
Beta Release / beta (push) Successful in 38s
- Config: sidebar navigation with 5 panels (Profile, AI Providers, Updates, Locale, Skills)
- Dashboard: remove duplicated system overview section, keep workflows and activity log
- New CSS for config window layout, cards, provider cards, update rows
- Add i18n panel keys (FR/EN)

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:41:25 +02:00
10 changed files with 731 additions and 499 deletions

View File

@@ -0,0 +1,157 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const maxTokensApprox = 100000
const summarizeThreshold = 80000
const charsPerToken = 4
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
}
func NewConversationStore() *ConversationStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "conversation.json")
cs := &ConversationStore{path: path}
cs.load()
return cs
}
func (cs *ConversationStore) load() {
data, err := os.ReadFile(cs.path)
if err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
if conv.Messages == nil {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
}
func (cs *ConversationStore) save() error {
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cs.path)
os.MkdirAll(dir, 0755)
return os.WriteFile(cs.path, data, 0600)
}
func (cs *ConversationStore) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
out := make([]FeedMessage, len(cs.conv.Messages))
copy(out, cs.conv.Messages)
return out
}
func (cs *ConversationStore) GetSummary() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.conv.Summary
}
func (cs *ConversationStore) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save()
}
func (cs *ConversationStore) SetSummary(summary string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Summary = summary
cs.save()
}
func (cs *ConversationStore) TrimOld(keepCount int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount {
return
}
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
cs.mu.RLock()
defer cs.mu.RUnlock()
total := utf8.RuneCountInString(cs.conv.Summary)
for _, m := range cs.conv.Messages {
total += utf8.RuneCountInString(m.Content)
}
return total / charsPerToken
}
func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > summarizeThreshold
}
func generateMsgID() string {
return time.Now().Format("20060102150405.000")
}

View File

@@ -15,6 +15,8 @@ import (
"github.com/muyue/muyue/internal/version" "github.com/muyue/muyue/internal/version"
) )
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
func writeJSON(w http.ResponseWriter, data interface{}) { func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
} }
@@ -264,18 +266,24 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
return return
} }
s.convStore.Add("user", body.Message)
if s.convStore.NeedsSummarization() {
s.autoSummarize()
}
orb, err := orchestrator.New(s.config) orb, err := orchestrator.New(s.config)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable) writeError(w, err.Error(), http.StatusServiceUnavailable)
return return
} }
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can: orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
- Create and manage development plans with step-by-step workflows - Créer et gérer des plans de développement étape par étape
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks - Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
- Track progress across multi-step tasks - Suivre la progression de tâches multi-étapes
- Suggest file modifications, code reviews, and architecture decisions - Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`) Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
if body.Stream { if body.Stream {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
@@ -285,7 +293,6 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher) flusher, canFlush := w.(http.Flusher)
chunkSize := 8
result, err := orb.Send(body.Message) result, err := orb.Send(body.Message)
if err != nil { if err != nil {
data, _ := json.Marshal(map[string]string{"error": err.Error()}) data, _ := json.Marshal(map[string]string{"error": err.Error()})
@@ -296,6 +303,9 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
return return
} }
s.convStore.Add("assistant", result)
chunkSize := 8
runes := []rune(result) runes := []rune(result)
for i := 0; i < len(runes); i += chunkSize { for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize end := i + chunkSize
@@ -323,9 +333,64 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
s.convStore.Add("assistant", result)
writeJSON(w, map[string]string{"content": result}) writeJSON(w, map[string]string{"content": result})
} }
func (s *Server) autoSummarize() {
messages := s.convStore.Get()
if len(messages) < 10 {
return
}
half := len(messages) / 2
var oldText string
for _, m := range messages[:half] {
oldText += m.Role + ": " + m.Content + "\n\n"
}
summary := s.convStore.GetSummary()
if summary != "" {
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
}
orb, err := orchestrator.New(s.config)
if err != nil {
return
}
orb.SetSystemPrompt(summarizePrompt)
result, err := orb.Send(oldText)
if err != nil {
return
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
})
}
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" { if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed) writeError(w, "PUT only", http.StatusMethodNotAllowed)

View File

@@ -12,6 +12,7 @@ type Server struct {
config *config.MuyueConfig config *config.MuyueConfig
scanResult *scanner.ScanResult scanResult *scanner.ScanResult
mux *http.ServeMux mux *http.ServeMux
convStore *ConversationStore
} }
func NewServer(cfg *config.MuyueConfig) *Server { func NewServer(cfg *config.MuyueConfig) *Server {
@@ -20,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
mux: http.NewServeMux(), mux: http.NewServeMux(),
} }
s.scanResult = scanner.ScanSystem() s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.routes() s.routes()
return s return s
} }
@@ -45,6 +47,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile) s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider) s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate) s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
} }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View File

@@ -33,6 +33,8 @@ const api = {
getTerminalSessions: () => request('/terminal/sessions'), getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }), addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }), deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
sendChat: (message, stream = true) => { sendChat: (message, stream = true) => {
if (!stream) { if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })

View File

@@ -1,9 +1,19 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n' import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards' 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 },
]
export default function Config({ api }) { export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n() const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [activePanel, setActivePanel] = useState('profile')
const [config, setConfig] = useState(null) const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([]) const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([]) const [skillList, setSkillList] = useState([])
@@ -119,14 +129,188 @@ export default function Config({ api }) {
const missingCount = tools.filter(t => !t.installed).length const missingCount = tools.filter(t => !t.installed).length
return ( return (
<div className="config-layout"> <div className="config-window">
{toast && <div className="config-toast">{toast}</div>} {toast && <div className="config-toast">{toast}</div>}
<div className="config-section"> <div className="config-sidebar">
<div className="config-section-title">{t('config.systemUpdates')}</div> {PANELS.map(p => {
<div className="config-actions-row"> const Icon = p.icon
return (
<div
key={p.id}
className={`config-sidebar-item ${activePanel === p.id ? 'active' : ''}`}
onClick={() => setActivePanel(p.id)}
>
<Icon size={16} />
<span>{t(`config.panels.${p.id}`)}</span>
</div>
)
})}
</div>
<div className="config-panel-area">
<div className="config-panel-header">
<h2 className="config-panel-title">{t(`config.panels.${activePanel}`)}</h2>
</div>
<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} />
)}
</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 }) {
return (
<div className="config-providers-list">
{providers.map((p, i) => (
<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.active && <span className="badge accent">{t('config.active')}</span>}
</div>
<div className="provider-card-actions">
{editProvider !== p.name && (
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
)}
{!p.active && editProvider !== p.name && (
<button className="sm" onClick={async () => {
await api.saveProvider({ name: p.name, active: true })
loadData()
}}>{t('config.activate')}</button>
)}
</div>
</div>
{editProvider !== p.name ? (
<div className="provider-card-meta">
<span className="mono">{p.model || '—'}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
</div>
) : (
<div className="provider-card-form">
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
<div className="config-card-actions">
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
</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}> <button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? t('config.checking') : t('config.checkUpdates')} {checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
</button> </button>
{needsUpdateCount > 0 && ( {needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}> <button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
@@ -134,14 +318,13 @@ export default function Config({ api }) {
</button> </button>
)} )}
</div> </div>
<div className="config-stats"> </div>
<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>
{updates.length === 0 ? ( {updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div> <div className="empty-state">{t('config.noUpdates')}</div>
</div>
) : ( ) : (
<div className="config-update-list"> <div className="config-update-list">
{updates.map((u, i) => ( {updates.map((u, i) => (
@@ -169,82 +352,15 @@ export default function Config({ api }) {
))} ))}
</div> </div>
)} )}
</div> </>
)
}
<div className="config-section"> function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
<div className="config-section-title"> return (
{t('config.profile')} <div className="config-card">
<button className="ghost sm" onClick={() => setEditProfile(!editProfile)}> <div className="config-card-group">
{editProfile ? t('config.cancel') : t('config.editProfile')} <span className="config-card-group-label">{t('config.language')}</span>
</button>
</div>
{config?.profile && !editProfile ? (
<div>
<FieldRow label={t('config.name')} value={config.profile.name} />
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
<FieldRow label={t('config.email')} value={config.profile.email} />
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
</div>
) : editProfile ? (
<div className="config-form">
<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 }))} />
<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-form-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</div>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => (
<div key={i} className="provider-card">
<div className="provider-info">
<div className="provider-name">
{p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
{editProvider !== p.name ? (
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
{!p.active && (
<button className="sm" onClick={async () => {
await api.saveProvider({ name: p.name, active: true })
loadData()
}}>{t('config.activate')}</button>
)}
</div>
) : (
<div className="config-form" style={{ marginTop: 8 }}>
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
<div className="config-form-actions">
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.language')}</div>
<div className="chip-row"> <div className="chip-row">
{LANGUAGES.map(lang => ( {LANGUAGES.map(lang => (
<div <div
@@ -257,9 +373,8 @@ export default function Config({ api }) {
))} ))}
</div> </div>
</div> </div>
<div className="config-card-group">
<div className="config-section"> <span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="chip-row"> <div className="chip-row">
{layouts.map(l => ( {layouts.map(l => (
<div <div
@@ -272,9 +387,13 @@ export default function Config({ api }) {
))} ))}
</div> </div>
</div> </div>
</div>
)
}
<div className="config-section"> function PanelSkills({ skillList, t }) {
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div> return (
<div className="config-card">
{skillList.length === 0 ? ( {skillList.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
{t('config.noSkills')} {t('config.noSkills')}
@@ -290,25 +409,15 @@ export default function Config({ api }) {
)) ))
)} )}
</div> </div>
</div>
)
}
function FieldRow({ label, value }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
</div>
) )
} }
function FormInput({ label, value, onChange, type = 'text' }) { function FormInput({ label, value, onChange, type = 'text' }) {
return ( return (
<div className="field-row"> <div className="config-form-field">
<span className="field-label">{label}</span> <label className="config-form-label">{label}</label>
<input <input
className="config-input" className="config-form-input"
type={type} type={type}
value={value} value={value}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}

View File

@@ -1,13 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) { export default function Dashboard({ api, onRescan }) {
const { t, layout } = useI18n() const { t, layout } = useI18n()
const [notifications, setNotifications] = useState([]) const [notifications, setNotifications] = useState([])
const installed = tools.filter(tool => tool.installed).length
const total = tools.length
const addNotif = (text, type) => { const addNotif = (text, type) => {
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev]) setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
} }
@@ -16,35 +13,6 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
<div className="dashboard-layout"> <div className="dashboard-layout">
<div className="dashboard-content"> <div className="dashboard-content">
<div className="dashboard-grid"> <div className="dashboard-grid">
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
{total > 0 && (
<span className="badge info">{installed}/{total}</span>
)}
</div>
{tools.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
<div className="tools-compact">
{tools.map((tool, i) => {
const name = tool.name || tool.Name
const ver = extractVersion(tool.Version || tool.version)
return (
<div key={i} className="tool-compact-row">
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
{tool.installed ? '\u2713' : '\u2717'}
</span>
<span className="tool-compact-name">{name}</span>
{ver && <span className="tool-compact-ver">{ver}</span>}
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
</div>
)
})}
</div>
)}
</div>
<div className="dashboard-section"> <div className="dashboard-section">
<div className="dashboard-section-header"> <div className="dashboard-section-header">
<div className="dashboard-section-title">{t('studio.workflows')}</div> <div className="dashboard-section-title">{t('studio.workflows')}</div>
@@ -92,9 +60,3 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
</div> </div>
) )
} }
function extractVersion(s) {
if (!s) return ''
const m = s.match(/\d+\.\d+\.\d+/)
return m ? m[0] : s.slice(0, 12)
}

View File

@@ -1,54 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
const MSG_ID = () => Math.random().toString(36).slice(2, 10)
function parsePlanBlocks(text) {
const plans = []
const regex = /(?:^|\n)(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN[^\]]*\]?:?\s*(.*?)(?=\n(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN|\n## |\n### |\n$)/gis
const matches = text.matchAll(regex)
for (const m of matches) {
plans.push({ id: MSG_ID(), title: m[1].trim(), content: m[0].trim() })
}
if (plans.length === 0 && /plan|workflow/i.test(text)) {
const lines = text.split('\n').filter(l => /^\s*[-*]\s|^\s*\d+\.\s/.test(l))
if (lines.length > 0) {
plans.push({ id: MSG_ID(), title: text.split('\n')[0].slice(0, 80), content: text.trim() })
}
}
return plans
}
function parseAgentMentions(text) {
const agents = new Set()
const names = ['crush', 'claude', 'claude code', 'ollama', 'copilot', 'cursor', 'agent']
for (const name of names) {
if (new RegExp('\\b' + name + '\\b', 'i').test(text)) {
agents.add(name)
}
}
return [...agents]
}
function parseSteps(text) {
const steps = []
const lines = text.split('\n')
for (const line of lines) {
const match = line.match(/^\s*(\d+)[.)]\s+(.+)/)
if (match) {
steps.push({ num: match[1], text: match[2].trim() })
}
const bulletMatch = line.match(/^\s*[-*]\s+(.+)/)
if (bulletMatch) {
steps.push({ num: String(steps.length + 1), text: bulletMatch[1].trim() })
}
}
return steps
}
function renderContent(text) { function renderContent(text) {
const parts = [] const parts = []
let i = 0
const codeBlockRegex = /(```[\s\S]*?```)/g const codeBlockRegex = /(```[\s\S]*?```)/g
let match let match
let lastIndex = 0 let lastIndex = 0
@@ -70,7 +24,7 @@ function renderContent(text) {
} }
function formatText(text) { function formatText(text) {
let html = text return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>') .replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
@@ -78,25 +32,41 @@ function formatText(text) {
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>') .replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>') .replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>') .replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
return html
} }
function MessageBubble({ msg }) { function FeedItem({ msg }) {
const { t } = useI18n() const isUser = msg.role === 'user'
const [expanded, setExpanded] = useState(null) const isSystem = msg.role === 'system'
const plans = msg.role === 'ai' ? parsePlanBlocks(msg.content) : []
const steps = msg.role === 'ai' ? parseSteps(msg.content) : [] const roleLabel = isUser ? null : isSystem ? null : (
const agents = msg.role === 'ai' ? parseAgentMentions(msg.content) : [] <div className="feed-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div>
)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
if (isSystem) {
return (
<div className="feed-item system">
<div className="feed-system-badge" />
<div className="feed-system-text">{msg.content}</div>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)
}
return ( return (
<div className={`studio-msg ${msg.role}`}> <div className={`feed-item ${msg.role}`}>
{msg.role === 'ai' && ( {roleLabel}
<div className="studio-msg-avatar"> <div className="feed-body">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> <div className="feed-header">
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div> </div>
)} <div className="feed-content">
<div className="studio-msg-body">
<div className="studio-msg-content">
{renderContent(msg.content).map((part, i) => {renderContent(msg.content).map((part, i) =>
part.type === 'code' ? ( part.type === 'code' ? (
<div key={i} className="studio-code-block"> <div key={i} className="studio-code-block">
@@ -108,53 +78,24 @@ function MessageBubble({ msg }) {
) )
)} )}
</div> </div>
{msg.role === 'ai' && (plans.length > 0 || agents.length > 0) && (
<div className="studio-msg-meta">
{plans.map(plan => (
<div key={plan.id} className="studio-plan-chip" onClick={() => setExpanded(expanded === plan.id ? null : plan.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{plan.title.slice(0, 60)}
<span className="studio-expand-icon">{expanded === plan.id ? '\u25B2' : '\u25BC'}</span>
</div>
))}
{agents.map(agent => (
<span key={agent} className="studio-agent-tag">
{agent}
</span>
))}
</div>
)}
{expanded && plans.find(p => p.id === expanded) && (
<div className="studio-plan-detail">
<div className="studio-plan-detail-header">{t('studio.planDetail')}</div>
{steps.length > 0 && (
<div className="studio-steps">
{steps.map(step => (
<div key={step.num} className="studio-step">
<span className="studio-step-num">{step.num}</span>
<span className="studio-step-text">{step.text}</span>
</div>
))}
</div>
)}
<div className="studio-plan-raw">
<pre>{plans.find(p => p.id === expanded).content}</pre>
</div>
</div>
)}
</div> </div>
</div> </div>
) )
} }
function StreamingMessage({ content }) { function StreamingItem({ content }) {
return ( return (
<div className="studio-msg ai"> <div className="feed-item assistant">
<div className="studio-msg-avatar"> <div className="feed-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div> </div>
<div className="studio-msg-body"> <div className="feed-body">
<div className="studio-msg-content"> <div className="feed-header">
<span className="feed-role">IA</span>
</div>
<div className="feed-content">
{renderContent(content).map((part, i) => {renderContent(content).map((part, i) =>
part.type === 'code' ? ( part.type === 'code' ? (
<div key={i} className="studio-code-block"> <div key={i} className="studio-code-block">
@@ -165,121 +106,41 @@ function StreamingMessage({ content }) {
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} /> <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
) )
)} )}
</div>
<span className="studio-cursor" /> <span className="studio-cursor" />
</div> </div>
</div> </div>
)
}
function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
const { t } = useI18n()
const [tab, setTab] = useState('plans')
const allPlans = []
const allAgents = new Set()
const activities = []
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role === 'ai') {
const plans = parsePlanBlocks(msg.content)
for (const plan of plans) {
if (!allPlans.find(p => p.title === plan.title)) {
allPlans.push({ ...plan, msgIndex: i })
}
}
parseAgentMentions(msg.content).forEach(a => allAgents.add(a))
}
activities.push({ role: msg.role, content: msg.content.slice(0, 100), time: msg.time })
}
const tabs = [
{ id: 'plans', label: t('studio.plans'), count: allPlans.length },
{ id: 'agents', label: t('studio.agents'), count: allAgents.size },
{ id: 'activity', label: t('studio.activity'), count: activities.length },
]
return (
<div className="studio-context">
<div className="studio-context-tabs">
{tabs.map(t2 => (
<div
key={t2.id}
className={`studio-context-tab ${tab === t2.id ? 'active' : ''}`}
onClick={() => setTab(t2.id)}
>
{t2.label}
{t2.count > 0 && <span className="studio-tab-count">{t2.count}</span>}
</div>
))}
</div>
<div className="studio-context-body">
{tab === 'plans' && (
allPlans.length > 0 ? (
<div className="studio-plan-list">
{allPlans.map(plan => (
<div
key={plan.id}
className={`studio-plan-item ${selectedPlan === plan.id ? 'active' : ''}`}
onClick={() => onSelectPlan(selectedPlan === plan.id ? null : plan.id)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<div className="studio-plan-item-text">{plan.title}</div>
<span className="studio-plan-item-badge">{parseSteps(plan.content).length} {t('studio.steps')}</span>
</div>
))}
</div>
) : (
<div className="studio-empty">{t('studio.noPlansYet')}</div>
)
)}
{tab === 'agents' && (
allAgents.size > 0 ? (
<div className="studio-agent-list">
{[...allAgents].map(agent => (
<div key={agent} className="studio-agent-item">
<div className="studio-agent-dot" />
<span className="studio-agent-name">{agent}</span>
<span className="badge info">{t('studio.mentioned')}</span>
</div>
))}
</div>
) : (
<div className="studio-empty">{t('studio.noAgentsYet')}</div>
)
)}
{tab === 'activity' && (
<div className="studio-activity-list">
{activities.map((act, i) => (
<div key={i} className="studio-activity-item">
<div className={`studio-activity-dot ${act.role}`} />
<div className="studio-activity-text">
{act.role === 'user' ? t('studio.you') + ': ' : 'AI: '}
{act.content}{act.content.length >= 100 ? '...' : ''}
</div>
</div>
))}
</div>
)}
</div>
</div> </div>
) )
} }
export default function Studio({ api }) { export default function Studio({ api }) {
const { t } = useI18n() const { t } = useI18n()
const [messages, setMessages] = useState([ const [messages, setMessages] = useState([])
{ id: MSG_ID(), role: 'ai', content: t('studio.welcomeNew'), time: new Date() },
])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('') const [streaming, setStreaming] = useState('')
const [selectedPlan, setSelectedPlan] = useState(null) const [loaded, setLoaded] = useState(false)
const [showContext, setShowContext] = useState(true)
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
useEffect(() => {
api.getChatHistory().then(data => {
if (data.messages && data.messages.length > 0) {
setMessages(data.messages)
} else {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
}
setLoaded(true)
}).catch(() => {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
setLoaded(true)
})
}, [])
useEffect(() => { useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming]) }, [messages, streaming])
@@ -291,11 +152,26 @@ export default function Studio({ api }) {
} }
}, [input]) }, [input])
const handleClear = useCallback(async () => {
try {
await api.clearChat()
setMessages([
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
])
} catch {}
}, [api, t])
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (!input.trim() || loading) return if (!input.trim() || loading) return
const text = input.trim() const text = input.trim()
setInput('') setInput('')
const userMsg = { id: MSG_ID(), role: 'user', content: text, time: new Date() }
if (text === '/clear') {
handleClear()
return
}
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
setMessages(prev => [...prev, userMsg]) setMessages(prev => [...prev, userMsg])
setLoading(true) setLoading(true)
setStreaming('') setStreaming('')
@@ -307,14 +183,24 @@ export default function Studio({ api }) {
}).catch(() => {}) }).catch(() => {})
const finalContent = accumulated || t('studio.noResponse') const finalContent = accumulated || t('studio.noResponse')
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }]) setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}])
} catch (err) { } catch (err) {
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: `${t('studio.error')}: ${err.message}`, time: new Date() }]) setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: `${t('studio.error')}: ${err.message}`,
time: new Date().toISOString(),
}])
} finally { } finally {
setLoading(false) setLoading(false)
setStreaming('') setStreaming('')
} }
}, [input, loading, api, t]) }, [input, loading, api, t, handleClear])
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -323,22 +209,35 @@ export default function Studio({ api }) {
} }
} }
if (!loaded) {
return ( return (
<div className="studio-layout"> <div className="studio-feed-layout">
<div className="studio-chat-area"> <div className="studio-feed">
<div className="studio-messages"> <div className="feed-loading">
{messages.map(msg => ( <div className="studio-thinking"><span /><span /><span /></div>
<MessageBubble key={msg.id} msg={msg} />
))}
{streaming && <StreamingMessage content={streaming} />}
{loading && !streaming && (
<div className="studio-msg ai">
<div className="studio-msg-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div> </div>
<div className="studio-msg-body"> </div>
<div className="studio-thinking"> </div>
<span /><span /><span /> )
}
return (
<div className="studio-feed-layout">
<div className="studio-feed">
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{streaming && <StreamingItem content={streaming} />}
{loading && !streaming && (
<div className="feed-item assistant">
<div className="feed-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div>
<div className="feed-body">
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div> </div>
</div> </div>
</div> </div>
@@ -368,26 +267,9 @@ export default function Studio({ api }) {
</button> </button>
</div> </div>
<div className="studio-input-hint"> <div className="studio-input-hint">
{t('studio.inputHint')} {t('studio.inputHint')} &middot; /clear
</div> </div>
</div> </div>
</div> </div>
<div className={`studio-sidebar ${showContext ? 'open' : ''}`}>
<div className="studio-sidebar-header">
<span>{t('studio.context')}</span>
<button className="ghost sm studio-sidebar-toggle" onClick={() => setShowContext(!showContext)}>
{showContext ? '\u203A' : '\u2039'}
</button>
</div>
{showContext && (
<ContextPanel
messages={messages}
selectedPlan={selectedPlan}
onSelectPlan={setSelectedPlan}
/>
)}
</div>
</div>
) )
} }

View File

@@ -113,6 +113,13 @@ const en = {
}, },
config: { config: {
panels: {
profile: 'Profile',
providers: 'AI Providers',
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
},
profile: 'Profile', profile: 'Profile',
name: 'Name', name: 'Name',
pseudo: 'Pseudo', pseudo: 'Pseudo',
@@ -155,7 +162,7 @@ const en = {
version: 'Version', version: 'Version',
installed: 'Installed', installed: 'Installed',
missing: 'Missing', missing: 'Missing',
editProfile: 'Edit profile', editProfile: 'Edit',
cancel: 'Cancel', cancel: 'Cancel',
editProvider: 'Configure', editProvider: 'Configure',
}, },

View File

@@ -113,6 +113,13 @@ const fr = {
}, },
config: { config: {
panels: {
profile: 'Profil',
providers: 'Fournisseurs IA',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9tences',
},
profile: 'Profil', profile: 'Profil',
name: 'Nom', name: 'Nom',
pseudo: 'Pseudo', pseudo: 'Pseudo',
@@ -155,7 +162,7 @@ const fr = {
version: 'Version', version: 'Version',
installed: 'Install\u00e9', installed: 'Install\u00e9',
missing: 'Manquant', missing: 'Manquant',
editProfile: 'Modifier le profil', editProfile: 'Modifier',
editProvider: 'Configurer', editProvider: 'Configurer',
cancel: 'Annuler', cancel: 'Annuler',
}, },

View File

@@ -407,34 +407,93 @@ input::placeholder { color: var(--text-disabled); }
display: flex; justify-content: flex-end; gap: 8px; display: flex; justify-content: flex-end; gap: 8px;
} }
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; position: relative; } .config-window { display: flex; height: 100%; overflow: hidden; }
.config-section { margin-bottom: 28px; }
.config-section-title { .config-sidebar {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; width: 180px; background: var(--bg-surface); border-right: 1px solid var(--border);
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; padding: 12px 8px; gap: 2px; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between; overflow-y: auto;
} }
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; } .config-sidebar-item {
.field-row:last-child { border-bottom: none; } display: flex; align-items: center; gap: 10px; padding: 9px 12px;
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; } border-radius: var(--radius); font-size: 13px; font-weight: 500;
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; } color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
.field-value.empty { color: var(--text-disabled); font-style: italic; } user-select: none;
.config-input { flex: 1; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; color: var(--text-primary); font-size: 13px; outline: none; font-family: var(--font-mono); } }
.config-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); } .config-sidebar-item:hover { background: var(--bg-card); color: var(--text-primary); }
.config-form-actions { display: flex; gap: 8px; padding: 12px 0 0 152px; } .config-sidebar-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
.config-actions-row { display: flex; gap: 8px; margin-bottom: 12px; } .config-sidebar-item svg { flex-shrink: 0; }
.config-stats { display: flex; gap: 8px; margin-bottom: 12px; }
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.config-panel-header {
padding: 20px 28px 0; flex-shrink: 0;
}
.config-panel-title {
font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 0;
}
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
.config-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px 24px; margin-bottom: 16px;
}
.config-card-row {
display: flex; align-items: center; padding: 10px 0;
border-bottom: 1px solid var(--border); gap: 16px;
}
.config-card-row:last-of-type { border-bottom: none; }
.config-card-label { width: 130px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.config-card-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.config-card-value.mono { font-family: var(--font-mono); }
.config-card-value:not(.mono)[style*="—"] { color: var(--text-disabled); font-style: italic; }
.config-card-actions { display: flex; gap: 8px; padding-top: 16px; }
.config-form-field { margin-bottom: 14px; }
.config-form-label { display: block; font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.3px; }
.config-form-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 12px; color: var(--text-primary);
font-size: 13px; font-family: var(--font-mono); outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.config-form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
.config-card-group { margin-bottom: 20px; }
.config-card-group:last-child { margin-bottom: 0; }
.config-card-group-label { display: block; font-size: 11px; font-weight: 700; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.config-providers-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card-v2 {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 20px; transition: border-color 0.2s;
}
.provider-card-v2:hover { border-color: var(--accent-dim); }
.provider-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.provider-card-identity { display: flex; align-items: center; gap: 10px; }
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.config-update-controls {
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.config-update-stats { display: flex; gap: 8px; }
.config-update-buttons { display: flex; gap: 8px; }
.config-update-list { display: flex; flex-direction: column; gap: 2px; } .config-update-list { display: flex; flex-direction: column; gap: 2px; }
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); } .config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-radius: var(--radius); background: var(--bg-card); border: 1px solid var(--border); margin-bottom: 6px; }
.config-update-row:hover { background: var(--bg-card); } .config-update-row:hover { border-color: var(--accent-dim); }
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; } .config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; } .config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); } .config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row:last-child { border-bottom: none; } .config-skill-row:last-child { border-bottom: none; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; } .config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; } .chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast { .config-toast {
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg); background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
@@ -442,15 +501,8 @@ input::placeholder { color: var(--text-disabled); }
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3); box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
} }
.provider-card { .spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); .mono { font-family: var(--font-mono); }
padding: 14px 16px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between;
transition: border-color 0.2s;
}
.provider-card:hover { border-color: var(--accent-dim); }
.provider-info { display: flex; flex-direction: column; gap: 4px; }
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
@@ -480,11 +532,8 @@ input::placeholder { color: var(--text-disabled); }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; } .dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
.dashboard-section { .dashboard-section {
background: var(--bg-card); background: var(--bg-card); border: 1px solid var(--border);
border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
border-radius: var(--radius-lg);
padding: 20px;
transition: border-color 0.2s;
} }
.dashboard-section:hover { border-color: var(--accent-dim); } .dashboard-section:hover { border-color: var(--accent-dim); }
.dashboard-section.full-width { grid-column: 1 / -1; } .dashboard-section.full-width { grid-column: 1 / -1; }
@@ -498,19 +547,6 @@ input::placeholder { color: var(--text-disabled); }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; } .dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-tools { padding: 0; }
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
.tool-compact-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; border-radius: var(--radius);
font-size: 13px; transition: background 0.1s;
}
.tool-compact-row:hover { background: var(--bg-card); }
.badge.sm { padding: 1px 5px; font-size: 10px; }
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
.dashboard-notifications { padding: 0; } .dashboard-notifications { padding: 0; }
.notif-row { .notif-row {
display: flex; align-items: flex-start; gap: 12px; display: flex; align-items: flex-start; gap: 12px;