Compare commits
4 Commits
v0.3.3-bet
...
v0.3.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bad2948c5 | ||
|
|
92eb783df0 | ||
|
|
8005e978f0 | ||
|
|
6e76e7dca6 |
@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, "no config", http.StatusNotFound)
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
|
||||||
Name string `json:"name"`
|
currentJSON, err := json.Marshal(s.config.Profile)
|
||||||
Pseudo string `json:"pseudo"`
|
if err != nil {
|
||||||
Email string `json:"email"`
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
Editor string `json:"editor"`
|
return
|
||||||
Shell string `json:"shell"`
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
var currentMap map[string]interface{}
|
||||||
|
json.Unmarshal(currentJSON, ¤tMap)
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if err := json.Unmarshal(body, &updates); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Name != "" {
|
|
||||||
s.config.Profile.Name = body.Name
|
deepMerge(currentMap, updates)
|
||||||
}
|
|
||||||
if body.Pseudo != "" {
|
mergedJSON, _ := json.Marshal(currentMap)
|
||||||
s.config.Profile.Pseudo = body.Pseudo
|
json.Unmarshal(mergedJSON, &s.config.Profile)
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if err := config.Save(s.config); err != nil {
|
if err := config.Save(s.config); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "ok"})
|
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) {
|
func (s *Server) handleSaveProvider(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)
|
||||||
|
|||||||
@@ -477,25 +477,16 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "zai":
|
case "zai":
|
||||||
if p.APIKey == "" {
|
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
|
||||||
q.Error = "no API key"
|
q.Healthy = true
|
||||||
results = append(results, q)
|
q.Data = map[string]interface{}{"note": "crush-only"}
|
||||||
continue
|
case "claude", "anthropic":
|
||||||
}
|
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||||
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
|
claudePath := "/usr/bin/claude"
|
||||||
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
if _, err := os.Stat(claudePath); err == nil {
|
||||||
req.Header.Set("Accept", "application/json")
|
q.Healthy = true
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
q.Error = err.Error()
|
|
||||||
} else {
|
} else {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
q.Error = "claude code not installed"
|
||||||
resp.Body.Close()
|
|
||||||
var data map[string]interface{}
|
|
||||||
if json.Unmarshal(body, &data) == nil {
|
|
||||||
q.Data = data
|
|
||||||
q.Healthy = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
q.Error = "quota not supported"
|
q.Error = "quota not supported"
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ export default function Config({ api }) {
|
|||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
setConfig(d)
|
setConfig(d)
|
||||||
setProfileForm({
|
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
|
||||||
name: d.profile?.name || '',
|
|
||||||
pseudo: d.profile?.pseudo || '',
|
|
||||||
email: d.profile?.email || '',
|
|
||||||
editor: d.profile?.preferences?.editor || '',
|
|
||||||
shell: d.profile?.preferences?.shell || '',
|
|
||||||
})
|
|
||||||
|
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||||
@@ -190,8 +183,8 @@ export default function Config({ api }) {
|
|||||||
)}
|
)}
|
||||||
{activePanel === 'locale' && (
|
{activePanel === 'locale' && (
|
||||||
<PanelLocale
|
<PanelLocale
|
||||||
language={keyboard} layouts={layouts}
|
language={language} keyboard={keyboard} layouts={layouts}
|
||||||
setLanguage={setLanguage} setKeyboard={setKeyboard}
|
api={api}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -209,57 +202,125 @@ export default function Config({ api }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
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 (
|
return (
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
{config?.profile && !editProfile ? (
|
<RenderFields obj={profile} path="" editing={editProfile} onChange={updateField} t={t} />
|
||||||
<>
|
<div className="config-card-actions">
|
||||||
<div className="config-card-row">
|
{editProfile ? (
|
||||||
<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="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||||
</div>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<button className="primary sm" onClick={() => {
|
||||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
|
||||||
)}
|
setEditProfile(true)
|
||||||
|
}}>{t('config.editProfile')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</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 }) {
|
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||||
const [validating, setValidating] = useState(null)
|
const [validating, setValidating] = useState(null)
|
||||||
const [validationStatus, setValidationStatus] = useState(null)
|
const [validationStatus, setValidationStatus] = useState(null)
|
||||||
@@ -292,8 +353,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<div className="provider-card-top">
|
<div className="provider-card-top">
|
||||||
<div className="provider-card-identity">
|
<div className="provider-card-identity">
|
||||||
<span className="provider-card-name">{p.name}</span>
|
<span className="provider-card-name">{p.name}</span>
|
||||||
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
|
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||||
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
|
|
||||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
||||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -331,7 +391,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
||||||
<span className="mono">{p.model || '—'}</span>
|
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>}
|
||||||
|
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,9 +460,29 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
|
function PanelLocale({ language, keyboard, layouts, api, t }) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
|
const showToast = (msg) => {
|
||||||
|
setToast(msg)
|
||||||
|
setTimeout(() => setToast(null), 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.savePreferences({ language, keyboard_layout: keyboard })
|
||||||
|
showToast(t('config.saved'))
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`${t('config.error')}: ${err.message}`)
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
<div className="config-card-group">
|
<div className="config-card-group">
|
||||||
<span className="config-card-group-label">{t('config.language')}</span>
|
<span className="config-card-group-label">{t('config.language')}</span>
|
||||||
<div className="chip-row">
|
<div className="chip-row">
|
||||||
@@ -430,6 +511,11 @@ function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="config-card-actions">
|
||||||
|
<button className="primary sm" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? t('config.saving') : t('config.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,32 +3,6 @@ import { useI18n } from '../i18n'
|
|||||||
|
|
||||||
const MAX_POINTS = 30
|
const MAX_POINTS = 30
|
||||||
|
|
||||||
function BgGraph({ data, max, color }) {
|
|
||||||
if (!data || data.length < 2) return null
|
|
||||||
const m = max || Math.max(...data, 1)
|
|
||||||
const w = 120
|
|
||||||
const h = 60
|
|
||||||
const points = data.map((v, i) => {
|
|
||||||
const x = (i / (data.length - 1)) * w
|
|
||||||
const y = h - (v / m) * h
|
|
||||||
return `${x},${y}`
|
|
||||||
})
|
|
||||||
const area = `${points.join(' ')} ${w},${h} 0,${h}`
|
|
||||||
const line = points.join(' ')
|
|
||||||
return (
|
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} className="dash-bg-graph" preserveAspectRatio="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={`g-${color.replace('#','')}`} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
|
|
||||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<polygon fill={`url(#g-${color.replace('#','')})`} points={area} />
|
|
||||||
<polyline fill="none" stroke={color} strokeWidth="1.5" points={line} vectorEffect="non-scaling-stroke" opacity="0.6" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniGraph({ data, max, color, label, unit }) {
|
function MiniGraph({ data, max, color, label, unit }) {
|
||||||
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||||
const m = max || Math.max(...data, 1)
|
const m = max || Math.max(...data, 1)
|
||||||
@@ -70,7 +44,6 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
const memRef = useRef([])
|
const memRef = useRef([])
|
||||||
const netRxRef = useRef([])
|
const netRxRef = useRef([])
|
||||||
const netTxRef = useRef([])
|
const netTxRef = useRef([])
|
||||||
const procCountRef = useRef([])
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -90,7 +63,6 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
|
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
|
||||||
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
|
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
|
||||||
}
|
}
|
||||||
procCountRef.current = [...procCountRef.current, procData.processes?.length || 0].slice(-MAX_POINTS)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard load error:', err)
|
console.error('Dashboard load error:', err)
|
||||||
}
|
}
|
||||||
@@ -105,99 +77,82 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
|
|
||||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||||
const zai = (quota || []).find(p => p.name === 'zai')
|
const zai = (quota || []).find(p => p.name === 'zai')
|
||||||
const totalQuotaUsed = minimax?.data?.models?.reduce((s, m) => s + (m.used || 0), 0) || 0
|
|
||||||
const totalQuotaMax = minimax?.data?.models?.reduce((s, m) => s + (m.total || 0), 0) || 1
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash-grid">
|
<div className="dash-grid">
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
<div className="dash-card dash-card-graph">
|
<div className="dash-card">
|
||||||
<BgGraph data={cpuRef.current} max={100} color="#06b6d4" />
|
<div className="dash-card-head">
|
||||||
<div className="dash-card-content">
|
<span className="dash-label">CPU</span>
|
||||||
<div className="dash-card-head">
|
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
|
||||||
<span className="dash-label">CPU</span>
|
|
||||||
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
|
|
||||||
</div>
|
|
||||||
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RAM */}
|
{/* RAM */}
|
||||||
<div className="dash-card dash-card-graph">
|
<div className="dash-card">
|
||||||
<BgGraph data={memRef.current} max={100} color="#a78bfa" />
|
<div className="dash-card-head">
|
||||||
<div className="dash-card-content">
|
<span className="dash-label">RAM</span>
|
||||||
<div className="dash-card-head">
|
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
||||||
<span className="dash-label">RAM</span>
|
|
||||||
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
|
||||||
</div>
|
|
||||||
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Network */}
|
{/* Network */}
|
||||||
<div className="dash-card dash-card-graph">
|
<div className="dash-card">
|
||||||
<BgGraph data={netRxRef.current} max={null} color="#34d399" />
|
<div className="dash-card-head">
|
||||||
<div className="dash-card-content">
|
<span className="dash-label">Network</span>
|
||||||
<div className="dash-card-head">
|
<span className="dash-count">{metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
|
||||||
<span className="dash-label">Network</span>
|
|
||||||
<span className="dash-count">{metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
|
|
||||||
</div>
|
|
||||||
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
|
|
||||||
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
|
||||||
|
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Quota */}
|
{/* API Quota */}
|
||||||
<div className="dash-card dash-card-graph">
|
<div className="dash-card">
|
||||||
<BgGraph data={totalQuotaMax > 0 ? [totalQuotaUsed / totalQuotaMax * 100, ...(cpuRef.current.length > 0 ? [] : [0])] : []} max={100} color="#f472b6" />
|
<div className="dash-card-head">
|
||||||
<div className="dash-card-content">
|
<span className="dash-label">API Quota</span>
|
||||||
<div className="dash-card-head">
|
</div>
|
||||||
<span className="dash-label">API Quota</span>
|
<div className="dash-quota-list">
|
||||||
</div>
|
{minimax && minimax.data?.models?.map((m, i) => (
|
||||||
<div className="dash-quota-list">
|
<div key={i} className="dash-quota-row">
|
||||||
{minimax && minimax.data?.models?.map((m, i) => (
|
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
|
||||||
<div key={i} className="dash-quota-row">
|
<div className="dash-bar">
|
||||||
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
|
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
||||||
<div className="dash-bar">
|
|
||||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="dash-quota-val">{m.remaining}/{m.total}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||||
{minimax && minimax.data?.models?.length === 0 && (
|
</div>
|
||||||
<div className="dash-quota-row">
|
))}
|
||||||
<span className="dash-quota-name">MiniMax</span>
|
{minimax && minimax.data?.models?.length === 0 && (
|
||||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
<div className="dash-quota-row">
|
||||||
</div>
|
<span className="dash-quota-name">MiniMax</span>
|
||||||
)}
|
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
||||||
{zai && (
|
</div>
|
||||||
<div className="dash-quota-row">
|
)}
|
||||||
<span className="dash-quota-name">Z.AI</span>
|
{zai && (
|
||||||
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
|
<div className="dash-quota-row">
|
||||||
</div>
|
<span className="dash-quota-name">Z.AI</span>
|
||||||
)}
|
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
|
||||||
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Running Processes */}
|
{/* Running Processes */}
|
||||||
<div className="dash-card dash-card-graph">
|
<div className="dash-card">
|
||||||
<BgGraph data={procCountRef.current} max={null} color="#fb923c" />
|
<div className="dash-card-head">
|
||||||
<div className="dash-card-content">
|
<span className="dash-label">Processes</span>
|
||||||
<div className="dash-card-head">
|
<span className="dash-count">{processes.length}</span>
|
||||||
<span className="dash-label">Processes</span>
|
</div>
|
||||||
<span className="dash-count">{processes.length}</span>
|
<div className="dash-proc-list">
|
||||||
</div>
|
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
|
||||||
<div className="dash-proc-list">
|
{processes.map((p, i) => (
|
||||||
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
|
<div key={i} className="dash-proc-row">
|
||||||
{processes.slice(0, 6).map((p, i) => (
|
<span className="dash-proc-name">{p.name}</span>
|
||||||
<div key={i} className="dash-proc-row">
|
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
|
||||||
<span className="dash-proc-name">{p.name}</span>
|
</div>
|
||||||
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,7 +163,7 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="dash-cmd-list">
|
<div className="dash-cmd-list">
|
||||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||||
{recentCmds.slice(0, 8).map((c, i) => (
|
{recentCmds.map((c, i) => (
|
||||||
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
||||||
<span className="dash-cmd-shell">{c.shell}</span>
|
<span className="dash-cmd-shell">{c.shell}</span>
|
||||||
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ export default function Studio({ api }) {
|
|||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
||||||
|
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||||
|
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const textareaRef = useRef(null)
|
const textareaRef = useRef(null)
|
||||||
const abortRef = useRef(null)
|
const abortRef = useRef(null)
|
||||||
@@ -336,12 +338,18 @@ export default function Studio({ api }) {
|
|||||||
|
|
||||||
const handleSummarize = useCallback(async () => {
|
const handleSummarize = useCallback(async () => {
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
|
||||||
|
setContextCollapsed('animating')
|
||||||
try {
|
try {
|
||||||
const data = await api.summarizeChat()
|
const data = await api.summarizeChat()
|
||||||
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
|
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
|
||||||
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString() }])
|
setTimeout(() => {
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString(), compressed: true }])
|
||||||
|
setContextCollapsed(true)
|
||||||
|
setMessagesCollapsed(true)
|
||||||
|
}, 600)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
|
||||||
|
setContextCollapsed(false)
|
||||||
}
|
}
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
@@ -396,7 +404,7 @@ export default function Studio({ api }) {
|
|||||||
if (text === '/model') {
|
if (text === '/model') {
|
||||||
api.getProviders().then(data => {
|
api.getProviders().then(data => {
|
||||||
const active = data.providers?.find(p => p.active)
|
const active = data.providers?.find(p => p.active)
|
||||||
const modelMsg = active ? `Provider: ${active.name}\nModèle: ${active.model}` : 'Aucun provider actif configuré'
|
const modelMsg = active ? active.name : 'Aucun provider actif configuré'
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||||
@@ -525,6 +533,34 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleCollapsed = useCallback(() => {
|
||||||
|
setMessagesCollapsed(prev => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderMessages = () => {
|
||||||
|
if (messagesCollapsed && messages.length > 4) {
|
||||||
|
const visibleCount = 4
|
||||||
|
const hiddenCount = messages.length - visibleCount
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{messages.slice(0, visibleCount).map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
|
))}
|
||||||
|
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
|
||||||
|
<span className="feed-collapsed-count">clic pour développer</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return messages.map(msg => (
|
||||||
|
<FeedItem key={msg.id} msg={msg} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return (
|
return (
|
||||||
<div className="studio-feed-layout">
|
<div className="studio-feed-layout">
|
||||||
@@ -540,27 +576,31 @@ export default function Studio({ api }) {
|
|||||||
return (
|
return (
|
||||||
<div className="studio-feed-layout">
|
<div className="studio-feed-layout">
|
||||||
<div className="studio-feed">
|
<div className="studio-feed">
|
||||||
{messages.map(msg => (
|
{renderMessages()}
|
||||||
<FeedItem key={msg.id} msg={msg} />
|
|
||||||
))}
|
|
||||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEnd} />
|
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="studio-input-area">
|
<div className="studio-input-area">
|
||||||
<div className="studio-token-bar">
|
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
<div className="studio-token-track">
|
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
<div
|
<div
|
||||||
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''}`}
|
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
|
||||||
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
|
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="studio-token-text">
|
<span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||||
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
|
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
|
||||||
{tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'}
|
{contextCollapsed === true && ' · compressé'}
|
||||||
|
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
|
||||||
</span>
|
</span>
|
||||||
|
{contextCollapsed === true && (
|
||||||
|
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
|
||||||
|
voir plus
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-row">
|
<div className="studio-input-row">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
.config-tabs-bar {
|
.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;
|
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,16 +547,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.dash-card-graph { padding: 0; }
|
|
||||||
.dash-bg-graph {
|
|
||||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
|
||||||
opacity: 0.35; pointer-events: none;
|
|
||||||
}
|
|
||||||
.dash-card-content {
|
|
||||||
position: relative; z-index: 1;
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
|
||||||
}
|
|
||||||
.dash-span-2 { grid-column: span 2; }
|
.dash-span-2 { grid-column: span 2; }
|
||||||
.dash-card-head {
|
.dash-card-head {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
@@ -585,7 +576,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.dash-tool-tag.missing { color: var(--error); }
|
.dash-tool-tag.missing { color: var(--error); }
|
||||||
|
|
||||||
/* Quota */
|
/* Quota */
|
||||||
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; }
|
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
|
||||||
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
|
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
|
||||||
.dash-quota-name {
|
.dash-quota-name {
|
||||||
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
||||||
@@ -604,7 +595,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Processes */
|
/* Processes */
|
||||||
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; }
|
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||||
.dash-proc-row {
|
.dash-proc-row {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
@@ -618,7 +609,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Commands */
|
/* Commands */
|
||||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; }
|
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
||||||
.dash-cmd-row {
|
.dash-cmd-row {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
padding: 3px 0; overflow: hidden;
|
padding: 3px 0; overflow: hidden;
|
||||||
@@ -737,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-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-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 { 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 {
|
.feed-thinking-block {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
|
||||||
@@ -800,7 +803,18 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
|
.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 { 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.warn { background: var(--warning); }
|
||||||
|
.studio-token-fill.compressed { height: 2px; }
|
||||||
|
.studio-token-fill.animating { animation: compress-pulse 0.6s ease-in-out; }
|
||||||
|
@keyframes compress-pulse {
|
||||||
|
0% { height: 3px; opacity: 1; }
|
||||||
|
50% { height: 5px; opacity: 0.8; background: var(--accent-light); }
|
||||||
|
100% { height: 2px; opacity: 1; }
|
||||||
|
}
|
||||||
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
.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 { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
.studio-input-row textarea {
|
.studio-input-row textarea {
|
||||||
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
|
||||||
@@ -825,6 +839,21 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.studio-stop-btn:hover { opacity: 0.8; }
|
.studio-stop-btn:hover { opacity: 0.8; }
|
||||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
.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 Blocks ── */
|
||||||
.studio-tool-block {
|
.studio-tool-block {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
|
|||||||
Reference in New Issue
Block a user