Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 32s

This commit is contained in:
Augustin
2026-04-23 21:14:56 +02:00
3 changed files with 176 additions and 74 deletions

View File

@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
Pseudo string `json:"pseudo"`
Email string `json:"email"`
Editor string `json:"editor"`
Shell string `json:"shell"`
currentJSON, err := json.Marshal(s.config.Profile)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
var currentMap map[string]interface{}
json.Unmarshal(currentJSON, &currentMap)
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name != "" {
s.config.Profile.Name = body.Name
}
if body.Pseudo != "" {
s.config.Profile.Pseudo = body.Pseudo
}
if body.Email != "" {
s.config.Profile.Email = body.Email
}
if body.Editor != "" {
s.config.Profile.Preferences.Editor = body.Editor
}
if body.Shell != "" {
s.config.Profile.Preferences.Shell = body.Shell
}
deepMerge(currentMap, updates)
mergedJSON, _ := json.Marshal(currentMap)
json.Unmarshal(mergedJSON, &s.config.Profile)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
func deepMerge(dst, src map[string]interface{}) {
for k, sv := range src {
if dv, ok := dst[k]; ok {
dstMap, dOk := dv.(map[string]interface{})
srcMap, sOk := sv.(map[string]interface{})
if dOk && sOk {
deepMerge(dstMap, srcMap)
continue
}
}
dst[k] = sv
}
}
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)

View File

@@ -34,14 +34,7 @@ export default function Config({ api }) {
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
setProfileForm({
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
@@ -209,57 +202,125 @@ export default function Config({ api }) {
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
const updateField = (path, value) => {
setProfileForm(prev => {
const next = JSON.parse(JSON.stringify(prev))
const keys = path.split('.')
let target = next
for (let i = 0; i < keys.length - 1; i++) {
if (target[keys[i]] == null) target[keys[i]] = {}
target = target[keys[i]]
}
target[keys[keys.length - 1]] = value
return next
})
}
const profile = editProfile ? profileForm : config?.profile
if (!profile) {
return (
<div className="config-card">
<div className="empty-state">{t('config.loadingProfile')}</div>
</div>
)
}
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">
<RenderFields obj={profile} path="" editing={editProfile} onChange={updateField} t={t} />
<div className="config-card-actions">
{editProfile ? (
<>
<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>
)}
</>
) : (
<button className="primary sm" onClick={() => {
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
setEditProfile(true)
}}>{t('config.editProfile')}</button>
)}
</div>
</div>
)
}
function RenderFields({ obj, path, editing, onChange, t }) {
if (!obj || typeof obj !== 'object') return null
return Object.entries(obj).map(([key, value]) => {
const fieldPath = path ? `${path}.${key}` : key
const label = getFieldLabel(key, t)
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return (
<div key={key} className="config-card-group">
<span className="config-card-group-label">{label}</span>
<RenderFields obj={value} path={fieldPath} editing={editing} onChange={onChange} t={t} />
</div>
)
}
if (editing) {
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
</label>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
</div>
)
}
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
</div>
)
}
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
</div>
)
}
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
</div>
)
})
}
function getFieldLabel(key, t) {
const translated = t(`config.${key}`)
if (translated !== `config.${key}`) return translated
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null)

View File

@@ -429,7 +429,7 @@ input::placeholder { color: var(--text-disabled); }
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.config-tabs-bar {
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
@@ -728,6 +728,18 @@ input::placeholder { color: var(--text-disabled); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
.feed-compressed-indicator {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; margin: 4px 0;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; }
.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; }
.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-thinking-block {
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
@@ -791,7 +803,12 @@ input::placeholder { color: var(--text-disabled); }
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.studio-token-fill.warn { background: var(--warning); }
.studio-token-fill.compressed { height: 2px; }
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.studio-token-text.compressed { font-size: 9px; }
.studio-token-track.compressed { height: 2px; }
.studio-token-bar.compressed { margin-bottom: 4px; }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
@@ -816,6 +833,21 @@ input::placeholder { color: var(--text-disabled); }
.studio-stop-btn:hover { opacity: 0.8; }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
/* ── Collapsed Messages ── */
.feed-collapsed-messages {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px; margin: 4px 0;
background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated));
border: 1px dashed var(--border-accent);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); }
.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; }
.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; }
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);