Compare commits

...

1 Commits

Author SHA1 Message Date
Augustin
11417d3ea7 feat(web): add i18n support with FR/EN locales and keyboard layout awareness
All checks were successful
Beta Release / beta (push) Successful in 36s
Add full internationalization system with React context, French/English
translations, and AZERTY/QWERTY keyboard layout support. Dashboard now
uses a tabbed layout (Tools, Notifications, Workflows). Config page exposes
language and keyboard preferences persisted via new /api/preferences endpoint.

💕 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 21:48:36 +02:00
15 changed files with 713 additions and 186 deletions

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"os/exec"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner"
@@ -174,6 +175,36 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Language string `json:"language"`
KeyboardLayout string `json:"keyboard_layout"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Language != "" {
s.config.Profile.Preferences.Language = body.Language
}
if body.KeyboardLayout != "" {
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)

View File

@@ -35,6 +35,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
}
@@ -42,7 +43,7 @@ func (s *Server) routes() {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)

View File

@@ -15,12 +15,14 @@ type Profile struct {
Email string `yaml:"email"`
Languages []string `yaml:"languages"`
Preferences struct {
Editor string `yaml:"editor"`
Shell string `yaml:"shell"`
Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"`
Editor string `yaml:"editor"`
Shell string `yaml:"shell"`
Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"`
Language string `yaml:"language"`
KeyboardLayout string `yaml:"keyboard_layout"`
} `yaml:"preferences"`
}
@@ -179,6 +181,8 @@ func Default() *MuyueConfig {
cfg.Profile.Preferences.AutoUpdate = true
cfg.Profile.Preferences.CheckOnStart = true
cfg.Profile.Preferences.Theme = "charm"
cfg.Profile.Preferences.Language = "fr"
cfg.Profile.Preferences.KeyboardLayout = "azerty"
cfg.AI.Providers = []AIProvider{
{

View File

@@ -25,6 +25,7 @@ const api = {
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
}

View File

@@ -1,24 +1,26 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
const TABS = [
{ id: 'dash', label: 'Dashboard', icon: '\u25A0' },
{ id: 'studio', label: 'Studio', icon: '\u27E8\u27E9' },
{ id: 'shell', label: 'Shell', icon: '$' },
{ id: 'config', label: 'Config', icon: '\u2699' },
]
export default function App() {
const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date())
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' },
{ id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' },
{ id: 'shell', label: t('tabs.shell'), icon: '$' },
{ id: 'config', label: t('tabs.config'), icon: '\u2699' },
], [t])
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {})
@@ -35,10 +37,16 @@ export default function App() {
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
const map = { '1': 'dash', '2': 'studio', '3': 'shell', '4': 'config' }
if (map[e.key]) {
if (!e.ctrlKey && !e.metaKey) return
const map = {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'config',
}
if (map[e.code]) {
e.preventDefault()
setActiveTab(map[e.key])
setActiveTab(map[e.code])
}
}
window.addEventListener('keydown', onKey)
@@ -50,6 +58,25 @@ export default function App() {
const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(t => t.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
}), [layout, t])
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
@@ -86,28 +113,43 @@ export default function App() {
<div className="header-spacer" />
<div className="header-indicators">
<span className={`indicator ${installed > 0 ? 'ok' : 'off'}`} title={`${installed} tools installed`} />
<span className={`indicator ${hasUpdates ? 'warn' : 'ok'}`} title={hasUpdates ? 'Updates available' : 'Up to date'} />
<span
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
title={t('header.toolsInstalled', { count: installed })}
/>
<span
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
/>
</div>
<span className="header-clock">
{clock.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span>
</header>
<main className="content fade-in" key={activeTab}>
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
{renderContent()}
</main>
<footer className="statusbar">
<div className="statusbar-left">
<span>Press 1-4 to switch tabs</span>
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div>
<div className="statusbar-right">
{hasUpdates && <span style={{ color: 'var(--warning)' }}>Updates available</span>}
<span>v{info.version || '...'}</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>
{layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
</span>
</div>
</footer>
</div>
)
}
function FooterShortcuts({ shortcuts }) {
return shortcuts.map((s, i) => (
<span key={i} className="statusbar-shortcut">
<kbd>{s.keys}</kbd> {s.desc}
</span>
))
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react'
import { getThemeNames, applyTheme, getTheme } from '../themes'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n()
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
@@ -14,6 +17,7 @@ export default function Config({ api }) {
}, [])
const themes = getThemeNames()
const layouts = getLayoutList()
const handleThemeChange = (themeId) => {
applyTheme(getTheme(themeId))
@@ -30,35 +34,65 @@ export default function Config({ api }) {
return (
<div className="config-layout">
<div className="config-section">
<div className="config-section-title">Profile</div>
<div className="config-section-title">{t('config.profile')}</div>
{config?.profile ? (
<div>
<FieldRow label="Name" value={config.profile.name} />
<FieldRow label="Pseudo" value={config.profile.pseudo} />
<FieldRow label="Email" value={config.profile.email} />
<FieldRow label="Editor" value={config.profile.preferences?.editor} />
<FieldRow label="Shell" value={config.profile.preferences?.shell} />
<FieldRow label="Default AI" value={config.profile.preferences?.defaultAI} />
<FieldRow label="Languages" value={config.profile.languages?.join(', ')} />
<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.defaultAi')} value={config.profile.preferences?.defaultAI} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
</div>
) : (
<div className="empty-state">Loading profile...</div>
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
<div className="config-section">
<div className="config-section-title">AI Providers</div>
<div className="config-section-title">{t('config.language')}</div>
<div className="actions-stack">
{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-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="actions-stack">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${keyboard === l.id ? 'active' : ''}`}
onClick={() => setKeyboard(l.id)}
>
{l.name}
</div>
))}
</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 }}>Active</span>}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? 'Key configured' : 'No key'}
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
</div>
</div>
@@ -67,32 +101,32 @@ export default function Config({ api }) {
</div>
<div className="config-section">
<div className="config-section-title">Theme</div>
<div className="config-section-title">{t('config.theme')}</div>
<div className="theme-picker">
{themes.map(t => (
{themes.map(th => (
<div
key={t.id}
className={`theme-swatch ${currentTheme === t.id ? 'active' : ''}`}
style={{ background: themeColors[t.id] || '#FF0033' }}
onClick={() => handleThemeChange(t.id)}
title={t.name}
key={th.id}
className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`}
style={{ background: themeColors[th.id] || '#FF0033' }}
onClick={() => handleThemeChange(th.id)}
title={th.name}
/>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">Skills ({skillList.length})</div>
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
{skillList.length === 0 ? (
<div className="empty-state">
No skills installed.
<span style={{ fontFamily: 'var(--font-mono)' }}>Run muyue skills init</span>
{t('config.noSkills')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
</div>
) : (
skillList.map((s, i) => (
<div key={i} className="tool-row">
<span className="tool-name">{s.name}</span>
<span className={`badge neutral`}>{s.target || 'both'}</span>
<span className="badge neutral">{s.target || 'both'}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span>
</div>
))
@@ -106,7 +140,7 @@ function FieldRow({ label, value }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || 'Not set'}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || ''}</span>
</div>
)
}

View File

@@ -1,126 +1,101 @@
import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) {
const [installing, setInstalling] = useState(false)
const [log, setLog] = useState([])
const { t, layout } = useI18n()
const [activeSection, setActiveSection] = useState('tools')
const [notifications, setNotifications] = useState([])
const installed = tools.filter(t => t.installed).length
const installed = tools.filter(tool => tool.installed).length
const total = tools.length
const pct = total > 0 ? Math.round((installed / total) * 100) : 0
const missing = tools.filter(t => !t.installed).map(t => t.name || t.Name)
const handleInstall = async () => {
if (missing.length === 0) return
setInstalling(true)
addLog(`Installing ${missing.length} tools...`, 'info')
try {
await api.installTools(missing)
addLog('Install started. Rescanning...', 'ok')
await api.runScan()
const data = await api.getTools()
onRescan(data.tools || [])
addLog('Done.', 'ok')
} catch (err) {
addLog(err.message, 'error')
}
setInstalling(false)
const addNotif = (text, type) => {
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
}
const handleScan = async () => {
addLog('Scanning system...', 'info')
await api.runScan()
const data = await api.getTools()
onRescan(data.tools || [])
addLog('Scan complete.', 'ok')
}
const handleCheckUpdates = async () => {
const data = await api.getUpdates().catch(() => ({ updates: [] }))
const count = (data.updates || []).filter(u => u.needsUpdate).length
addLog(count > 0 ? `${count} updates available.` : 'All tools up to date.', count > 0 ? 'warn' : 'ok')
}
const addLog = (text, type) => setLog(prev => [...prev, { text, type, id: Date.now() }])
const sections = [
{ id: 'tools', label: t('dashboard.systemOverview') },
{ id: 'notifications', label: t('dashboard.activityLog') },
{ id: 'workflows', label: t('studio.workflows') },
]
return (
<div className="grid-2">
<div style={{ overflow: 'auto' }}>
<div className="card">
<div className="card-header">
System Overview &mdash; {installed}/{total} tools ({pct}%)
<div className="dashboard-layout">
<div className="dashboard-tabs">
{sections.map(s => (
<div
key={s.id}
className={`dashboard-tab ${activeSection === s.id ? 'active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.label}
{s.id === 'tools' && total > 0 && (
<span className="tab-count">{installed}/{total}</span>
)}
{s.id === 'notifications' && notifications.length > 0 && (
<span className="tab-count warn">{notifications.length}</span>
)}
</div>
<div className="progress" style={{ marginBottom: 16 }}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div>
{tools.map((t, i) => {
const name = t.name || t.Name
const ver = extractVersion(t.Version || t.version)
return (
<div key={i} className="tool-row">
<span className={`badge ${t.installed ? 'ok' : 'error'}`}>
{t.installed ? 'Installed' : 'Missing'}
</span>
<span className="tool-name">{name}</span>
{ver && <span className="tool-version">{ver}</span>}
</div>
)
})}
</div>
</div>
))}
</div>
<div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="card">
<div className="card-header">Quick Actions</div>
<div className="actions-stack">
<button onClick={handleInstall} disabled={installing || missing.length === 0}>
{installing && <span className="spinner" />}
Install missing ({missing.length})
</button>
<button onClick={handleCheckUpdates}>Check for updates</button>
<button onClick={handleScan}>Rescan system</button>
<button onClick={() => api.configureMCP().then(() => addLog('MCP configured.', 'ok'))}>
Configure MCP
</button>
<div className="dashboard-content">
{activeSection === 'tools' && (
<div className="dashboard-tools">
{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>
)}
<div className="card" style={{ flex: 1 }}>
<div className="card-header">Updates</div>
{updates.length === 0 ? (
<div className="empty-state">No update data yet.</div>
) : (
updates.map((u, i) => (
<div key={i} className="tool-row">
<span className={`badge ${u.needsUpdate ? 'warn' : 'ok'}`}>
{u.needsUpdate ? 'Update' : 'Latest'}
</span>
<span className="tool-name">{u.tool}</span>
{u.needsUpdate && (
<span style={{ color: 'var(--warning)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{u.current} &rarr; {u.latest}
{activeSection === 'notifications' && (
<div className="dashboard-notifications">
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
notifications.map(n => (
<div key={n.id} className={`notif-row notif-${n.type}`}>
<span className="notif-time">
{n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
</div>
))
)}
</div>
<span className="notif-text">{n.text}</span>
</div>
))
)}
</div>
)}
{log.length > 0 && (
<div className="card">
<div className="card-header">Activity Log</div>
{log.map(entry => (
<div key={entry.id} style={{
fontSize: 12,
padding: '4px 0',
color: entry.type === 'error' ? 'var(--error)' :
entry.type === 'warn' ? 'var(--warning)' :
entry.type === 'ok' ? 'var(--success)' : 'var(--text-tertiary)',
}}>
{entry.text}
{activeSection === 'workflows' && (
<div className="dashboard-workflows">
<div className="workflow-section">
<div className="section-label">{t('studio.workflows')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
))}
</div>
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div>
)}
</div>

View File

@@ -1,12 +1,14 @@
import { useState, useRef, useEffect } from 'react'
import { useI18n } from '../i18n'
export default function Shell({ api }) {
const { t } = useI18n()
const [history, setHistory] = useState([])
const [input, setInput] = useState('')
const [cwd, setCwd] = useState('~')
const [showAi, setShowAi] = useState(false)
const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: 'I know your system inside out. Ask me anything.' }
{ role: 'ai', content: t('shell.aiWelcome') }
])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
@@ -68,9 +70,9 @@ export default function Shell({ api }) {
try {
const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || 'No response' }])
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
} catch (err) {
setAiMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }])
setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
}
setAiLoading(false)
}
@@ -80,11 +82,11 @@ export default function Shell({ api }) {
<div className="terminal" style={{ flex: 1 }}>
<div className="panel-header">
<span className="panel-title">
Terminal
{t('shell.terminal')}
<span className="panel-subtitle">{cwd}</span>
</span>
<button className="ghost sm" onClick={() => setShowAi(!showAi)}>
{showAi ? 'Hide AI' : 'AI Assistant'}
{showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
</button>
</div>
@@ -110,7 +112,7 @@ export default function Shell({ api }) {
{showAi && (
<div className="ai-panel">
<div className="ai-panel-header">AI Assistant</div>
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages">
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
@@ -124,9 +126,9 @@ export default function Shell({ api }) {
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder="Ask AI..."
placeholder={t('shell.askAi')}
/>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>Send</button>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div>
</div>
)}

View File

@@ -1,9 +1,11 @@
import { useState, useRef, useEffect } from 'react'
import { useI18n } from '../i18n'
export default function Studio({ api }) {
const { t, layout } = useI18n()
const [messages, setMessages] = useState([
{ role: 'ai', content: 'Welcome to Studio! Chat with your AI assistant here.' },
{ role: 'ai', content: 'Configure agents and workflows from the sidebar.' },
{ role: 'ai', content: t('studio.welcome') },
{ role: 'ai', content: t('studio.configureHint') },
])
const [input, setInput] = useState('')
const [sidebarPanel, setSidebarPanel] = useState('chat')
@@ -23,10 +25,10 @@ export default function Studio({ api }) {
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
.then(res => {
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || 'No response' }])
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || t('studio.noResponse') }])
})
.catch(err => {
setMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }])
setMessages(prev => [...prev, { role: 'ai', content: `${t('studio.error')}: ${err.message}` }])
})
.finally(() => setLoading(false))
}
@@ -39,9 +41,9 @@ export default function Studio({ api }) {
}
const sidebarItems = [
{ id: 'chat', label: 'Chat', icon: '#' },
{ id: 'agents', label: 'Agents', icon: '*' },
{ id: 'workflows', label: 'Workflows', icon: '~' },
{ id: 'chat', label: t('studio.chat'), icon: '#' },
{ id: 'agents', label: t('studio.agents'), icon: '*' },
{ id: 'workflows', label: t('studio.workflows'), icon: '~' },
]
return (
@@ -49,7 +51,7 @@ export default function Studio({ api }) {
<div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}>
<div className="panel-header">
<span className="panel-title">
Chat
{t('studio.chat')}
{loading && <span className="spinner" />}
</span>
</div>
@@ -68,11 +70,11 @@ export default function Studio({ api }) {
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send)"
placeholder={t('studio.placeholder')}
disabled={loading}
/>
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
Send
{t('studio.send')}
</button>
</div>
</div>
@@ -93,42 +95,42 @@ export default function Studio({ api }) {
{sidebarPanel === 'chat' && (
<div>
<div className="section-title">Commands</div>
<div className="section-title">{t('studio.commands')}</div>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>
/plan &lt;goal&gt;<br />
/help
{t('studio.planGoal')}<br />
{t('studio.help')}
</div>
</div>
)}
{sidebarPanel === 'agents' && (
<div>
<div className="section-title">Active Agents</div>
<div className="section-title">{t('studio.activeAgents')}</div>
<div className="agent-card">
<div className="agent-avatar">C</div>
<div>
<div className="agent-name">Crush</div>
<div className="agent-status">Stopped</div>
<div className="agent-name">{t('studio.crush')}</div>
<div className="agent-status">{t('studio.stopped')}</div>
</div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div>
<div className="agent-card">
<div className="agent-avatar">CC</div>
<div>
<div className="agent-name">Claude Code</div>
<div className="agent-status">Stopped</div>
<div className="agent-name">{t('studio.claudeCode')}</div>
<div className="agent-status">{t('studio.stopped')}</div>
</div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div>
</div>
)}
{sidebarPanel === 'workflows' && (
<div>
<div className="section-title">Workflows</div>
<div className="section-title">{t('studio.workflows')}</div>
<div className="empty-state">
No active workflow.
<span style={{ fontFamily: 'var(--font-mono)' }}>Use /plan &lt;goal&gt; in chat to start.</span>
{t('studio.noWorkflow')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('studio.usePlan')}</span>
</div>
</div>
)}

105
web/src/i18n/en.js Normal file
View File

@@ -0,0 +1,105 @@
const en = {
tabs: {
dashboard: 'Dashboard',
studio: 'Studio',
shell: 'Shell',
config: 'Config',
},
header: {
toolsInstalled: '{count} tools installed',
updatesAvailable: 'Updates available',
upToDate: 'Up to date',
},
statusbar: {
switchWindow: 'Switch window',
sendMessage: 'Send message',
newLine: 'New line',
runCommand: 'Run command',
commandHistory: 'Command history',
},
dashboard: {
systemOverview: 'System Overview',
tools: 'tools',
installed: 'Installed',
missing: 'Missing',
quickActions: 'Quick Actions',
installMissing: 'Install missing',
checkUpdates: 'Check for updates',
rescanSystem: 'Rescan system',
configureMCP: 'Configure MCP',
updates: 'Updates',
update: 'Update',
latest: 'Latest',
activityLog: 'Activity Log',
noUpdateData: 'No update data yet.',
installing: 'Installing {count} tools...',
installStarted: 'Install started. Rescanning...',
done: 'Done.',
scanComplete: 'Scan complete.',
updatesCount: '{count} updates available.',
allUpToDate: 'All tools up to date.',
mcpConfigured: 'MCP configured.',
},
studio: {
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
configureHint: 'Configure agents and workflows from the sidebar.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Type a message... (Enter to send)',
send: 'Send',
commands: 'Commands',
planGoal: '/plan <goal>',
help: '/help',
activeAgents: 'Active Agents',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Stopped',
inactive: 'Inactive',
noWorkflow: 'No active workflow.',
usePlan: 'Use /plan <goal> in chat to start.',
noResponse: 'No response',
error: 'Error',
},
shell: {
terminal: 'Terminal',
hideAi: 'Hide AI',
aiAssistant: 'AI Assistant',
aiWelcome: 'I know your system inside out. Ask me anything.',
askAi: 'Ask AI...',
send: 'Send',
noResponse: 'No response',
error: 'Error',
},
config: {
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
email: 'Email',
editor: 'Editor',
shell: 'Shell',
defaultAi: 'Default AI',
languages: 'Languages',
loadingProfile: 'Loading profile...',
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
keyConfigured: 'Key configured',
noKey: 'No key',
theme: 'Theme',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
},
}
export default en

105
web/src/i18n/fr.js Normal file
View File

@@ -0,0 +1,105 @@
const fr = {
tabs: {
dashboard: 'Tableau de bord',
studio: 'Studio',
shell: 'Terminal',
config: 'Configuration',
},
header: {
toolsInstalled: '{count} outils install\u00e9s',
updatesAvailable: 'Mises \u00e0 jour disponibles',
upToDate: '\u00c0 jour',
},
statusbar: {
switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},
dashboard: {
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
tools: 'outils',
installed: 'Install\u00e9',
missing: 'Manquant',
quickActions: 'Actions rapides',
installMissing: 'Installer les manquants',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
rescanSystem: 'Rescanner le syst\u00e8me',
configureMCP: 'Configurer MCP',
updates: 'Mises \u00e0 jour',
update: 'Mise \u00e0 jour',
latest: '\u00c0 jour',
activityLog: 'Journal d\u2019activit\u00e9',
noUpdateData: 'Aucune donn\u00e9e de mise \u00e0 jour.',
installing: 'Installation de {count} outils...',
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
done: 'Termin\u00e9.',
scanComplete: 'Scan termin\u00e9.',
updatesCount: '{count} mises \u00e0 jour disponibles.',
allUpToDate: 'Tous les outils sont \u00e0 jour.',
mcpConfigured: 'MCP configur\u00e9.',
},
studio: {
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
send: 'Envoyer',
commands: 'Commandes',
planGoal: '/plan <objectif>',
help: '/help',
activeAgents: 'Agents actifs',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Arr\u00eat\u00e9',
inactive: 'Inactif',
noWorkflow: 'Aucun workflow actif.',
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
},
shell: {
terminal: 'Terminal',
hideAi: 'Masquer IA',
aiAssistant: 'Assistant IA',
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
askAi: 'Demander \u00e0 l\u2019IA...',
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
},
config: {
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
email: 'Email',
editor: '\u00c9diteur',
shell: 'Shell',
defaultAi: 'IA par d\u00e9faut',
languages: 'Langages',
loadingProfile: 'Chargement du profil...',
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
theme: 'Th\u00e8me',
skills: 'Comp\u00e9tences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
},
}
export default fr

101
web/src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
import en from './en'
import fr from './fr'
import { getLayout, getLayoutList } from './keyboards'
import api from '../api/client'
const translations = { en, fr }
const STORAGE_KEY_LANG = 'muyue-language'
const STORAGE_KEY_KBD = 'muyue-keyboard'
const I18nContext = createContext(null)
function resolveLocale(layout) {
const l = getLayout(layout)
return l.locale
}
export function I18nProvider({ children }) {
const [language, setLanguageState] = useState(() => localStorage.getItem(STORAGE_KEY_LANG) || 'fr')
const [keyboard, setKeyboardState] = useState(() => localStorage.getItem(STORAGE_KEY_KBD) || 'azerty')
const [loaded, setLoaded] = useState(false)
const pendingSave = useRef(null)
useEffect(() => {
api.getConfig()
.then(d => {
const prefs = d.profile?.preferences
if (prefs?.language) setLanguageState(prefs.language)
if (prefs?.keyboard_layout) setKeyboardState(prefs.keyboard_layout)
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
useEffect(() => {
if (!loaded) return
if (pendingSave.current) clearTimeout(pendingSave.current)
pendingSave.current = setTimeout(() => {
api.savePreferences({ language, keyboard_layout: keyboard }).catch(() => {})
}, 500)
return () => { if (pendingSave.current) clearTimeout(pendingSave.current) }
}, [language, keyboard, loaded])
const setLanguage = useCallback((lang) => {
setLanguageState(lang)
localStorage.setItem(STORAGE_KEY_LANG, lang)
}, [])
const setKeyboard = useCallback((kbd) => {
setKeyboardState(kbd)
localStorage.setItem(STORAGE_KEY_KBD, kbd)
}, [])
const layout = useMemo(() => getLayout(keyboard), [keyboard])
const t = useCallback((key, params) => {
const dict = translations[language] || translations.fr
const keys = key.split('.')
let value = dict
for (const k of keys) {
if (value == null) return key
value = value[k]
}
if (typeof value !== 'string') return key
if (params) {
return Object.entries(params).reduce((str, [k, v]) => str.replace(`{${k}}`, v), value)
}
return value
}, [language])
const clockLocale = useMemo(() => resolveLocale(keyboard), [keyboard])
const contextValue = useMemo(() => ({
language,
keyboard,
layout,
setLanguage,
setKeyboard,
t,
clockLocale,
layouts: getLayoutList(),
}), [language, keyboard, layout, t, clockLocale])
return (
<I18nContext.Provider value={contextValue}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
export const LANGUAGES = [
{ id: 'fr', name: 'Fran\u00e7ais' },
{ id: 'en', name: 'English' },
]

61
web/src/i18n/keyboards.js Normal file
View File

@@ -0,0 +1,61 @@
export const LAYOUTS = {
qwerty: {
id: 'qwerty',
name: 'QWERTY',
locale: 'en-US',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Ctrl',
enter: 'Enter',
shift: 'Shift',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
azerty: {
id: 'azerty',
name: 'AZERTY',
locale: 'fr-FR',
keys: {
tab1: '&',
tab2: '\u00e9',
tab3: '"',
tab4: "'",
ctrl: 'Ctrl',
enter: 'Entr\u00e9e',
shift: 'Maj',
up: '\u2191',
down: '\u2193',
range: '&-\u00e9-"-\'',
},
},
qwertz: {
id: 'qwertz',
name: 'QWERTZ',
locale: 'de-DE',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Strg',
enter: 'Enter',
shift: 'Umschalt',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
}
export function getLayout(id) {
return LAYOUTS[id] || LAYOUTS.azerty
}
export function getLayoutList() {
return Object.values(LAYOUTS)
}

View File

@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { I18nProvider } from './i18n'
import './styles/global.css'
import App from './components/App'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
<I18nProvider>
<App />
</I18nProvider>
</React.StrictMode>
)

View File

@@ -168,6 +168,12 @@ input::placeholder { color: var(--text-disabled); }
color: var(--text-disabled);
}
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
.statusbar-shortcut kbd {
display: inline-block; padding: 1px 5px; border-radius: 3px;
background: var(--bg-card); border: 1px solid var(--border);
font-family: var(--font-mono); font-size: 10px; color: var(--text-tertiary);
}
.card {
background: var(--bg-card);
@@ -328,6 +334,60 @@ input::placeholder { color: var(--text-disabled); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.dashboard-tab {
padding: 10px 24px; font-size: 13px; font-weight: 600;
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent;
user-select: none;
}
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-size: 10px; padding: 1px 6px; border-radius: 99px;
background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono);
}
.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-tools { padding: 16px 24px; }
.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: 16px 24px; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
}
.notif-row:hover { background: var(--bg-card); }
.notif-time { color: var(--text-disabled); font-size: 11px; font-family: var(--font-mono); flex-shrink: 0; padding-top: 1px; }
.notif-text { font-size: 13px; color: var(--text-secondary); }
.notif-info .notif-text { color: var(--info); }
.notif-ok .notif-text { color: var(--success); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
.panel-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);