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