Compare commits
3 Commits
v0.3.0-bet
...
v0.3.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
040e482c75 | ||
|
|
c8903efa5e | ||
|
|
f3cb306053 |
157
internal/api/conversation.go
Normal file
157
internal/api/conversation.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
const maxTokensApprox = 100000
|
||||
const summarizeThreshold = 80000
|
||||
const charsPerToken = 4
|
||||
|
||||
type FeedMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
Messages []FeedMessage `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ConversationStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
conv *Conversation
|
||||
}
|
||||
|
||||
func NewConversationStore() *ConversationStore {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
dir = "/tmp/muyue"
|
||||
}
|
||||
path := filepath.Join(dir, "conversation.json")
|
||||
cs := &ConversationStore{path: path}
|
||||
cs.load()
|
||||
return cs
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) load() {
|
||||
data, err := os.ReadFile(cs.path)
|
||||
if err != nil {
|
||||
cs.conv = &Conversation{
|
||||
Messages: []FeedMessage{},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
return
|
||||
}
|
||||
var conv Conversation
|
||||
if err := json.Unmarshal(data, &conv); err != nil {
|
||||
cs.conv = &Conversation{
|
||||
Messages: []FeedMessage{},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
return
|
||||
}
|
||||
if conv.Messages == nil {
|
||||
conv.Messages = []FeedMessage{}
|
||||
}
|
||||
cs.conv = &conv
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) save() error {
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
data, err := json.MarshalIndent(cs.conv, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(cs.path)
|
||||
os.MkdirAll(dir, 0755)
|
||||
return os.WriteFile(cs.path, data, 0600)
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Get() []FeedMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
out := make([]FeedMessage, len(cs.conv.Messages))
|
||||
copy(out, cs.conv.Messages)
|
||||
return out
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) GetSummary() string {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return cs.conv.Summary
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
msg := FeedMessage{
|
||||
ID: generateMsgID(),
|
||||
Role: role,
|
||||
Content: content,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
cs.conv.Messages = append(cs.conv.Messages, msg)
|
||||
cs.save()
|
||||
return msg
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.conv.Messages = []FeedMessage{}
|
||||
cs.conv.Summary = ""
|
||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) SetSummary(summary string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.conv.Summary = summary
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if len(cs.conv.Messages) <= keepCount {
|
||||
return
|
||||
}
|
||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
total := utf8.RuneCountInString(cs.conv.Summary)
|
||||
for _, m := range cs.conv.Messages {
|
||||
total += utf8.RuneCountInString(m.Content)
|
||||
}
|
||||
return total / charsPerToken
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||
return cs.ApproxTokenCount() > summarizeThreshold
|
||||
}
|
||||
|
||||
func generateMsgID() string {
|
||||
return time.Now().Format("20060102150405.000")
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/muyue/muyue/internal/version"
|
||||
)
|
||||
|
||||
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
@@ -264,18 +266,24 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.convStore.Add("user", body.Message)
|
||||
|
||||
if s.convStore.NeedsSummarization() {
|
||||
s.autoSummarize()
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can:
|
||||
- Create and manage development plans with step-by-step workflows
|
||||
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks
|
||||
- Track progress across multi-step tasks
|
||||
- Suggest file modifications, code reviews, and architecture decisions
|
||||
orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
|
||||
- Créer et gérer des plans de développement étape par étape
|
||||
- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
|
||||
- Suivre la progression de tâches multi-étapes
|
||||
- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
|
||||
|
||||
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`)
|
||||
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
|
||||
|
||||
if body.Stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
@@ -285,7 +293,6 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
chunkSize := 8
|
||||
result, err := orb.Send(body.Message)
|
||||
if err != nil {
|
||||
data, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
@@ -296,6 +303,9 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
|
||||
return
|
||||
}
|
||||
|
||||
s.convStore.Add("assistant", result)
|
||||
|
||||
chunkSize := 8
|
||||
runes := []rune(result)
|
||||
for i := 0; i < len(runes); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
@@ -323,9 +333,64 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.convStore.Add("assistant", result)
|
||||
writeJSON(w, map[string]string{"content": result})
|
||||
}
|
||||
|
||||
func (s *Server) autoSummarize() {
|
||||
messages := s.convStore.Get()
|
||||
if len(messages) < 10 {
|
||||
return
|
||||
}
|
||||
|
||||
half := len(messages) / 2
|
||||
var oldText string
|
||||
for _, m := range messages[:half] {
|
||||
oldText += m.Role + ": " + m.Content + "\n\n"
|
||||
}
|
||||
|
||||
summary := s.convStore.GetSummary()
|
||||
if summary != "" {
|
||||
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(summarizePrompt)
|
||||
|
||||
result, err := orb.Send(oldText)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.convStore.SetSummary(result)
|
||||
s.convStore.TrimOld(len(messages) - half)
|
||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
||||
}
|
||||
|
||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
messages := s.convStore.Get()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.convStore.ApproxTokenCount(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.convStore.Clear()
|
||||
writeJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
writeError(w, "PUT only", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -12,6 +12,7 @@ type Server struct {
|
||||
config *config.MuyueConfig
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
@@ -20,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
s.convStore = NewConversationStore()
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -45,6 +47,9 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
|
||||
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
|
||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -33,6 +33,8 @@ const api = {
|
||||
getTerminalSessions: () => request('/terminal/sessions'),
|
||||
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
||||
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
getChatHistory: () => request('/chat/history'),
|
||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||
sendChat: (message, stream = true) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
|
||||
const PANELS = [
|
||||
{ id: 'profile', icon: User },
|
||||
{ id: 'providers', icon: Brain },
|
||||
{ id: 'updates', icon: RefreshCw },
|
||||
{ id: 'locale', icon: Globe },
|
||||
{ id: 'skills', icon: Wrench },
|
||||
]
|
||||
|
||||
export default function Config({ api }) {
|
||||
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
|
||||
const [activePanel, setActivePanel] = useState('profile')
|
||||
const [config, setConfig] = useState(null)
|
||||
const [providers, setProviders] = useState([])
|
||||
const [skillList, setSkillList] = useState([])
|
||||
@@ -119,132 +129,238 @@ export default function Config({ api }) {
|
||||
const missingCount = tools.filter(t => !t.installed).length
|
||||
|
||||
return (
|
||||
<div className="config-layout">
|
||||
<div className="config-window">
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.systemUpdates')}</div>
|
||||
<div className="config-actions-row">
|
||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
||||
{checking ? t('config.checking') : t('config.checkUpdates')}
|
||||
</button>
|
||||
{needsUpdateCount > 0 && (
|
||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
||||
</button>
|
||||
<div className="config-sidebar">
|
||||
{PANELS.map(p => {
|
||||
const Icon = p.icon
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`config-sidebar-item ${activePanel === p.id ? 'active' : ''}`}
|
||||
onClick={() => setActivePanel(p.id)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span>{t(`config.panels.${p.id}`)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="config-panel-area">
|
||||
<div className="config-panel-header">
|
||||
<h2 className="config-panel-title">{t(`config.panels.${activePanel}`)}</h2>
|
||||
</div>
|
||||
|
||||
<div className="config-panel-body">
|
||||
{activePanel === 'profile' && (
|
||||
<PanelProfile
|
||||
config={config} editProfile={editProfile}
|
||||
profileForm={profileForm} setProfileForm={setProfileForm}
|
||||
setEditProfile={setEditProfile} handleSaveProfile={handleSaveProfile}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'providers' && (
|
||||
<PanelProviders
|
||||
providers={providers} editProvider={editProvider}
|
||||
providerForm={providerForm} setProviderForm={setProviderForm}
|
||||
setEditProvider={setEditProvider} openProviderEdit={openProviderEdit}
|
||||
handleSaveProvider={handleSaveProvider} api={api} loadData={loadData}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'updates' && (
|
||||
<PanelUpdates
|
||||
updates={updates} tools={tools}
|
||||
checking={checking} updating={updating}
|
||||
needsUpdateCount={needsUpdateCount}
|
||||
installedCount={installedCount} missingCount={missingCount}
|
||||
handleCheckUpdates={handleCheckUpdates}
|
||||
handleUpdateTool={handleUpdateTool}
|
||||
handleUpdateAll={handleUpdateAll}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'locale' && (
|
||||
<PanelLocale
|
||||
language={keyboard} layouts={layouts}
|
||||
setLanguage={setLanguage} setKeyboard={setKeyboard}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'skills' && (
|
||||
<PanelSkills skillList={skillList} t={t} />
|
||||
)}
|
||||
</div>
|
||||
<div className="config-stats">
|
||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
||||
</div>
|
||||
|
||||
{updates.length === 0 ? (
|
||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||
) : (
|
||||
<div className="config-update-list">
|
||||
{updates.map((u, i) => (
|
||||
<div key={i} className="config-update-row">
|
||||
<div className="config-update-info">
|
||||
<span className="config-update-name">{u.tool}</span>
|
||||
<span className="config-update-versions">
|
||||
{u.needsUpdate ? (
|
||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
||||
) : (
|
||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{u.needsUpdate && (
|
||||
<button
|
||||
className="sm"
|
||||
onClick={() => handleUpdateTool(u.tool)}
|
||||
disabled={updating === u.tool}
|
||||
>
|
||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">
|
||||
{t('config.profile')}
|
||||
<button className="ghost sm" onClick={() => setEditProfile(!editProfile)}>
|
||||
{editProfile ? t('config.cancel') : t('config.editProfile')}
|
||||
</button>
|
||||
</div>
|
||||
{config?.profile && !editProfile ? (
|
||||
<div>
|
||||
<FieldRow label={t('config.name')} value={config.profile.name} />
|
||||
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
|
||||
<FieldRow label={t('config.email')} value={config.profile.email} />
|
||||
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
|
||||
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
|
||||
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
|
||||
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
|
||||
return (
|
||||
<div className="config-card">
|
||||
{config?.profile && !editProfile ? (
|
||||
<>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.name')}</span>
|
||||
<span className="config-card-value">{config.profile.name || '—'}</span>
|
||||
</div>
|
||||
) : editProfile ? (
|
||||
<div className="config-form">
|
||||
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
|
||||
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
|
||||
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} />
|
||||
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
|
||||
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
|
||||
<div className="config-form-actions">
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.pseudo')}</span>
|
||||
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.email')}</span>
|
||||
<span className="config-card-value">{config.profile.email || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.editor')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.shell')}</span>
|
||||
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.languages')}</span>
|
||||
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
|
||||
</div>
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : editProfile ? (
|
||||
<>
|
||||
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
|
||||
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
|
||||
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
|
||||
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
|
||||
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||
return (
|
||||
<div className="config-providers-list">
|
||||
{providers.map((p, i) => (
|
||||
<div key={i} className="config-card provider-card-v2">
|
||||
<div className="provider-card-top">
|
||||
<div className="provider-card-identity">
|
||||
<span className="provider-card-name">{p.name}</span>
|
||||
{p.active && <span className="badge accent">{t('config.active')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">{t('config.loadingProfile')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.aiProviders')}</div>
|
||||
{providers.map((p, i) => (
|
||||
<div key={i} className="provider-card">
|
||||
<div className="provider-info">
|
||||
<div className="provider-name">
|
||||
{p.name}
|
||||
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
|
||||
</div>
|
||||
{editProvider !== p.name ? (
|
||||
<div className="provider-meta">
|
||||
<span>{p.model}</span>
|
||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
||||
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
|
||||
</span>
|
||||
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
|
||||
{!p.active && (
|
||||
<button className="sm" onClick={async () => {
|
||||
await api.saveProvider({ name: p.name, active: true })
|
||||
loadData()
|
||||
}}>{t('config.activate')}</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="config-form" style={{ marginTop: 8 }}>
|
||||
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
|
||||
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
|
||||
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
|
||||
<div className="config-form-actions">
|
||||
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="provider-card-actions">
|
||||
{editProvider !== p.name && (
|
||||
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
|
||||
)}
|
||||
{!p.active && editProvider !== p.name && (
|
||||
<button className="sm" onClick={async () => {
|
||||
await api.saveProvider({ name: p.name, active: true })
|
||||
loadData()
|
||||
}}>{t('config.activate')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editProvider !== p.name ? (
|
||||
<div className="provider-card-meta">
|
||||
<span className="mono">{p.model || '—'}</span>
|
||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
||||
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="provider-card-form">
|
||||
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
|
||||
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
|
||||
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
|
||||
<div className="config-card-actions">
|
||||
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
|
||||
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||
return (
|
||||
<>
|
||||
<div className="config-card">
|
||||
<div className="config-update-controls">
|
||||
<div className="config-update-stats">
|
||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
||||
</div>
|
||||
<div className="config-update-buttons">
|
||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
||||
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
|
||||
</button>
|
||||
{needsUpdateCount > 0 && (
|
||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.language')}</div>
|
||||
{updates.length === 0 ? (
|
||||
<div className="config-card">
|
||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="config-update-list">
|
||||
{updates.map((u, i) => (
|
||||
<div key={i} className="config-update-row">
|
||||
<div className="config-update-info">
|
||||
<span className="config-update-name">{u.tool}</span>
|
||||
<span className="config-update-versions">
|
||||
{u.needsUpdate ? (
|
||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
||||
) : (
|
||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{u.needsUpdate && (
|
||||
<button
|
||||
className="sm"
|
||||
onClick={() => handleUpdateTool(u.tool)}
|
||||
disabled={updating === u.tool}
|
||||
>
|
||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
|
||||
return (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.language')}</span>
|
||||
<div className="chip-row">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
@@ -257,9 +373,8 @@ export default function Config({ api }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.keyboardLayout')}</div>
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
||||
<div className="chip-row">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
@@ -272,43 +387,37 @@ export default function Config({ api }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
|
||||
{skillList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{t('config.noSkills')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
||||
</div>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="config-skill-row">
|
||||
<span className="config-skill-name">{s.name}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
<span className="config-skill-desc">{s.description}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldRow({ label, value }) {
|
||||
function PanelSkills({ skillList, t }) {
|
||||
return (
|
||||
<div className="field-row">
|
||||
<span className="field-label">{label}</span>
|
||||
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
|
||||
<div className="config-card">
|
||||
{skillList.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{t('config.noSkills')}
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
||||
</div>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="config-skill-row">
|
||||
<span className="config-skill-name">{s.name}</span>
|
||||
<span className="badge neutral">{s.target || 'both'}</span>
|
||||
<span className="config-skill-desc">{s.description}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormInput({ label, value, onChange, type = 'text' }) {
|
||||
return (
|
||||
<div className="field-row">
|
||||
<span className="field-label">{label}</span>
|
||||
<div className="config-form-field">
|
||||
<label className="config-form-label">{label}</label>
|
||||
<input
|
||||
className="config-input"
|
||||
className="config-form-input"
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function Dashboard({ tools, updates, api, onRescan }) {
|
||||
export default function Dashboard({ api, onRescan }) {
|
||||
const { t, layout } = useI18n()
|
||||
const [notifications, setNotifications] = useState([])
|
||||
|
||||
const installed = tools.filter(tool => tool.installed).length
|
||||
const total = tools.length
|
||||
|
||||
const addNotif = (text, type) => {
|
||||
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
|
||||
}
|
||||
@@ -16,35 +13,6 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
|
||||
<div className="dashboard-layout">
|
||||
<div className="dashboard-content">
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
|
||||
{total > 0 && (
|
||||
<span className="badge info">{installed}/{total}</span>
|
||||
)}
|
||||
</div>
|
||||
{tools.length === 0 ? (
|
||||
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
|
||||
) : (
|
||||
<div className="tools-compact">
|
||||
{tools.map((tool, i) => {
|
||||
const name = tool.name || tool.Name
|
||||
const ver = extractVersion(tool.Version || tool.version)
|
||||
return (
|
||||
<div key={i} className="tool-compact-row">
|
||||
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
|
||||
{tool.installed ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
<span className="tool-compact-name">{name}</span>
|
||||
{ver && <span className="tool-compact-ver">{ver}</span>}
|
||||
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-header">
|
||||
<div className="dashboard-section-title">{t('studio.workflows')}</div>
|
||||
@@ -92,9 +60,3 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractVersion(s) {
|
||||
if (!s) return ''
|
||||
const m = s.match(/\d+\.\d+\.\d+/)
|
||||
return m ? m[0] : s.slice(0, 12)
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ export default function Shell({ api }) {
|
||||
|
||||
setTabs(prev => {
|
||||
const next = prev.filter(t => t.id !== tabId)
|
||||
if (activeTab === tabId) {
|
||||
if (activeTab === tabId && next.length > 0) {
|
||||
setActiveTab(next[next.length - 1].id)
|
||||
}
|
||||
return next
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const MSG_ID = () => Math.random().toString(36).slice(2, 10)
|
||||
|
||||
function parsePlanBlocks(text) {
|
||||
const plans = []
|
||||
const regex = /(?:^|\n)(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN[^\]]*\]?:?\s*(.*?)(?=\n(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN|\n## |\n### |\n$)/gis
|
||||
const matches = text.matchAll(regex)
|
||||
for (const m of matches) {
|
||||
plans.push({ id: MSG_ID(), title: m[1].trim(), content: m[0].trim() })
|
||||
}
|
||||
if (plans.length === 0 && /plan|workflow/i.test(text)) {
|
||||
const lines = text.split('\n').filter(l => /^\s*[-*]\s|^\s*\d+\.\s/.test(l))
|
||||
if (lines.length > 0) {
|
||||
plans.push({ id: MSG_ID(), title: text.split('\n')[0].slice(0, 80), content: text.trim() })
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
function parseAgentMentions(text) {
|
||||
const agents = new Set()
|
||||
const names = ['crush', 'claude', 'claude code', 'ollama', 'copilot', 'cursor', 'agent']
|
||||
for (const name of names) {
|
||||
if (new RegExp('\\b' + name + '\\b', 'i').test(text)) {
|
||||
agents.add(name)
|
||||
}
|
||||
}
|
||||
return [...agents]
|
||||
}
|
||||
|
||||
function parseSteps(text) {
|
||||
const steps = []
|
||||
const lines = text.split('\n')
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(\d+)[.)]\s+(.+)/)
|
||||
if (match) {
|
||||
steps.push({ num: match[1], text: match[2].trim() })
|
||||
}
|
||||
const bulletMatch = line.match(/^\s*[-*]\s+(.+)/)
|
||||
if (bulletMatch) {
|
||||
steps.push({ num: String(steps.length + 1), text: bulletMatch[1].trim() })
|
||||
}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
function renderContent(text) {
|
||||
const parts = []
|
||||
let i = 0
|
||||
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||
let match
|
||||
let lastIndex = 0
|
||||
@@ -70,7 +24,7 @@ function renderContent(text) {
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
let html = text
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
@@ -78,25 +32,41 @@ function formatText(text) {
|
||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
||||
return html
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const plans = msg.role === 'ai' ? parsePlanBlocks(msg.content) : []
|
||||
const steps = msg.role === 'ai' ? parseSteps(msg.content) : []
|
||||
const agents = msg.role === 'ai' ? parseAgentMentions(msg.content) : []
|
||||
function FeedItem({ msg }) {
|
||||
const isUser = msg.role === 'user'
|
||||
const isSystem = msg.role === 'system'
|
||||
|
||||
const roleLabel = isUser ? null : isSystem ? null : (
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="feed-item system">
|
||||
<div className="feed-system-badge" />
|
||||
<div className="feed-system-text">{msg.content}</div>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`studio-msg ${msg.role}`}>
|
||||
{msg.role === 'ai' && (
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
{roleLabel}
|
||||
<div className="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
<div className="feed-content">
|
||||
{renderContent(msg.content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
@@ -108,53 +78,24 @@ function MessageBubble({ msg }) {
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'ai' && (plans.length > 0 || agents.length > 0) && (
|
||||
<div className="studio-msg-meta">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="studio-plan-chip" onClick={() => setExpanded(expanded === plan.id ? null : plan.id)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
{plan.title.slice(0, 60)}
|
||||
<span className="studio-expand-icon">{expanded === plan.id ? '\u25B2' : '\u25BC'}</span>
|
||||
</div>
|
||||
))}
|
||||
{agents.map(agent => (
|
||||
<span key={agent} className="studio-agent-tag">
|
||||
{agent}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && plans.find(p => p.id === expanded) && (
|
||||
<div className="studio-plan-detail">
|
||||
<div className="studio-plan-detail-header">{t('studio.planDetail')}</div>
|
||||
{steps.length > 0 && (
|
||||
<div className="studio-steps">
|
||||
{steps.map(step => (
|
||||
<div key={step.num} className="studio-step">
|
||||
<span className="studio-step-num">{step.num}</span>
|
||||
<span className="studio-step-text">{step.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-plan-raw">
|
||||
<pre>{plans.find(p => p.id === expanded).content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingMessage({ content }) {
|
||||
function StreamingItem({ content }) {
|
||||
return (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-msg-content">
|
||||
<div className="feed-body">
|
||||
<div className="feed-header">
|
||||
<span className="feed-role">IA</span>
|
||||
</div>
|
||||
<div className="feed-content">
|
||||
{renderContent(content).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
@@ -165,103 +106,8 @@ function StreamingMessage({ content }) {
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
|
||||
const { t } = useI18n()
|
||||
const [tab, setTab] = useState('plans')
|
||||
|
||||
const allPlans = []
|
||||
const allAgents = new Set()
|
||||
const activities = []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role === 'ai') {
|
||||
const plans = parsePlanBlocks(msg.content)
|
||||
for (const plan of plans) {
|
||||
if (!allPlans.find(p => p.title === plan.title)) {
|
||||
allPlans.push({ ...plan, msgIndex: i })
|
||||
}
|
||||
}
|
||||
parseAgentMentions(msg.content).forEach(a => allAgents.add(a))
|
||||
}
|
||||
activities.push({ role: msg.role, content: msg.content.slice(0, 100), time: msg.time })
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'plans', label: t('studio.plans'), count: allPlans.length },
|
||||
{ id: 'agents', label: t('studio.agents'), count: allAgents.size },
|
||||
{ id: 'activity', label: t('studio.activity'), count: activities.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="studio-context">
|
||||
<div className="studio-context-tabs">
|
||||
{tabs.map(t2 => (
|
||||
<div
|
||||
key={t2.id}
|
||||
className={`studio-context-tab ${tab === t2.id ? 'active' : ''}`}
|
||||
onClick={() => setTab(t2.id)}
|
||||
>
|
||||
{t2.label}
|
||||
{t2.count > 0 && <span className="studio-tab-count">{t2.count}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="studio-context-body">
|
||||
{tab === 'plans' && (
|
||||
allPlans.length > 0 ? (
|
||||
<div className="studio-plan-list">
|
||||
{allPlans.map(plan => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`studio-plan-item ${selectedPlan === plan.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectPlan(selectedPlan === plan.id ? null : plan.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<div className="studio-plan-item-text">{plan.title}</div>
|
||||
<span className="studio-plan-item-badge">{parseSteps(plan.content).length} {t('studio.steps')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noPlansYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'agents' && (
|
||||
allAgents.size > 0 ? (
|
||||
<div className="studio-agent-list">
|
||||
{[...allAgents].map(agent => (
|
||||
<div key={agent} className="studio-agent-item">
|
||||
<div className="studio-agent-dot" />
|
||||
<span className="studio-agent-name">{agent}</span>
|
||||
<span className="badge info">{t('studio.mentioned')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-empty">{t('studio.noAgentsYet')}</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'activity' && (
|
||||
<div className="studio-activity-list">
|
||||
{activities.map((act, i) => (
|
||||
<div key={i} className="studio-activity-item">
|
||||
<div className={`studio-activity-dot ${act.role}`} />
|
||||
<div className="studio-activity-text">
|
||||
{act.role === 'user' ? t('studio.you') + ': ' : 'AI: '}
|
||||
{act.content}{act.content.length >= 100 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -269,17 +115,32 @@ function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
|
||||
|
||||
export default function Studio({ api }) {
|
||||
const { t } = useI18n()
|
||||
const [messages, setMessages] = useState([
|
||||
{ id: MSG_ID(), role: 'ai', content: t('studio.welcomeNew'), time: new Date() },
|
||||
])
|
||||
const [messages, setMessages] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [selectedPlan, setSelectedPlan] = useState(null)
|
||||
const [showContext, setShowContext] = useState(true)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getChatHistory().then(data => {
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
setMessages(data.messages)
|
||||
} else {
|
||||
setMessages([
|
||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||
])
|
||||
}
|
||||
setLoaded(true)
|
||||
}).catch(() => {
|
||||
setMessages([
|
||||
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
|
||||
])
|
||||
setLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streaming])
|
||||
@@ -291,11 +152,26 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [input])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
await api.clearChat()
|
||||
setMessages([
|
||||
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
|
||||
])
|
||||
} catch {}
|
||||
}, [api, t])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || loading) return
|
||||
const text = input.trim()
|
||||
setInput('')
|
||||
const userMsg = { id: MSG_ID(), role: 'user', content: text, time: new Date() }
|
||||
|
||||
if (text === '/clear') {
|
||||
handleClear()
|
||||
return
|
||||
}
|
||||
|
||||
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
@@ -307,14 +183,24 @@ export default function Studio({ api }) {
|
||||
}).catch(() => {})
|
||||
|
||||
const finalContent = accumulated || t('studio.noResponse')
|
||||
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }])
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
time: new Date().toISOString(),
|
||||
}])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: `${t('studio.error')}: ${err.message}`, time: new Date() }])
|
||||
setMessages(prev => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'system',
|
||||
content: `${t('studio.error')}: ${err.message}`,
|
||||
time: new Date().toISOString(),
|
||||
}])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setStreaming('')
|
||||
}
|
||||
}, [input, loading, api, t])
|
||||
}, [input, loading, api, t, handleClear])
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -323,70 +209,66 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio-layout">
|
||||
<div className="studio-chat-area">
|
||||
<div className="studio-messages">
|
||||
{messages.map(msg => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
{streaming && <StreamingMessage content={streaming} />}
|
||||
{loading && !streaming && (
|
||||
<div className="studio-msg ai">
|
||||
<div className="studio-msg-avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div className="studio-msg-body">
|
||||
<div className="studio-thinking">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="studio-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')}
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
<div className="feed-loading">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className={`studio-sidebar ${showContext ? 'open' : ''}`}>
|
||||
<div className="studio-sidebar-header">
|
||||
<span>{t('studio.context')}</span>
|
||||
<button className="ghost sm studio-sidebar-toggle" onClick={() => setShowContext(!showContext)}>
|
||||
{showContext ? '\u203A' : '\u2039'}
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
{messages.map(msg => (
|
||||
<FeedItem key={msg.id} msg={msg} />
|
||||
))}
|
||||
{streaming && <StreamingItem content={streaming} />}
|
||||
{loading && !streaming && (
|
||||
<div className="feed-item assistant">
|
||||
<div className="feed-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="feed-body">
|
||||
<div className="feed-content">
|
||||
<div className="studio-thinking"><span /><span /><span /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="studio-input-area">
|
||||
<div className="studio-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('studio.placeholderNew')}
|
||||
disabled={loading}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="studio-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{showContext && (
|
||||
<ContextPanel
|
||||
messages={messages}
|
||||
selectedPlan={selectedPlan}
|
||||
onSelectPlan={setSelectedPlan}
|
||||
/>
|
||||
)}
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@ const en = {
|
||||
steps: 'steps',
|
||||
you: 'You',
|
||||
mentioned: 'mentioned',
|
||||
cleared: 'Conversation cleared.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
@@ -113,6 +114,13 @@ const en = {
|
||||
},
|
||||
|
||||
config: {
|
||||
panels: {
|
||||
profile: 'Profile',
|
||||
providers: 'AI Providers',
|
||||
updates: 'Updates',
|
||||
locale: 'Language & Keyboard',
|
||||
skills: 'Skills',
|
||||
},
|
||||
profile: 'Profile',
|
||||
name: 'Name',
|
||||
pseudo: 'Pseudo',
|
||||
@@ -155,7 +163,7 @@ const en = {
|
||||
version: 'Version',
|
||||
installed: 'Installed',
|
||||
missing: 'Missing',
|
||||
editProfile: 'Edit profile',
|
||||
editProfile: 'Edit',
|
||||
cancel: 'Cancel',
|
||||
editProvider: 'Configure',
|
||||
},
|
||||
|
||||
@@ -76,6 +76,7 @@ const fr = {
|
||||
steps: '\u00e9tapes',
|
||||
you: 'Vous',
|
||||
mentioned: 'mentionn\u00e9',
|
||||
cleared: 'Conversation effac\u00e9e.',
|
||||
},
|
||||
|
||||
shell: {
|
||||
@@ -113,6 +114,13 @@ const fr = {
|
||||
},
|
||||
|
||||
config: {
|
||||
panels: {
|
||||
profile: 'Profil',
|
||||
providers: 'Fournisseurs IA',
|
||||
updates: 'Mises \u00e0 jour',
|
||||
locale: 'Langue & Clavier',
|
||||
skills: 'Comp\u00e9tences',
|
||||
},
|
||||
profile: 'Profil',
|
||||
name: 'Nom',
|
||||
pseudo: 'Pseudo',
|
||||
@@ -155,7 +163,7 @@ const fr = {
|
||||
version: 'Version',
|
||||
installed: 'Install\u00e9',
|
||||
missing: 'Manquant',
|
||||
editProfile: 'Modifier le profil',
|
||||
editProfile: 'Modifier',
|
||||
editProvider: 'Configurer',
|
||||
cancel: 'Annuler',
|
||||
},
|
||||
|
||||
@@ -407,34 +407,93 @@ input::placeholder { color: var(--text-disabled); }
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
|
||||
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; position: relative; }
|
||||
.config-section { margin-bottom: 28px; }
|
||||
.config-section-title {
|
||||
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
|
||||
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
.config-window { display: flex; height: 100%; overflow: hidden; }
|
||||
|
||||
.config-sidebar {
|
||||
width: 180px; background: var(--bg-surface); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; padding: 12px 8px; gap: 2px; flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
|
||||
.field-row:last-child { border-bottom: none; }
|
||||
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
|
||||
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; }
|
||||
.field-value.empty { color: var(--text-disabled); font-style: italic; }
|
||||
.config-input { flex: 1; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; color: var(--text-primary); font-size: 13px; outline: none; font-family: var(--font-mono); }
|
||||
.config-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
|
||||
.config-form-actions { display: flex; gap: 8px; padding: 12px 0 0 152px; }
|
||||
.config-actions-row { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.config-stats { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.config-sidebar-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
|
||||
border-radius: var(--radius); font-size: 13px; font-weight: 500;
|
||||
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.config-sidebar-item:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.config-sidebar-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
|
||||
.config-sidebar-item svg { flex-shrink: 0; }
|
||||
|
||||
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.config-panel-header {
|
||||
padding: 20px 28px 0; flex-shrink: 0;
|
||||
}
|
||||
.config-panel-title {
|
||||
font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 0;
|
||||
}
|
||||
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
|
||||
|
||||
.config-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 20px 24px; margin-bottom: 16px;
|
||||
}
|
||||
.config-card-row {
|
||||
display: flex; align-items: center; padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border); gap: 16px;
|
||||
}
|
||||
.config-card-row:last-of-type { border-bottom: none; }
|
||||
.config-card-label { width: 130px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
|
||||
.config-card-value { color: var(--text-primary); font-size: 14px; flex: 1; }
|
||||
.config-card-value.mono { font-family: var(--font-mono); }
|
||||
.config-card-value:not(.mono)[style*="—"] { color: var(--text-disabled); font-style: italic; }
|
||||
.config-card-actions { display: flex; gap: 8px; padding-top: 16px; }
|
||||
|
||||
.config-form-field { margin-bottom: 14px; }
|
||||
.config-form-label { display: block; font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.config-form-input {
|
||||
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px 12px; color: var(--text-primary);
|
||||
font-size: 13px; font-family: var(--font-mono); outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.config-form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
|
||||
|
||||
.config-card-group { margin-bottom: 20px; }
|
||||
.config-card-group:last-child { margin-bottom: 0; }
|
||||
.config-card-group-label { display: block; font-size: 11px; font-weight: 700; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
|
||||
|
||||
.config-providers-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.provider-card-v2 {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 16px 20px; transition: border-color 0.2s;
|
||||
}
|
||||
.provider-card-v2:hover { border-color: var(--accent-dim); }
|
||||
.provider-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.provider-card-identity { display: flex; align-items: center; gap: 10px; }
|
||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
|
||||
.config-update-controls {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.config-update-stats { display: flex; gap: 8px; }
|
||||
.config-update-buttons { display: flex; gap: 8px; }
|
||||
|
||||
.config-update-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); }
|
||||
.config-update-row:hover { background: var(--bg-card); }
|
||||
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-radius: var(--radius); background: var(--bg-card); border: 1px solid var(--border); margin-bottom: 6px; }
|
||||
.config-update-row:hover { border-color: var(--accent-dim); }
|
||||
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
|
||||
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
||||
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
||||
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
|
||||
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.config-skill-row:last-child { border-bottom: none; }
|
||||
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
|
||||
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.config-toast {
|
||||
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
|
||||
@@ -442,15 +501,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px 16px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.provider-card:hover { border-color: var(--accent-dim); }
|
||||
.provider-info { display: flex; flex-direction: column; gap: 4px; }
|
||||
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
|
||||
.spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
|
||||
|
||||
@@ -480,11 +532,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
|
||||
|
||||
.dashboard-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
|
||||
}
|
||||
.dashboard-section:hover { border-color: var(--accent-dim); }
|
||||
.dashboard-section.full-width { grid-column: 1 / -1; }
|
||||
@@ -498,19 +547,6 @@ input::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.dashboard-tools { padding: 0; }
|
||||
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tool-compact-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 12px; border-radius: var(--radius);
|
||||
font-size: 13px; transition: background 0.1s;
|
||||
}
|
||||
.tool-compact-row:hover { background: var(--bg-card); }
|
||||
.badge.sm { padding: 1px 5px; font-size: 10px; }
|
||||
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
|
||||
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
|
||||
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
|
||||
|
||||
.dashboard-notifications { padding: 0; }
|
||||
.notif-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
@@ -543,142 +579,20 @@ input::placeholder { color: var(--text-disabled); }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.fade-in { animation: fadeIn 0.2s ease-out; }
|
||||
|
||||
/* ── Studio ── */
|
||||
.studio-layout { display: flex; height: 100%; overflow: hidden; }
|
||||
.studio-chat-area { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.studio-messages { flex: 1; overflow-y: auto; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.studio-msg { display: flex; gap: 10px; max-width: 85%; animation: fadeIn 0.2s ease-out; }
|
||||
.studio-msg.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.studio-msg.ai { align-self: flex-start; }
|
||||
|
||||
.studio-msg-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%; background: var(--accent-bg); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.studio-msg-body { display: flex; flex-direction: column; gap: 0; }
|
||||
.studio-msg-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
|
||||
.studio-code-block {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; margin: 8px 0;
|
||||
}
|
||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||
.studio-code-lang {
|
||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
|
||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||
|
||||
.studio-msg-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.studio-plan-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: var(--radius);
|
||||
background: var(--bg-card); border: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);
|
||||
cursor: pointer; transition: all 0.15s; user-select: none;
|
||||
}
|
||||
.studio-plan-chip:hover { border-color: var(--accent-dark); background: var(--bg-hover); color: var(--text-primary); }
|
||||
.studio-expand-icon { font-size: 9px; color: var(--text-tertiary); margin-left: 4px; }
|
||||
|
||||
.studio-agent-tag {
|
||||
display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 99px;
|
||||
background: rgba(68,138,255,0.12); color: var(--info); font-size: 11px; font-weight: 600;
|
||||
}
|
||||
|
||||
.studio-plan-detail {
|
||||
margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg-surface); overflow: hidden;
|
||||
}
|
||||
.studio-plan-detail-header { padding: 10px 14px; font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
|
||||
.studio-steps { display: flex; flex-direction: column; gap: 2px; padding: 8px 0; }
|
||||
.studio-step { display: flex; gap: 10px; align-items: baseline; padding: 4px 14px; }
|
||||
.studio-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 24px; }
|
||||
.studio-step-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
|
||||
.studio-plan-raw { padding: 8px 14px 12px; border-top: 1px solid var(--border); }
|
||||
.studio-plan-raw pre { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.5; }
|
||||
|
||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.studio-thinking { display: flex; gap: 4px; padding: 8px 0; }
|
||||
.studio-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); animation: bounce 1.2s ease-in-out infinite; }
|
||||
.studio-thinking span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
|
||||
|
||||
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
|
||||
.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;
|
||||
font-size: 14px; line-height: 1.5; border-radius: var(--radius);
|
||||
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||
font-family: var(--font-sans); outline: none; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.studio-input-row textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
|
||||
.studio-input-row textarea::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.studio-send-btn {
|
||||
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius); background: var(--accent); color: #fff; border: 1px solid var(--accent);
|
||||
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||
|
||||
/* ── Studio Sidebar ── */
|
||||
.studio-sidebar {
|
||||
width: 0; border-left: 1px solid var(--border); background: var(--bg-surface);
|
||||
overflow: hidden; transition: width 0.25s ease; flex-shrink: 0; display: flex; flex-direction: column;
|
||||
}
|
||||
.studio-sidebar.open { width: 300px; }
|
||||
|
||||
.studio-sidebar-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.studio-sidebar-header span { font-size: 13px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.studio-sidebar-toggle { font-size: 18px; padding: 0 6px; line-height: 1; }
|
||||
|
||||
.studio-context { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
|
||||
.studio-context-tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.studio-context-tab {
|
||||
flex: 1; padding: 9px 8px; font-size: 12px; font-weight: 600; color: var(--text-tertiary);
|
||||
cursor: pointer; text-align: center; transition: all 0.15s; border-bottom: 2px solid transparent; user-select: none;
|
||||
}
|
||||
.studio-context-tab:hover { color: var(--text-primary); background: var(--bg-card); }
|
||||
.studio-context-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.studio-tab-count { font-size: 10px; padding: 1px 5px; border-radius: 99px; background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono); margin-left: 4px; }
|
||||
|
||||
.studio-context-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
|
||||
.studio-plan-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-plan-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius);
|
||||
cursor: pointer; transition: all 0.15s; font-size: 13px; color: var(--text-secondary);
|
||||
}
|
||||
.studio-plan-item:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.studio-plan-item.active { background: var(--accent-bg); border-left: 2px solid var(--accent); }
|
||||
.studio-plan-item svg { flex-shrink: 0; color: var(--text-tertiary); }
|
||||
.studio-plan-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-plan-item-badge { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
.studio-agent-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-agent-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: var(--radius); }
|
||||
.studio-agent-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--info); flex-shrink: 0; }
|
||||
.studio-agent-name { font-size: 13px; color: var(--text-secondary); flex: 1; }
|
||||
|
||||
.studio-empty { display: flex; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-disabled); font-size: 12px; text-align: center; line-height: 1.6; }
|
||||
|
||||
.studio-activity-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.studio-activity-item { display: flex; gap: 8px; padding: 6px 10px; border-radius: var(--radius); font-size: 12px; }
|
||||
.studio-activity-item:hover { background: var(--bg-card); }
|
||||
.studio-activity-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||||
.studio-activity-dot.user { background: var(--accent-muted); }
|
||||
.studio-activity-dot.ai { background: var(--info); }
|
||||
.studio-activity-text { color: var(--text-tertiary); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* ── Studio Feed ── */
|
||||
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||
.feed-item:hover { background: var(--bg-card); }
|
||||
.feed-item.user { background: var(--bg-card); border-left: 3px solid var(--accent-muted); }
|
||||
.feed-item.assistant { }
|
||||
.feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; }
|
||||
.feed-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--accent-bg); color: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
||||
.feed-body { flex: 1; min-width: 0; }
|
||||
.feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user