Compare commits
15 Commits
v0.3.3-bet
...
v0.3.5-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf | ||
|
|
40ec493bae | ||
|
|
233368c954 | ||
|
|
00118f0803 | ||
|
|
167ab82978 | ||
|
|
a23c0c5b94 | ||
|
|
24b31b0b47 | ||
|
|
7ae4017672 | ||
|
|
8c540eba93 | ||
|
|
1074b019d3 | ||
|
|
2da0cf9421 | ||
|
|
9987a586e2 | ||
|
|
2827acfe96 |
@@ -170,7 +170,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Commit changelog
|
- name: Commit changelog
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
git config user.name "CI Bot"
|
git config user.name "CI Bot"
|
||||||
git config user.email "ci@legion-muyue.fr"
|
git config user.email "ci@legion-muyue.fr"
|
||||||
@@ -181,30 +181,45 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -ex
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
echo "Warning: GITEATOKEN not set, skipping release"
|
echo "Error: GITEA_TOKEN secret is not set"
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
BODY=$(cat /tmp/stable_changelog.md)
|
echo "Creating release ${VERSION} at ${API}"
|
||||||
RESPONSE=$(curl -s -X POST "${API}" \
|
|
||||||
|
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"tag_name\":\"${VERSION}\",
|
\"tag_name\":\"${VERSION}\",
|
||||||
\"target_commitish\":\"main\",
|
\"target_commitish\":\"main\",
|
||||||
\"name\":\"muyue ${VERSION}\",
|
\"name\":\"muyue ${VERSION}\",
|
||||||
\"body\":$(echo "$BODY" | jq -Rs .),
|
\"body\":${BODY},
|
||||||
\"draft\":false,
|
\"draft\":false,
|
||||||
\"prerelease\":false
|
\"prerelease\":false
|
||||||
}")
|
}")
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
echo "HTTP Status: ${HTTP_CODE}"
|
||||||
|
echo "Response: ${RESPONSE_BODY}"
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Failed to create release:"
|
echo "Failed to create release"
|
||||||
echo "$RESPONSE"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
@@ -212,8 +227,12 @@ jobs:
|
|||||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST "${UPLOAD_URL}" \
|
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-F "attachment=@${file};filename=${filename}" > /dev/null
|
-F "attachment=@${file};filename=${filename}")
|
||||||
|
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
|
||||||
|
if [ "$UPLOAD_CODE" != "201" ]; then
|
||||||
|
echo "Upload failed with status ${UPLOAD_CODE}"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
echo "Stable release ${VERSION} published!"
|
echo "Stable release ${VERSION} published!"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
@@ -76,12 +75,8 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
words := strings.Fields(content)
|
if ce.onChunk != nil {
|
||||||
for _, w := range words {
|
ce.onChunk(map[string]interface{}{"content": content})
|
||||||
chunk := w
|
|
||||||
if ce.onChunk != nil {
|
|
||||||
ce.onChunk(map[string]interface{}{"content": chunk})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finalContent = content
|
finalContent = content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -477,9 +478,58 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "zai":
|
case "zai":
|
||||||
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
|
if p.APIKey == "" {
|
||||||
q.Healthy = true
|
q.Error = "no API key"
|
||||||
q.Data = map[string]interface{}{"note": "crush-only"}
|
results = append(results, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var data map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &data) == nil {
|
||||||
|
if d, ok := data["data"].(map[string]interface{}); ok {
|
||||||
|
if limits, ok := d["limits"].([]interface{}); ok {
|
||||||
|
models := make([]map[string]interface{}, 0)
|
||||||
|
for _, l := range limits {
|
||||||
|
if lm, ok := l.(map[string]interface{}); ok {
|
||||||
|
name := "Z.AI"
|
||||||
|
if model, ok := lm["model"].(string); ok && model != "" {
|
||||||
|
name = model
|
||||||
|
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
|
||||||
|
name = t
|
||||||
|
}
|
||||||
|
usage, _ := lm["usage"].(float64)
|
||||||
|
remaining, _ := lm["remaining"].(float64)
|
||||||
|
limitVal, hasLimit := lm["limit"].(float64)
|
||||||
|
total := usage + remaining
|
||||||
|
if hasLimit && limitVal > 0 {
|
||||||
|
total = limitVal
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
models = append(models, map[string]interface{}{
|
||||||
|
"model": name,
|
||||||
|
"used": usage,
|
||||||
|
"total": total,
|
||||||
|
"remaining": remaining,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(models) > 0 {
|
||||||
|
q.Data = map[string]interface{}{"models": models}
|
||||||
|
q.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
case "claude", "anthropic":
|
case "claude", "anthropic":
|
||||||
// Claude Code n'a pas d'API externe, vérifier l'installation
|
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||||
claudePath := "/usr/bin/claude"
|
claudePath := "/usr/bin/claude"
|
||||||
@@ -516,10 +566,11 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
|||||||
shell = "zsh"
|
shell = "zsh"
|
||||||
}
|
}
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := strings.Split(string(data), "\n")
|
||||||
start := len(lines) - 25
|
start := len(lines) - 50
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
start = 0
|
start = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := len(lines) - 1; i >= start; i-- {
|
for i := len(lines) - 1; i >= start; i-- {
|
||||||
line := strings.TrimSpace(lines[i])
|
line := strings.TrimSpace(lines[i])
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
@@ -536,6 +587,15 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
base := strings.Fields(line)[0]
|
||||||
|
if len(base) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
|
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,3 +277,16 @@ Sois concret et technique. Le rapport sera utilisé comme contexte pour un assis
|
|||||||
"analysis": result,
|
"analysis": result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
analysis := LoadSystemAnalysis()
|
||||||
|
if analysis == "" {
|
||||||
|
writeJSON(w, map[string]interface{}{"analysis": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"analysis": analysis})
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
|
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
|
||||||
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
|
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
|
||||||
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
|
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
|
||||||
|
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
|
||||||
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
||||||
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
||||||
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||||
|
|||||||
@@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: pty started successfully")
|
log.Printf("terminal: pty started successfully")
|
||||||
defer func() {
|
|
||||||
ptmx.Close()
|
|
||||||
if cmd.Process != nil {
|
|
||||||
cmd.Process.Kill()
|
|
||||||
cmd.Wait()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
@@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 4096)
|
buf := make([]byte, 4096)
|
||||||
@@ -171,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
n, err := ptmx.Read(buf)
|
n, err := ptmx.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
conn.WriteMessage(websocket.CloseMessage,
|
|
||||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := conn.WriteJSON(wsMessage{
|
if err := conn.WriteJSON(wsMessage{
|
||||||
@@ -230,12 +222,11 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
KeyPath string `json:"key_path"`
|
||||||
KeyPath string `json:"key_path"`
|
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.3"
|
Version = "0.3.5"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const api = {
|
|||||||
getShellChatHistory: () => request('/shell/chat/history'),
|
getShellChatHistory: () => request('/shell/chat/history'),
|
||||||
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||||
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||||
|
getShellAnalysis: () => request('/shell/analysis'),
|
||||||
sendChat: (message, stream = true, onChunk, signal) => {
|
sendChat: (message, stream = true, onChunk, signal) => {
|
||||||
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 }) })
|
||||||
@@ -104,7 +105,7 @@ const api = {
|
|||||||
}).catch(reject)
|
}).catch(reject)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
message,
|
message,
|
||||||
cwd: context.cwd || '',
|
cwd: context.cwd || '',
|
||||||
@@ -119,6 +120,7 @@ const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export default function App() {
|
|||||||
|
|
||||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setActiveTab('shell')
|
||||||
|
window.addEventListener('navigate-to-shell', handler)
|
||||||
|
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||||
const installed = tools.filter(tool => tool.installed).length
|
const installed = tools.filter(tool => tool.installed).length
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
|
||||||
|
|
||||||
const PANELS = [
|
const PANELS = [
|
||||||
{ id: 'profile', icon: User },
|
{ id: 'profile', icon: User },
|
||||||
{ id: 'providers', icon: Brain },
|
{ id: 'providers', icon: Brain },
|
||||||
{ id: 'updates', icon: RefreshCw },
|
{ id: 'updates', icon: RefreshCw },
|
||||||
{ id: 'locale', icon: Globe },
|
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
{ id: 'system', icon: Monitor },
|
{ id: 'system', icon: Monitor },
|
||||||
]
|
]
|
||||||
@@ -29,8 +27,6 @@ export default function Config({ api }) {
|
|||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
|
|
||||||
const layouts = getLayoutList()
|
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
setConfig(d)
|
setConfig(d)
|
||||||
@@ -65,28 +61,15 @@ export default function Config({ api }) {
|
|||||||
setChecking(false)
|
setChecking(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateTool = async (tool) => {
|
const handleUpdateTool = (tool) => {
|
||||||
setUpdating(tool)
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
try {
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
|
||||||
await api.runUpdate(tool)
|
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(`${tool} ✓`)
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
const handleUpdateAll = () => {
|
||||||
setUpdating('__all__')
|
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
|
||||||
try {
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
await api.runUpdate('')
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(t('config.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
@@ -181,13 +164,6 @@ export default function Config({ api }) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activePanel === 'locale' && (
|
|
||||||
<PanelLocale
|
|
||||||
language={language} keyboard={keyboard} layouts={layouts}
|
|
||||||
api={api}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activePanel === 'skills' && (
|
{activePanel === 'skills' && (
|
||||||
<PanelSkills skillList={skillList} t={t} />
|
<PanelSkills skillList={skillList} t={t} />
|
||||||
)}
|
)}
|
||||||
@@ -333,39 +309,57 @@ function getFieldLabel(key, t) {
|
|||||||
|
|
||||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||||
const [validating, setValidating] = useState(null)
|
const [validating, setValidating] = useState(null)
|
||||||
const [validationStatus, setValidationStatus] = useState(null)
|
const [keyStatus, setKeyStatus] = useState({})
|
||||||
|
|
||||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
useEffect(() => {
|
||||||
setValidating(name)
|
providers.forEach(p => {
|
||||||
setValidationStatus(null)
|
if (p.apiKey && !keyStatus[p.name]) {
|
||||||
try {
|
validateKey(p)
|
||||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
} else if (!p.apiKey) {
|
||||||
setValidationStatus({ provider: name, valid: true })
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||||
} catch (err) {
|
|
||||||
const msg = err.message || ''
|
|
||||||
if (msg.includes('invalid_api_key')) {
|
|
||||||
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
|
|
||||||
} else {
|
|
||||||
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}, [providers])
|
||||||
|
|
||||||
|
const validateKey = async (p) => {
|
||||||
|
setValidating(p.name)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' })
|
||||||
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||||
|
} catch (err) {
|
||||||
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
}
|
}
|
||||||
setValidating(null)
|
setValidating(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||||
|
setValidating(name)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||||
|
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
|
||||||
|
} catch (err) {
|
||||||
|
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
|
}
|
||||||
|
setValidating(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-providers-list">
|
<div className="config-providers-list">
|
||||||
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
{displayed.map((p, i) => {
|
||||||
{providers.map((p, i) => {
|
|
||||||
const isEditing = editProvider === p.name
|
const isEditing = editProvider === p.name
|
||||||
const isValidationTarget = validationStatus?.provider === p.name
|
const currentModel = providerForm[p.name]?.model || p.model
|
||||||
|
const status = keyStatus[p.name]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="config-card provider-card-v2">
|
<div key={i} className="config-card provider-card-v2">
|
||||||
<div className="provider-card-top">
|
<div className="provider-card-top">
|
||||||
<div className="provider-card-identity">
|
<div className="provider-card-identity">
|
||||||
<span className="provider-card-name">{p.name}</span>
|
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||||
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
{status?.checked && status?.valid && <span className="badge ok">✓ {t('config.keyValid')}</span>}
|
||||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
{status?.checked && !status?.valid && <span className="badge error">✗ {status.error || t('config.keyInvalid')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<input
|
<input
|
||||||
className="config-form-input"
|
className="config-form-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t('config.tokenPlaceholder')}
|
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
|
||||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!isEditing) openProviderEdit(p)
|
if (!isEditing) openProviderEdit(p)
|
||||||
@@ -391,18 +385,18 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
|
||||||
>
|
>
|
||||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||||
</button>
|
</button>
|
||||||
{isValidationTarget && validationStatus?.valid && (
|
{isEditing && (
|
||||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
<div className="provider-card-model">
|
||||||
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>}
|
<span className="provider-card-model-label">{t('config.model')}</span>
|
||||||
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>}
|
<span className="provider-card-model-value">{p.model || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,7 +406,14 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||||
|
const handleInstallTool = (tool) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingTools = tools.filter(t => !t.installed)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
@@ -435,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{missingTools.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
|
||||||
|
<div className="config-update-list">
|
||||||
|
{missingTools.map((tool, i) => (
|
||||||
|
<div key={`miss-${i}`} className="config-update-row">
|
||||||
|
<div className="config-update-info">
|
||||||
|
<span className="config-update-name">{tool.name}</span>
|
||||||
|
<span className="config-update-versions">
|
||||||
|
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
onClick={() => handleInstallTool(tool.name)}
|
||||||
|
>
|
||||||
|
{t('config.install') || 'Installer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{updates.length === 0 ? (
|
{updates.length === 0 ? (
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||||
@@ -470,98 +495,7 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelLocale({ language, keyboard, layouts, api, t }) {
|
|
||||||
const { setLanguage, setKeyboard } = useI18n()
|
|
||||||
const [editLocale, setEditLocale] = useState(false)
|
|
||||||
const [draftLang, setDraftLang] = useState(language)
|
|
||||||
const [draftKbd, setDraftKbd] = useState(keyboard)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [toast, setToast] = useState(null)
|
|
||||||
|
|
||||||
const showToast = (msg) => {
|
|
||||||
setToast(msg)
|
|
||||||
setTimeout(() => setToast(null), 2500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await api.savePreferences({ language: draftLang, keyboard_layout: draftKbd })
|
|
||||||
setLanguage(draftLang)
|
|
||||||
setKeyboard(draftKbd)
|
|
||||||
setEditLocale(false)
|
|
||||||
showToast(t('config.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentLang = LANGUAGES.find(l => l.id === language)
|
|
||||||
const currentKbd = layouts.find(l => l.id === keyboard)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="config-profile-center">
|
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.language')}</span>
|
|
||||||
<span className="config-card-value">{currentLang?.name || language}</span>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-row">
|
|
||||||
<span className="config-card-label">{t('config.keyboardLayout')}</span>
|
|
||||||
<span className="config-card-value">{currentKbd?.name || keyboard}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{editLocale && (
|
|
||||||
<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
|
|
||||||
key={lang.id}
|
|
||||||
className={`chip ${draftLang === lang.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setDraftLang(lang.id)}
|
|
||||||
>
|
|
||||||
{lang.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-group">
|
|
||||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
|
||||||
<div className="chip-row">
|
|
||||||
{layouts.map(l => (
|
|
||||||
<div
|
|
||||||
key={l.id}
|
|
||||||
className={`chip ${draftKbd === l.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setDraftKbd(l.id)}
|
|
||||||
>
|
|
||||||
{l.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
|
|
||||||
{editLocale ? (
|
|
||||||
<>
|
|
||||||
<button className="primary sm" onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? t('config.saving') : t('config.save')}
|
|
||||||
</button>
|
|
||||||
<button className="ghost sm" onClick={() => setEditLocale(false)}>{t('config.cancel')}</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button className="primary sm" onClick={() => { setDraftLang(language); setDraftKbd(keyboard); setEditLocale(true) }}>{t('config.editProfile')}</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelSkills({ skillList, t }) {
|
function PanelSkills({ skillList, t }) {
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
@@ -644,7 +578,7 @@ function PanelSkills({ skillList, t }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PanelSystem({ api, t }) {
|
function PanelSystem({ api, t }) {
|
||||||
const [resetConfirm, setResetConfirm] = useState(false)
|
const [showResetModal, setShowResetModal] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
const showToast = (msg) => {
|
const showToast = (msg) => {
|
||||||
@@ -655,7 +589,7 @@ function PanelSystem({ api, t }) {
|
|||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
await api.resetConfig()
|
await api.resetConfig()
|
||||||
setResetConfirm(false)
|
setShowResetModal(false)
|
||||||
showToast(t('config.resetDone'))
|
showToast(t('config.resetDone'))
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -663,49 +597,66 @@ function PanelSystem({ api, t }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyStarship = async () => {
|
const handleApplyStarship = () => {
|
||||||
try {
|
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||||
await api.applyStarshipTheme('charm')
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
|
||||||
showToast(t('config.starshipApplied'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
|
|
||||||
|
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
|
||||||
<div className="config-card">
|
<div className="config-card">
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
{t('config.starshipApplied')}
|
Vérifie l'installation de starship et configure le thème charm via l'IA.
|
||||||
</div>
|
</div>
|
||||||
<button className="sm primary" onClick={handleApplyStarship}>
|
<button className="sm primary" onClick={handleApplyStarship}>
|
||||||
{t('config.applyStarship')}
|
{t('config.applyStarship')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="config-card" style={{ marginTop: 12 }}>
|
|
||||||
|
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||||
|
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
|
Zone Rouge
|
||||||
|
</div>
|
||||||
|
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
|
||||||
</div>
|
</div>
|
||||||
{resetConfirm ? (
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
<div>
|
Cette action supprimera toute votre configuration et relancera l'application.
|
||||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
</div>
|
||||||
{t('config.resetConfirm')}
|
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
|
||||||
|
{t('config.resetConfig')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showResetModal && (
|
||||||
|
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
|
||||||
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
|
||||||
|
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||||
|
{t('config.resetConfig')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div className="shell-modal-body">
|
||||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
|
||||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
{t('config.resetConfirm')}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>
|
||||||
|
Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shell-modal-footer">
|
||||||
|
<button className="ghost" onClick={() => setShowResetModal(false)}>{t('config.cancel')}</button>
|
||||||
|
<button className="danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
)}
|
||||||
{t('config.resetConfig')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
const [recentCmds, setRecentCmds] = useState([])
|
const [recentCmds, setRecentCmds] = useState([])
|
||||||
const [processes, setProcesses] = useState([])
|
const [processes, setProcesses] = useState([])
|
||||||
const [metrics, setMetrics] = useState(null)
|
const [metrics, setMetrics] = useState(null)
|
||||||
|
const [copiedIdx, setCopiedIdx] = useState(-1)
|
||||||
const cpuRef = useRef([])
|
const cpuRef = useRef([])
|
||||||
const memRef = useRef([])
|
const memRef = useRef([])
|
||||||
const netRxRef = useRef([])
|
const netRxRef = useRef([])
|
||||||
@@ -92,13 +93,14 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||||
const zai = (quota || []).find(p => p.name === 'zai')
|
const zai = (quota || []).find(p => p.name === 'zai')
|
||||||
|
|
||||||
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history']
|
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||||||
|
|
||||||
const topCmds = (() => {
|
const topCmds = (() => {
|
||||||
const counts = {}
|
const counts = {}
|
||||||
for (const c of recentCmds) {
|
for (const c of recentCmds) {
|
||||||
const base = c.cmd.split(/\s+/)[0]
|
const base = c.cmd.split(/\s+/)[0]
|
||||||
if (EXCLUDE_CMDS.includes(base) || !base) continue
|
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
|
||||||
|
if (!/^[a-zA-Z@.\/]/.test(base)) continue
|
||||||
counts[base] = (counts[base] || 0) + 1
|
counts[base] = (counts[base] || 0) + 1
|
||||||
}
|
}
|
||||||
return Object.entries(counts)
|
return Object.entries(counts)
|
||||||
@@ -158,10 +160,19 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{zai && (
|
{zai && zai.data?.models?.map((m, i) => (
|
||||||
|
<div key={i} className="dash-quota-row">
|
||||||
|
<span className="dash-quota-name">{String(m.model)}</span>
|
||||||
|
<div className="dash-bar">
|
||||||
|
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{zai && !zai.data?.models?.length && (
|
||||||
<div className="dash-quota-row">
|
<div className="dash-quota-row">
|
||||||
<span className="dash-quota-name">Z.AI</span>
|
<span className="dash-quota-name">Z.AI</span>
|
||||||
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
|
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{zai.error || 'no data'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||||
@@ -193,8 +204,8 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
{topCmds.length > 0 && (
|
{topCmds.length > 0 && (
|
||||||
<div className="dash-cmd-top">
|
<div className="dash-cmd-top">
|
||||||
{topCmds.map((c, i) => (
|
{topCmds.map((c, i) => (
|
||||||
<div key={i} className="dash-cmd-chip" onClick={() => navigator.clipboard.writeText(c.cmd)} title="Copier">
|
<div key={i} className={'dash-cmd-chip' + (copiedIdx === i ? ' dash-cmd-chip-copied' : '')} onClick={() => { navigator.clipboard.writeText(c.cmd); setCopiedIdx(i); setTimeout(() => setCopiedIdx(-1), 1200); }}>
|
||||||
<span className="dash-cmd-chip-name">{c.cmd}</span>
|
<span className="dash-cmd-chip-name">{copiedIdx === i ? '✓ Copié' : c.cmd}</span>
|
||||||
<span className="dash-cmd-chip-count">{c.count}×</span>
|
<span className="dash-cmd-chip-count">{c.count}×</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,12 +2,70 @@ import { useState, useRef, useEffect, useCallback } from 'react'
|
|||||||
import { Terminal as XTerm } from '@xterm/xterm'
|
import { Terminal as XTerm } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send } from 'lucide-react'
|
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } from 'lucide-react'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
const MAX_TABS = 7
|
const MAX_TABS = 7
|
||||||
const SHELL_MAX_TOKENS = 100000
|
const SHELL_MAX_TOKENS = 100000
|
||||||
|
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
||||||
|
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
|
||||||
|
|
||||||
|
function renderContent(text) {
|
||||||
|
const parts = []
|
||||||
|
const codeBlockRegex = /(```[\s\S]*?```)/g
|
||||||
|
let match
|
||||||
|
let lastIndex = 0
|
||||||
|
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
|
||||||
|
}
|
||||||
|
const full = match[1]
|
||||||
|
const firstNewline = full.indexOf('\n')
|
||||||
|
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
|
||||||
|
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
|
||||||
|
parts.push({ type: 'code', lang, content: code })
|
||||||
|
lastIndex = match.index + full.length
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
const remaining = text.slice(lastIndex)
|
||||||
|
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
||||||
|
if (openBlock) {
|
||||||
|
if (openBlock.index > 0) {
|
||||||
|
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
||||||
|
}
|
||||||
|
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
||||||
|
} else {
|
||||||
|
parts.push({ type: 'text', content: remaining })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatText(text) {
|
||||||
|
let html = text
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
||||||
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
||||||
|
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
||||||
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/data:/gi, '')
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
const THEMES = {
|
const THEMES = {
|
||||||
default: {
|
default: {
|
||||||
@@ -74,7 +132,7 @@ function createTerminal(container, settings = {}) {
|
|||||||
const theme = getTheme(settings.theme || 'default')
|
const theme = getTheme(settings.theme || 'default')
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: settings.fontSize || 14,
|
fontSize: settings.fontSize || 12,
|
||||||
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
theme,
|
theme,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
@@ -91,7 +149,7 @@ function createTerminal(container, settings = {}) {
|
|||||||
return { term, fitAddon }
|
return { term, fitAddon }
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket(term, fitAddon, initPayload) {
|
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
||||||
|
|
||||||
@@ -101,9 +159,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
|||||||
if (dims) {
|
if (dims) {
|
||||||
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
||||||
}
|
}
|
||||||
|
if (onStateChange) onStateChange(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let firstMessage = true
|
||||||
ws.addEventListener('message', (event) => {
|
ws.addEventListener('message', (event) => {
|
||||||
|
if (firstMessage) {
|
||||||
|
firstMessage = false
|
||||||
|
if (onFirstMessage) onFirstMessage()
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if (msg.type === 'output') {
|
if (msg.type === 'output') {
|
||||||
@@ -118,16 +182,12 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
|||||||
|
|
||||||
ws.addEventListener('close', () => {
|
ws.addEventListener('close', () => {
|
||||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||||
|
if (onStateChange) onStateChange(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.addEventListener('error', () => {
|
ws.addEventListener('error', () => {
|
||||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||||
})
|
if (onStateChange) onStateChange(false)
|
||||||
|
|
||||||
term.onData((data) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'input', data }))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
term.onResize(({ rows, cols }) => {
|
term.onResize(({ rows, cols }) => {
|
||||||
@@ -143,11 +203,34 @@ export default function Shell({ api }) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const tabsRef = useRef({})
|
const tabsRef = useRef({})
|
||||||
const nextIdRef = useRef(1)
|
const nextIdRef = useRef(1)
|
||||||
|
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
|
||||||
|
const activeTabRef = useRef(activeTab)
|
||||||
|
const pendingCommandsRef = useRef({})
|
||||||
|
|
||||||
const [tabs, setTabs] = useState([
|
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
||||||
|
|
||||||
|
const savedTabs = (() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TABS_STORAGE_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
return parsed.map(t => ({ ...t, connected: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const [tabs, setTabs] = useState(savedTabs || [
|
||||||
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
||||||
])
|
])
|
||||||
const [activeTab, setActiveTab] = useState(1)
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
|
if (savedTabs) {
|
||||||
|
return savedTabs[0]?.id || 1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
const [sshConnections, setSshConnections] = useState([])
|
const [sshConnections, setSshConnections] = useState([])
|
||||||
const [systemTerminals, setSystemTerminals] = useState([])
|
const [systemTerminals, setSystemTerminals] = useState([])
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
@@ -155,11 +238,13 @@ export default function Shell({ api }) {
|
|||||||
const [editingTab, setEditingTab] = useState(null)
|
const [editingTab, setEditingTab] = useState(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [terminalSettings, setTerminalSettings] = useState({
|
const [terminalSettings, setTerminalSettings] = useState({
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
|
||||||
|
|
||||||
const [sshForm, setSshForm] = useState({
|
const [sshForm, setSshForm] = useState({
|
||||||
name: '', host: '', port: 22, user: '', key_path: '',
|
name: '', host: '', port: 22, user: '', key_path: '',
|
||||||
})
|
})
|
||||||
@@ -170,13 +255,25 @@ export default function Shell({ api }) {
|
|||||||
const [aiTokens, setAiTokens] = useState(0)
|
const [aiTokens, setAiTokens] = useState(0)
|
||||||
const [aiAtLimit, setAiAtLimit] = useState(false)
|
const [aiAtLimit, setAiAtLimit] = useState(false)
|
||||||
const [analyzing, setAnalyzing] = useState(false)
|
const [analyzing, setAnalyzing] = useState(false)
|
||||||
|
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||||
|
const [analysisContent, setAnalysisContent] = useState('')
|
||||||
const aiMessagesRef = useRef(null)
|
const aiMessagesRef = useRef(null)
|
||||||
const aiLoadedRef = useRef(false)
|
const aiLoadedRef = useRef(false)
|
||||||
|
const aiLoadingRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||||
}, [aiMessages])
|
}, [aiMessages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getShellAnalysis?.().then(d => {
|
||||||
|
if (d?.analysis) setAnalysisContent(d.analysis)
|
||||||
|
}).catch(() => {
|
||||||
|
const stored = localStorage.getItem('shell_analysis')
|
||||||
|
if (stored) setAnalysisContent(stored)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (aiLoadedRef.current) return
|
if (aiLoadedRef.current) return
|
||||||
aiLoadedRef.current = true
|
aiLoadedRef.current = true
|
||||||
@@ -193,6 +290,11 @@ export default function Shell({ api }) {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maxId = tabs.reduce((max, t) => Math.max(max, t.id), 0)
|
||||||
|
nextIdRef.current = maxId + 1
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getTerminalSessions().then(d => {
|
api.getTerminalSessions().then(d => {
|
||||||
setSshConnections(d.ssh || [])
|
setSshConnections(d.ssh || [])
|
||||||
@@ -201,7 +303,7 @@ export default function Shell({ api }) {
|
|||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
if (d.terminal) {
|
if (d.terminal) {
|
||||||
setTerminalSettings({
|
setTerminalSettings({
|
||||||
fontSize: d.terminal.font_size || 14,
|
fontSize: d.terminal.font_size || 12,
|
||||||
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
||||||
theme: d.terminal.theme || 'default',
|
theme: d.terminal.theme || 'default',
|
||||||
})
|
})
|
||||||
@@ -215,10 +317,11 @@ export default function Shell({ api }) {
|
|||||||
const container = document.getElementById(`terminal-${tabId}`)
|
const container = document.getElementById(`terminal-${tabId}`)
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
|
const s = settingsRef.current
|
||||||
const { term, fitAddon } = createTerminal(container, {
|
const { term, fitAddon } = createTerminal(container, {
|
||||||
fontSize: terminalSettings.fontSize,
|
fontSize: s.fontSize,
|
||||||
fontFamily: terminalSettings.fontFamily,
|
fontFamily: s.fontFamily,
|
||||||
theme: terminalSettings.theme,
|
theme: s.theme,
|
||||||
})
|
})
|
||||||
|
|
||||||
let initPayload
|
let initPayload
|
||||||
@@ -239,20 +342,63 @@ export default function Shell({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
let disposed = false
|
||||||
|
|
||||||
ws.onopen = () => {
|
const saveBuffer = () => {
|
||||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
try {
|
||||||
|
const buf = term.buffer.active
|
||||||
|
const lines = []
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i)
|
||||||
|
if (line) lines.push(line.translateToString(true))
|
||||||
|
}
|
||||||
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||||
|
savedBuffers[tabId] = lines.join('\n')
|
||||||
|
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||||
|
} catch (e) { console.warn('[Shell] Buffer save failed:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
const onWsState = (connected) => {
|
||||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
if (disposed) return
|
||||||
|
if (!connected) saveBuffer()
|
||||||
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
const restoreBuffer = () => {
|
||||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
try {
|
||||||
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||||
|
if (savedBuffers[tabId]) {
|
||||||
|
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
|
||||||
|
term.write(savedBuffers[tabId])
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[Shell] Buffer restore failed:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer)
|
||||||
|
|
||||||
|
const clearBufferOnClear = () => {
|
||||||
|
try {
|
||||||
|
const buf = term.buffer.active
|
||||||
|
const lineY = buf.length - 1
|
||||||
|
const line = buf.getLine(lineY)
|
||||||
|
if (line) {
|
||||||
|
const text = line.translateToString(true).trim().toLowerCase()
|
||||||
|
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
|
||||||
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||||
|
delete savedBuffers[tabId]
|
||||||
|
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[Shell] Clear detection failed:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (data === '\r') clearBufferOnClear()
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'input', data }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
const el = document.getElementById(`terminal-${tabId}`)
|
const el = document.getElementById(`terminal-${tabId}`)
|
||||||
if (el && el.offsetParent !== null) {
|
if (el && el.offsetParent !== null) {
|
||||||
@@ -264,37 +410,104 @@ export default function Shell({ api }) {
|
|||||||
resizeObserver.observe(container)
|
resizeObserver.observe(container)
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
|
|
||||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
|
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
||||||
|
|
||||||
|
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
|
||||||
|
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
||||||
|
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
||||||
|
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
||||||
|
|
||||||
|
const pending = pendingCommandsRef.current[tabId]
|
||||||
|
if (pending && pending.length > 0) {
|
||||||
|
console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`)
|
||||||
|
for (const cmd of pending) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete pendingCommandsRef.current[tabId]
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = tabs.find(t => t.id === activeTab)
|
const tab = tabs.find(t => t.id === activeTab)
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
|
|
||||||
const container = document.getElementById(`terminal-${tab.id}`)
|
let cancelled = false
|
||||||
if (!container) return
|
const pending = []
|
||||||
|
|
||||||
if (!tabsRef.current[tab.id]) {
|
const tryInit = (attempt) => {
|
||||||
const timer = setTimeout(() => {
|
if (cancelled || attempt > 20) return
|
||||||
|
const shellCol = document.querySelector('.shell-terminal-col')
|
||||||
|
if (!shellCol || shellCol.offsetParent === null) {
|
||||||
|
pending.push(setTimeout(() => tryInit(attempt + 1), 150))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const container = document.getElementById(`terminal-${tab.id}`)
|
||||||
|
if (!container || container.offsetHeight === 0) {
|
||||||
|
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!tabsRef.current[tab.id]) {
|
||||||
initTerminal(tab.id, tab)
|
initTerminal(tab.id, tab)
|
||||||
requestAnimationFrame(() => {
|
}
|
||||||
const entry = tabsRef.current[tab.id]
|
|
||||||
if (entry) entry.fitAddon.fit()
|
|
||||||
})
|
|
||||||
}, 100)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
} else {
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return
|
||||||
const entry = tabsRef.current[tab.id]
|
const entry = tabsRef.current[tab.id]
|
||||||
if (entry) entry.fitAddon.fit()
|
if (entry) entry.fitAddon.fit()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tryInit(0)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
pending.forEach(clearTimeout)
|
||||||
|
}
|
||||||
}, [activeTab, tabs, initTerminal])
|
}, [activeTab, tabs, initTerminal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const entry = tabsRef.current[tab.id]
|
||||||
|
if (entry) {
|
||||||
|
const el = document.getElementById(`terminal-${tab.id}`)
|
||||||
|
if (el && el.offsetParent !== null) {
|
||||||
|
entry.fitAddon.fit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [tabs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const [tabId, entry] of Object.entries(tabsRef.current)) {
|
||||||
|
entry._markDisposed?.()
|
||||||
|
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
||||||
|
window.removeEventListener('resize', entry.onResize)
|
||||||
|
entry.resizeObserver?.disconnect()
|
||||||
|
entry.ws?.close()
|
||||||
|
entry.term?.dispose()
|
||||||
|
}
|
||||||
|
tabsRef.current = {}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||||
if (!e.altKey) return
|
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && e.shiftKey) {
|
||||||
|
const shellTab = document.querySelector('.shell-layout')
|
||||||
|
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = tabs.findIndex(t => t.id === activeTab)
|
||||||
|
const next = (idx + 1) % tabs.length
|
||||||
|
setActiveTab(tabs[next].id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const num = parseInt(e.key)
|
const num = parseInt(e.key)
|
||||||
if (num >= 1 && num <= tabs.length) {
|
if (num >= 1 && num <= tabs.length) {
|
||||||
@@ -309,8 +522,8 @@ export default function Shell({ api }) {
|
|||||||
const addLocalTab = (shell, name) => {
|
const addLocalTab = (shell, name) => {
|
||||||
if (tabs.length >= MAX_TABS) return
|
if (tabs.length >= MAX_TABS) return
|
||||||
const id = nextIdRef.current++
|
const id = nextIdRef.current++
|
||||||
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
|
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false }
|
||||||
setTabs(prev => [...prev, newTab])
|
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||||
setActiveTab(id)
|
setActiveTab(id)
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
}
|
}
|
||||||
@@ -328,25 +541,34 @@ export default function Shell({ api }) {
|
|||||||
key_path: conn.key_path || '',
|
key_path: conn.key_path || '',
|
||||||
connected: false,
|
connected: false,
|
||||||
}
|
}
|
||||||
setTabs(prev => [...prev, newTab])
|
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
|
||||||
setActiveTab(id)
|
setActiveTab(id)
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeTab = (tabId, e) => {
|
const closeTab = (tabId, e) => {
|
||||||
if (e) e.stopPropagation()
|
if (e) e.stopPropagation()
|
||||||
if (tabs.length <= 1) return
|
|
||||||
|
|
||||||
if (tabsRef.current[tabId]) {
|
const entry = tabsRef.current[tabId]
|
||||||
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
if (entry) {
|
||||||
window.removeEventListener('resize', onResize)
|
entry._markDisposed?.()
|
||||||
resizeObserver.disconnect()
|
entry.saveBuffer?.()
|
||||||
ws.close()
|
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
||||||
term.dispose()
|
window.removeEventListener('resize', entry.onResize)
|
||||||
|
entry.resizeObserver.disconnect()
|
||||||
|
entry.ws.close()
|
||||||
|
entry.term.dispose()
|
||||||
delete tabsRef.current[tabId]
|
delete tabsRef.current[tabId]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||||
|
delete savedBuffers[tabId]
|
||||||
|
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||||
|
} catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) }
|
||||||
|
|
||||||
setTabs(prev => {
|
setTabs(prev => {
|
||||||
|
if (prev.length <= 1) return prev
|
||||||
const next = prev.filter(t => t.id !== tabId)
|
const next = prev.filter(t => t.id !== tabId)
|
||||||
if (activeTab === tabId && next.length > 0) {
|
if (activeTab === tabId && next.length > 0) {
|
||||||
setActiveTab(next[next.length - 1].id)
|
setActiveTab(next[next.length - 1].id)
|
||||||
@@ -391,73 +613,115 @@ export default function Shell({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendToTerminal = useCallback((code) => {
|
const sendToTerminal = useCallback((code, tabId) => {
|
||||||
const tab = tabs.find(t => t.id === activeTab)
|
const targetId = tabId || activeTabRef.current
|
||||||
if (!tab) return
|
const entry = tabsRef.current[targetId]
|
||||||
const entry = tabsRef.current[tab.id]
|
if (!entry) {
|
||||||
if (!entry?.ws || entry.ws.readyState !== WebSocket.OPEN) return
|
console.warn(`[Shell] sendToTerminal: tab ${targetId} not ready. Queueing. tabsRef:`, Object.keys(tabsRef.current), 'activeTab:', activeTabRef.current, 'requested:', tabId)
|
||||||
|
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
||||||
|
pendingCommandsRef.current[targetId].push(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
console.warn(`[Shell] sendToTerminal: WS not open for tab ${targetId} (state=${entry.ws?.readyState}). Queueing.`)
|
||||||
|
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
||||||
|
pendingCommandsRef.current[targetId].push(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[Shell] sendToTerminal: tab ${targetId} ← ${code.length} chars`)
|
||||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||||
}, [tabs, activeTab])
|
}, [])
|
||||||
|
|
||||||
const handleAiSend = async () => {
|
const focusAiTerminal = useCallback(() => {
|
||||||
if (!aiInput.trim() || aiLoading || aiAtLimit) return
|
const entry = tabsRef.current[activeTabRef.current]
|
||||||
const text = aiInput.trim()
|
if (entry) entry.term.focus()
|
||||||
setAiInput('')
|
}, [])
|
||||||
|
|
||||||
if (text === '/clear') {
|
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
|
||||||
|
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
|
||||||
|
const trimmed = text.trim()
|
||||||
|
aiLoadingRef.current = true
|
||||||
|
|
||||||
|
if (!fromEvent) {
|
||||||
|
setAiInput('')
|
||||||
|
focusAiTerminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '/clear') {
|
||||||
try {
|
try {
|
||||||
await api.clearShellChat()
|
await api.clearShellChat()
|
||||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||||
setAiTokens(0)
|
setAiTokens(0)
|
||||||
setAiAtLimit(false)
|
setAiAtLimit(false)
|
||||||
} catch {}
|
} catch {}
|
||||||
|
aiLoadingRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text === '/help') {
|
if (trimmed === '/help') {
|
||||||
setAiMessages(prev => [...prev,
|
setAiMessages(prev => [...prev,
|
||||||
{ role: 'user', content: text },
|
{ role: 'user', content: trimmed },
|
||||||
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
||||||
])
|
])
|
||||||
|
aiLoadingRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
const currentTab = activeTabRef.current
|
||||||
|
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
|
||||||
|
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }])
|
||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
await api.sendShellChat(text, {}, true, (partial) => {
|
await api.sendShellChat(trimmed, {}, true, (partial) => {
|
||||||
accumulated = partial
|
accumulated = partial
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
|
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
setAiMessages(prev => {
|
setAiMessages(prev => {
|
||||||
const filtered = prev.filter(m => !m._streaming)
|
const filtered = prev.filter(m => !m._streaming)
|
||||||
return [...filtered, { role: 'assistant', content: accumulated }]
|
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
|
||||||
})
|
})
|
||||||
// Refresh token count
|
|
||||||
api.getShellChatHistory().then(d => {
|
api.getShellChatHistory().then(d => {
|
||||||
setAiTokens(d.tokens || 0)
|
setAiTokens(d.tokens || 0)
|
||||||
setAiAtLimit(d.at_limit || false)
|
setAiAtLimit(d.at_limit || false)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message.includes('context limit')) {
|
if (err.message?.includes('context limit')) {
|
||||||
setAiAtLimit(true)
|
setAiAtLimit(true)
|
||||||
}
|
}
|
||||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||||
}
|
}
|
||||||
setAiLoading(false)
|
setAiLoading(false)
|
||||||
}
|
aiLoadingRef.current = false
|
||||||
|
}, [api, t, aiAtLimit, focusAiTerminal])
|
||||||
|
|
||||||
|
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
const msg = e.detail?.message
|
||||||
|
if (!msg) return
|
||||||
|
setAiInput(msg)
|
||||||
|
setTimeout(() => _sendAiMessage(msg, true), 100)
|
||||||
|
}
|
||||||
|
window.addEventListener('ask-ai-terminal', handler)
|
||||||
|
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||||
|
}, [_sendAiMessage])
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
setAnalyzing(true)
|
setAnalyzing(true)
|
||||||
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
|
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
|
||||||
try {
|
try {
|
||||||
const d = await api.analyzeSystem()
|
const d = await api.analyzeSystem()
|
||||||
|
if (d.analysis) {
|
||||||
|
setAnalysisContent(d.analysis)
|
||||||
|
localStorage.setItem('shell_analysis', d.analysis)
|
||||||
|
}
|
||||||
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
|
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
|
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
|
||||||
@@ -585,15 +849,26 @@ export default function Shell({ api }) {
|
|||||||
<div className="shell-ai-col">
|
<div className="shell-ai-col">
|
||||||
<div className="ai-panel-header">
|
<div className="ai-panel-header">
|
||||||
<span>Analyste Système</span>
|
<span>Analyste Système</span>
|
||||||
<button
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
className="shell-analyze-btn"
|
<button
|
||||||
onClick={handleAnalyze}
|
className="shell-analyze-btn"
|
||||||
disabled={analyzing}
|
onClick={() => setShowAnalysis(true)}
|
||||||
title="Analyser le système"
|
disabled={!analysisContent}
|
||||||
>
|
title="Voir l'analyse"
|
||||||
<Search size={13} />
|
>
|
||||||
{analyzing ? '...' : 'Analyser'}
|
<Eye size={13} />
|
||||||
</button>
|
Analyse
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shell-analyze-btn"
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={analyzing}
|
||||||
|
title="Analyser le système"
|
||||||
|
>
|
||||||
|
<Search size={13} />
|
||||||
|
{analyzing ? '...' : 'Analyser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shell-ai-token-bar">
|
<div className="shell-ai-token-bar">
|
||||||
<div className="shell-ai-token-track">
|
<div className="shell-ai-token-track">
|
||||||
@@ -606,7 +881,7 @@ export default function Shell({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||||
{aiMessages.map((msg, i) => (
|
{aiMessages.map((msg, i) => (
|
||||||
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
|
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
|
||||||
))}
|
))}
|
||||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -622,6 +897,29 @@ export default function Shell({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showAnalysis && analysisContent && (
|
||||||
|
<div className="shell-modal-overlay" onClick={() => setShowAnalysis(false)}>
|
||||||
|
<div className="shell-analysis-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="shell-analysis-modal-header">
|
||||||
|
<span>Analyse Système</span>
|
||||||
|
<button className="shell-tab-close" onClick={() => setShowAnalysis(false)}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="shell-analysis-modal-body">
|
||||||
|
{renderContent(analysisContent).map((part, i) =>
|
||||||
|
part.type === 'code' ? (
|
||||||
|
<div key={i} className="shell-code-block">
|
||||||
|
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||||
|
<pre><code>{part.content}</code></pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showSshModal && (
|
{showSshModal && (
|
||||||
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
||||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
@@ -675,49 +973,41 @@ export default function Shell({ api }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShellAIMessage({ msg, sendToTerminal }) {
|
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||||
const parts = parseMarkdown(msg.content || '')
|
const content = msg.content || ''
|
||||||
|
|
||||||
|
if (role === 'user') {
|
||||||
|
return <div className={`ai-message user`}>{content}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'system') {
|
||||||
|
return <div className={`ai-message system`}>{content}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = renderContent(content)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`ai-message ${role}`}>
|
<div className={`ai-message assistant`}>
|
||||||
{parts.map((part, i) => {
|
{parts.map((part, i) => {
|
||||||
if (part.type === 'code') {
|
if (part.type === 'code') {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="shell-code-block">
|
<div key={i} className="shell-code-block">
|
||||||
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
||||||
<pre><code>{part.code}</code></pre>
|
<pre><code>{part.content}</code></pre>
|
||||||
<div className="shell-code-actions">
|
<div className="shell-code-actions">
|
||||||
<button onClick={() => navigator.clipboard.writeText(part.code)} title="Copier">
|
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
|
||||||
<Copy size={12} /> Copier
|
<Copy size={12} /> Copier
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => sendToTerminal(part.code)} title="Envoyer au terminal">
|
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
||||||
<Send size={12} /> Terminal
|
<Send size={12} /> Terminal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <span key={i}>{part.text}</span>
|
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMarkdown(text) {
|
|
||||||
const parts = []
|
|
||||||
const regex = /```(\w*)\n([\s\S]*?)```/g
|
|
||||||
let last = 0
|
|
||||||
let match
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
if (match.index > last) {
|
|
||||||
parts.push({ type: 'text', text: text.slice(last, match.index) })
|
|
||||||
}
|
|
||||||
parts.push({ type: 'code', lang: match[1] || '', code: match[2].replace(/\n$/, '') })
|
|
||||||
last = match.index + match[0].length
|
|
||||||
}
|
|
||||||
if (last < text.length) {
|
|
||||||
parts.push({ type: 'text', text: text.slice(last) })
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? parts : [{ type: 'text', text }]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,17 +47,24 @@ function renderContent(text) {
|
|||||||
lastIndex = match.index + full.length
|
lastIndex = match.index + full.length
|
||||||
}
|
}
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push({ type: 'text', content: text.slice(lastIndex) })
|
const remaining = text.slice(lastIndex)
|
||||||
|
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
||||||
|
if (openBlock) {
|
||||||
|
if (openBlock.index > 0) {
|
||||||
|
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
||||||
|
}
|
||||||
|
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
||||||
|
} else {
|
||||||
|
parts.push({ type: 'text', content: remaining })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatText(text) {
|
function formatText(text) {
|
||||||
// First escape HTML entities
|
|
||||||
let html = text
|
let html = text
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
// Apply markdown transformations (now with escaped brackets)
|
|
||||||
html = html
|
html = html
|
||||||
.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>')
|
||||||
@@ -66,10 +73,13 @@ function formatText(text) {
|
|||||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||||
|
.replace(/\n/g, '<br/>')
|
||||||
|
|
||||||
// Sanitize: remove event handlers and dangerous protocols
|
|
||||||
html = html
|
html = html
|
||||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||||
|
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
|
||||||
|
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
|
||||||
|
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||||
.replace(/javascript:/gi, '')
|
.replace(/javascript:/gi, '')
|
||||||
.replace(/data:/gi, '')
|
.replace(/data:/gi, '')
|
||||||
|
|
||||||
@@ -331,6 +341,20 @@ export default function Studio({ api }) {
|
|||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onTab = (e) => {
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
|
||||||
|
const feed = document.querySelector('.studio-feed-layout')
|
||||||
|
if (!feed?.closest('.tab-hidden')) {
|
||||||
|
e.preventDefault()
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onTab)
|
||||||
|
return () => window.removeEventListener('keydown', onTab)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto'
|
||||||
@@ -380,6 +404,14 @@ export default function Studio({ api }) {
|
|||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
setInput('')
|
setInput('')
|
||||||
|
|
||||||
|
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t)
|
||||||
|
|
||||||
|
if (text.startsWith('/') && !isSlashCommand(text)) {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (text === '/clear') {
|
if (text === '/clear') {
|
||||||
handleClear()
|
handleClear()
|
||||||
return
|
return
|
||||||
@@ -395,6 +427,7 @@ export default function Studio({ api }) {
|
|||||||
'- `/plan <objectif>` - Demander un plan structuré',
|
'- `/plan <objectif>` - Demander un plan structuré',
|
||||||
'- `/export` - Exporter la conversation en Markdown',
|
'- `/export` - Exporter la conversation en Markdown',
|
||||||
'- `/model` - Afficher le provider et modèle actifs',
|
'- `/model` - Afficher le provider et modèle actifs',
|
||||||
|
'- `/model change` - Basculer entre MiniMax et ZAI',
|
||||||
'',
|
'',
|
||||||
'## Tools disponibles',
|
'## Tools disponibles',
|
||||||
'- Terminal - Exécuter des commandes',
|
'- Terminal - Exécuter des commandes',
|
||||||
@@ -414,14 +447,37 @@ export default function Studio({ api }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text === '/model') {
|
if (text === '/model' || text === '/model change') {
|
||||||
api.getProviders().then(data => {
|
if (text === '/model change') {
|
||||||
const active = data.providers?.find(p => p.active)
|
api.getProviders().then(data => {
|
||||||
const modelMsg = active ? active.name : 'Aucun provider actif configuré'
|
const providers = data.providers || []
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
|
||||||
}).catch(() => {
|
const zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
if (!minimax || !zai) {
|
||||||
})
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const active = providers.find(p => p.active)
|
||||||
|
const activeName = active ? active.name.toUpperCase() : ''
|
||||||
|
const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX'
|
||||||
|
const target = switchTo === 'MINIMAX' ? minimax : zai
|
||||||
|
api.saveProvider({ name: target.name, active: true }).then(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
api.getProviders().then(data => {
|
||||||
|
const active = data.providers?.find(p => p.active)
|
||||||
|
const modelMsg = active ? `**${active.name}** — ${active.model}` : 'Aucun provider actif configuré'
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
||||||
|
}).catch(() => {
|
||||||
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||||
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,10 +595,38 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
handleSend()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
const ta = textareaRef.current
|
||||||
|
if (!ta) return
|
||||||
|
if (document.activeElement !== ta) {
|
||||||
|
ta.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const val = ta.value
|
||||||
|
const pos = ta.selectionStart
|
||||||
|
const before = val.slice(0, pos)
|
||||||
|
const afterSlash = before.match(/\/[\w ]*$/)
|
||||||
|
if (afterSlash) {
|
||||||
|
const partial = afterSlash[0]
|
||||||
|
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const completed = matches[0] + ' '
|
||||||
|
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||||
|
setInput(newText)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +737,7 @@ export default function Studio({ api }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="studio-input-hint">
|
<div className="studio-input-hint">
|
||||||
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model
|
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -393,6 +393,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||||
.connection-dot.off { background: var(--error); }
|
.connection-dot.off { background: var(--error); }
|
||||||
|
|
||||||
|
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
|
||||||
|
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
||||||
.shell-analyze-btn {
|
.shell-analyze-btn {
|
||||||
@@ -446,6 +449,21 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-code-actions button:last-child { border-right: none; }
|
.shell-code-actions button:last-child { border-right: none; }
|
||||||
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
.shell-analysis-modal {
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.shell-analysis-modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 700; font-size: 15px; color: var(--accent);
|
||||||
|
}
|
||||||
|
.shell-analysis-modal-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
||||||
|
color: var(--text-primary); word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-modal-overlay {
|
.shell-modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
@@ -522,6 +540,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
.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-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-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||||
|
.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||||
|
.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); }
|
||||||
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
.provider-setup-hint {
|
.provider-setup-hint {
|
||||||
@@ -605,7 +626,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg); padding: 14px 16px;
|
border-radius: var(--radius-lg); padding: 14px 16px;
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; justify-content: center; gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,6 +715,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
cursor: pointer; transition: all 0.15s;
|
cursor: pointer; transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
|
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
|
||||||
|
.dash-cmd-chip-copied { border-color: var(--accent) !important; background: var(--accent-bg) !important; }
|
||||||
|
.dash-cmd-chip-copied .dash-cmd-chip-name { color: var(--accent); }
|
||||||
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
||||||
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
|
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
|
||||||
|
|
||||||
@@ -808,7 +831,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
.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-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-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; }
|
||||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||||
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
||||||
@@ -868,11 +891,11 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
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); }
|
.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-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 1px 0; }
|
||||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
.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 { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||||
.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; }
|
.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; } }
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user