Compare commits
45 Commits
v0.3.3-bet
...
v0.3.5-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a218b1904 | ||
|
|
399b845e14 | ||
|
|
436d5c6149 | ||
|
|
5a9edc076e | ||
|
|
5bdc7a6429 | ||
|
|
5a0480bae0 | ||
|
|
80de4dd523 | ||
|
|
de52f4ebd6 | ||
|
|
98ff0dd578 | ||
|
|
9a1ff6e8dc | ||
|
|
034b9ee0e4 | ||
|
|
c1b1fc653f | ||
|
|
50ca75180c | ||
|
|
b8aa935bec | ||
|
|
5627ddd2ce | ||
|
|
d27872572a | ||
|
|
7d0f807fb0 | ||
|
|
cbf623b98b | ||
|
|
b85ebb8e54 | ||
|
|
7cc206dc20 | ||
|
|
bf8c0fd380 | ||
|
|
08dc1fd53b | ||
|
|
13e937a11b | ||
|
|
3cf701b002 | ||
|
|
3a09e0e0c2 | ||
|
|
47fa2e01bb | ||
|
|
401292ec5b | ||
|
|
199a7e409a | ||
|
|
c91931f42f | ||
|
|
cbbb224725 | ||
|
|
8d10d2182e | ||
|
|
e9696ef82b | ||
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf | ||
|
|
40ec493bae | ||
|
|
233368c954 | ||
|
|
00118f0803 | ||
|
|
167ab82978 | ||
|
|
a23c0c5b94 | ||
|
|
24b31b0b47 | ||
|
|
7ae4017672 | ||
|
|
8c540eba93 | ||
|
|
1074b019d3 | ||
|
|
2da0cf9421 |
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Commit changelog
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git config user.name "CI Bot"
|
||||
git config user.email "ci@legion-muyue.fr"
|
||||
@@ -181,30 +181,45 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -ex
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "Warning: GITEATOKEN not set, skipping release"
|
||||
exit 0
|
||||
echo "Error: GITEA_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
BODY=$(cat /tmp/stable_changelog.md)
|
||||
RESPONSE=$(curl -s -X POST "${API}" \
|
||||
echo "Creating release ${VERSION} at ${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 "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\":\"${VERSION}\",
|
||||
\"target_commitish\":\"main\",
|
||||
\"name\":\"muyue ${VERSION}\",
|
||||
\"body\":$(echo "$BODY" | jq -Rs .),
|
||||
\"body\":${BODY},
|
||||
\"draft\":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
|
||||
echo "Failed to create release:"
|
||||
echo "$RESPONSE"
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
@@ -212,8 +227,12 @@ jobs:
|
||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||
filename=$(basename "$file")
|
||||
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}" \
|
||||
-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
|
||||
echo "Stable release ${VERSION} published!"
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
@@ -76,12 +75,8 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
if content != "" {
|
||||
words := strings.Fields(content)
|
||||
for _, w := range words {
|
||||
chunk := w
|
||||
if ce.onChunk != nil {
|
||||
ce.onChunk(map[string]interface{}{"content": chunk})
|
||||
}
|
||||
ce.onChunk(map[string]interface{}{"content": content})
|
||||
}
|
||||
finalContent = content
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -477,9 +478,63 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
case "zai":
|
||||
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
|
||||
if p.APIKey == "" {
|
||||
q.Error = "no API key"
|
||||
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
|
||||
q.Data = map[string]interface{}{"note": "crush-only"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "mimo":
|
||||
q.Healthy = p.APIKey != ""
|
||||
if p.APIKey == "" {
|
||||
q.Error = "no API key"
|
||||
}
|
||||
case "claude", "anthropic":
|
||||
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||
claudePath := "/usr/bin/claude"
|
||||
@@ -516,10 +571,11 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||
shell = "zsh"
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
start := len(lines) - 25
|
||||
start := len(lines) - 50
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
for i := len(lines) - 1; i >= start; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
@@ -536,6 +592,15 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||
if line == "" {
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,3 +277,16 @@ Sois concret et technique. Le rapport sera utilisé comme contexte pour un assis
|
||||
"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/clear", s.handleShellChatClear)
|
||||
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/list", s.handleWorkflowList)
|
||||
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||
|
||||
@@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
@@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
})
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
@@ -171,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
@@ -234,7 +226,6 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
|
||||
@@ -269,6 +269,12 @@ func Default() *MuyueConfig {
|
||||
BaseURL: "https://api.minimax.io/v1",
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Name: "mimo",
|
||||
Model: "MiMo-V2.5-Pro",
|
||||
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||
Active: false,
|
||||
},
|
||||
{
|
||||
Name: "zai",
|
||||
Model: "glm",
|
||||
|
||||
@@ -476,6 +476,8 @@ func getProviderBaseURL(name string) string {
|
||||
return "https://api.openai.com/v1"
|
||||
case "zai":
|
||||
return "https://api.z.ai/v1"
|
||||
case "mimo":
|
||||
return "https://token-plan-ams.xiaomimimo.com/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -503,11 +505,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
||||
if o.provider != nil {
|
||||
providerOrder = append(providerOrder, o.provider)
|
||||
}
|
||||
var zaiProvider *config.AIProvider
|
||||
for _, p := range providers {
|
||||
if o.provider == nil || p.Name != o.provider.Name {
|
||||
if p.Name == "zai" {
|
||||
zaiProvider = p
|
||||
} else {
|
||||
providerOrder = append(providerOrder, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if zaiProvider != nil {
|
||||
providerOrder = append(providerOrder, zaiProvider)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var triedProviders []string
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.3.3"
|
||||
Version = "0.3.5"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
|
||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
48
web/package-lock.json
generated
48
web/package-lock.json
generated
@@ -7,8 +7,12 @@
|
||||
"name": "muyue-web",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||
"@xterm/xterm": "^6.1.0-beta.203",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
@@ -406,16 +410,52 @@
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-image": {
|
||||
"version": "0.10.0-beta.203",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.203.tgz",
|
||||
"integrity": "sha512-1hRy7/jYCYvUhc6GYu177EdsW44QQQHsq71Odvo6cEhHKEEoqFsrOnLpe9WuNWZXgqpCwy2Cnp6FepHm960Eiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.17.0-beta.203",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.203.tgz",
|
||||
"integrity": "sha512-agxzh30h4L82kjGlTwWEsaXnXzOuMIAm80+zcNElFL/hHuT/nLvcwRng+s7RzOWNNLG3pB4jbTHqbBaM+nW8mg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.10.0-beta.203",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.203.tgz",
|
||||
"integrity": "sha512-KqMOqqpeEPQw5TQLb8jNHPESjZSwenFzhBPNA1g2zcPY5JtZ15pFzzoFxXdzS5LYmdYxexpd8s2ianf8WmQKyg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.20.0-beta.202",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.202.tgz",
|
||||
"integrity": "sha512-GCh0QlUv77XX8cJt8/7AVdDUNFpa1f6MGX/skhciu5ZRK88hR1m8T+8MZ3FYfddLV6phY0ksmiO9ErC0R+7G/A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^6.1.0-beta.203"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"version": "6.1.0-beta.203",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.203.tgz",
|
||||
"integrity": "sha512-Ctqf05M6fPWZkfKxC4hy2+PP5P2BlVnJLbIsXZMpkCz/MjJvcf5OwwsGkq+nzhFDuojSX+rc2RxIetLONUBGqw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||
"@xterm/xterm": "^6.1.0-beta.203",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
|
||||
@@ -60,6 +60,7 @@ const api = {
|
||||
getShellChatHistory: () => request('/shell/chat/history'),
|
||||
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||
getShellAnalysis: () => request('/shell/analysis'),
|
||||
sendChat: (message, stream = true, onChunk, signal) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
@@ -104,7 +105,7 @@ const api = {
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||
const payload = {
|
||||
message,
|
||||
cwd: context.cwd || '',
|
||||
@@ -119,6 +120,7 @@ const api = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
|
||||
@@ -76,6 +76,12 @@ export default function App() {
|
||||
|
||||
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 installed = tools.filter(tool => tool.installed).length
|
||||
|
||||
@@ -86,6 +92,12 @@ export default function App() {
|
||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||
],
|
||||
shell: [
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
||||
{ keys: `${layout.keys.ctrl}+F`, desc: t('statusbar.search') },
|
||||
{ keys: `${layout.keys.ctrl}+/Ctrl−`, desc: t('statusbar.zoom') },
|
||||
{ keys: `Alt+1-7`, desc: t('statusbar.switchTab') },
|
||||
{ keys: `${layout.keys.shift}+Tab`, desc: t('statusbar.nextTab') },
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||
],
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const PANELS = [
|
||||
{ id: 'profile', icon: User },
|
||||
{ id: 'providers', icon: Brain },
|
||||
{ id: 'updates', icon: RefreshCw },
|
||||
{ id: 'locale', icon: Globe },
|
||||
{ id: 'skills', icon: Wrench },
|
||||
{ id: 'system', icon: Monitor },
|
||||
]
|
||||
@@ -29,8 +27,6 @@ export default function Config({ api }) {
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
|
||||
const layouts = getLayoutList()
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
api.getConfig().then(d => {
|
||||
setConfig(d)
|
||||
@@ -65,28 +61,15 @@ export default function Config({ api }) {
|
||||
setChecking(false)
|
||||
}
|
||||
|
||||
const handleUpdateTool = async (tool) => {
|
||||
setUpdating(tool)
|
||||
try {
|
||||
await api.runUpdate(tool)
|
||||
await handleCheckUpdates()
|
||||
showToast(`${tool} ✓`)
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
const handleUpdateTool = (tool) => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
|
||||
}
|
||||
|
||||
const handleUpdateAll = async () => {
|
||||
setUpdating('__all__')
|
||||
try {
|
||||
await api.runUpdate('')
|
||||
await handleCheckUpdates()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
const handleUpdateAll = () => {
|
||||
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
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.` } }))
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
@@ -181,13 +164,6 @@ export default function Config({ api }) {
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'locale' && (
|
||||
<PanelLocale
|
||||
language={language} keyboard={keyboard} layouts={layouts}
|
||||
api={api}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'skills' && (
|
||||
<PanelSkills skillList={skillList} t={t} />
|
||||
)}
|
||||
@@ -333,33 +309,48 @@ function getFieldLabel(key, t) {
|
||||
|
||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||
const [validating, setValidating] = useState(null)
|
||||
const [validationStatus, setValidationStatus] = useState(null)
|
||||
const [keyStatus, setKeyStatus] = useState({})
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
setValidationStatus(null)
|
||||
const validateKey = async (p) => {
|
||||
setValidating(p.name)
|
||||
try {
|
||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||
setValidationStatus({ provider: name, valid: true })
|
||||
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) {
|
||||
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}` })
|
||||
}
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||
}
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
|
||||
useEffect(() => {
|
||||
providers.forEach(p => {
|
||||
if (p.apiKey && !keyStatus[p.name]) {
|
||||
validateKey(p)
|
||||
} else if (!p.apiKey) {
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||
}
|
||||
})
|
||||
}, [providers])
|
||||
|
||||
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 === 'mimo')
|
||||
|
||||
return (
|
||||
<div className="config-providers-list">
|
||||
{displayed.map((p, i) => {
|
||||
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 (
|
||||
<div key={i} className="config-card provider-card-v2">
|
||||
@@ -367,8 +358,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
<div className="provider-card-identity">
|
||||
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||
{isValidationTarget && validationStatus?.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 ok">✓ {t('config.keyValid')}</span>}
|
||||
{status?.checked && !status?.valid && <span className="badge error">✗ {status.error || t('config.keyInvalid')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,20 +394,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
||||
<span className="config-form-label">{t('config.model')}</span>
|
||||
<input
|
||||
className="config-form-input"
|
||||
value={currentModel || ''}
|
||||
onChange={e => {
|
||||
setProviderForm(prev => ({
|
||||
...prev,
|
||||
[p.name]: { ...(prev[p.name] || {}), model: e.target.value },
|
||||
}))
|
||||
setEditProvider(p.name)
|
||||
}}
|
||||
placeholder="model-name"
|
||||
/>
|
||||
<div className="provider-card-model">
|
||||
<span className="provider-card-model-label">{t('config.model')}</span>
|
||||
<span className="provider-card-model-value">{p.model || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -426,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(tool => !tool.installed)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="config-card">
|
||||
@@ -449,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
||||
</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 ? (
|
||||
<div className="config-card">
|
||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||
@@ -484,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 }) {
|
||||
const [selected, setSelected] = useState(null)
|
||||
@@ -658,7 +578,7 @@ function PanelSkills({ skillList, t }) {
|
||||
}
|
||||
|
||||
function PanelSystem({ api, t }) {
|
||||
const [resetConfirm, setResetConfirm] = useState(false)
|
||||
const [showResetModal, setShowResetModal] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const showToast = (msg) => {
|
||||
@@ -669,7 +589,7 @@ function PanelSystem({ api, t }) {
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await api.resetConfig()
|
||||
setResetConfirm(false)
|
||||
setShowResetModal(false)
|
||||
showToast(t('config.resetDone'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
@@ -677,49 +597,66 @@ function PanelSystem({ api, t }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyStarship = async () => {
|
||||
try {
|
||||
await api.applyStarshipTheme('charm')
|
||||
showToast(t('config.starshipApplied'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
const handleApplyStarship = () => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
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.` } }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{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-row" style={{ marginBottom: 16 }}>
|
||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
{t('config.starshipApplied')}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
Vérifie l'installation de starship et configure le thème charm via l'IA.
|
||||
</div>
|
||||
<button className="sm primary" onClick={handleApplyStarship}>
|
||||
{t('config.applyStarship')}
|
||||
</button>
|
||||
</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 }}>
|
||||
<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>
|
||||
{resetConfirm ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
||||
{t('config.resetConfirm')}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
Cette action supprimera toute votre configuration et relancera l'application.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
||||
<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 className="shell-modal-body">
|
||||
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
const [recentCmds, setRecentCmds] = useState([])
|
||||
const [processes, setProcesses] = useState([])
|
||||
const [metrics, setMetrics] = useState(null)
|
||||
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||
const cpuRef = useRef([])
|
||||
const memRef = useRef([])
|
||||
const netRxRef = useRef([])
|
||||
@@ -90,15 +91,16 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
}, [loadData, refreshRef])
|
||||
|
||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||
const zai = (quota || []).find(p => p.name === 'zai')
|
||||
const mimo = (quota || []).find(p => p.name === 'mimo')
|
||||
|
||||
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 counts = {}
|
||||
for (const c of recentCmds) {
|
||||
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
|
||||
}
|
||||
return Object.entries(counts)
|
||||
@@ -107,6 +109,32 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
.map(([cmd, count]) => ({ cmd, count }))
|
||||
})()
|
||||
|
||||
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||||
|
||||
const copyCmd = (cmd, key) => {
|
||||
navigator.clipboard.writeText(cmd)
|
||||
setCopiedSet(prev => new Set(prev).add(key))
|
||||
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||||
}
|
||||
|
||||
const relativeTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}s`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
|
||||
const recentUnique = (() => {
|
||||
const seen = new Set()
|
||||
return recentCmds.filter(c => {
|
||||
if (seen.has(c.cmd)) return false
|
||||
seen.add(c.cmd)
|
||||
return true
|
||||
})
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="dash-grid">
|
||||
{/* CPU */}
|
||||
@@ -158,13 +186,22 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
||||
</div>
|
||||
)}
|
||||
{zai && (
|
||||
{mimo && mimo.data?.models?.map((m, i) => (
|
||||
<div key={i} className="dash-quota-row">
|
||||
<span className="dash-quota-name">{String(m.model).replace('MiMo-', '')}</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>
|
||||
))}
|
||||
{mimo && !mimo.data?.models?.length && (
|
||||
<div className="dash-quota-row">
|
||||
<span className="dash-quota-name">Z.AI</span>
|
||||
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
|
||||
<span className="dash-quota-name">MiMo</span>
|
||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{mimo.error || (mimo.healthy ? '✓ configured' : 'no key')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||
{!minimax && !mimo && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,26 +223,34 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
</div>
|
||||
|
||||
{/* Recent Commands */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card dash-cmd-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Recent Commands</span>
|
||||
<span className="dash-count">{recentUnique.length}</span>
|
||||
</div>
|
||||
{topCmds.length > 0 && (
|
||||
<div className="dash-cmd-top">
|
||||
<div className="dash-cmd-freq">
|
||||
<span className="dash-cmd-freq-title">Most used</span>
|
||||
{topCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-chip" onClick={() => navigator.clipboard.writeText(c.cmd)} title="Copier">
|
||||
<span className="dash-cmd-chip-name">{c.cmd}</span>
|
||||
<span className="dash-cmd-chip-count">{c.count}×</span>
|
||||
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||||
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||||
<div className="dash-cmd-freq-bar-wrap">
|
||||
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||||
</div>
|
||||
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="dash-cmd-list">
|
||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
||||
<span className="dash-cmd-shell">{c.shell}</span>
|
||||
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
||||
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentUnique.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||||
<div className="dash-cmd-left">
|
||||
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||||
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||||
</div>
|
||||
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,17 +47,24 @@ function renderContent(text) {
|
||||
lastIndex = match.index + full.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
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
// First escape HTML entities
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
// Apply markdown transformations (now with escaped brackets)
|
||||
html = html
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
@@ -66,10 +73,13 @@ function formatText(text) {
|
||||
.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/>')
|
||||
|
||||
// Sanitize: remove event handlers and dangerous protocols
|
||||
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(/data:/gi, '')
|
||||
|
||||
@@ -187,7 +197,7 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
@@ -331,6 +341,20 @@ export default function Studio({ api }) {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [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(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
@@ -380,6 +404,14 @@ export default function Studio({ api }) {
|
||||
const text = input.trim()
|
||||
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') {
|
||||
handleClear()
|
||||
return
|
||||
@@ -395,6 +427,7 @@ export default function Studio({ api }) {
|
||||
'- `/plan <objectif>` - Demander un plan structuré',
|
||||
'- `/export` - Exporter la conversation en Markdown',
|
||||
'- `/model` - Afficher le provider et modèle actifs',
|
||||
'- `/model change` - Basculer entre MiniMax et ZAI',
|
||||
'',
|
||||
'## Tools disponibles',
|
||||
'- Terminal - Exécuter des commandes',
|
||||
@@ -414,14 +447,37 @@ export default function Studio({ api }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/model') {
|
||||
if (text === '/model' || text === '/model change') {
|
||||
if (text === '/model change') {
|
||||
api.getProviders().then(data => {
|
||||
const providers = data.providers || []
|
||||
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
|
||||
const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO')
|
||||
if (!minimax || !mimo) {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo 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' ? 'MIMO' : 'MINIMAX'
|
||||
const target = switchTo === 'MINIMAX' ? minimax : mimo
|
||||
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 : 'Aucun provider actif configuré'
|
||||
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
|
||||
}
|
||||
|
||||
@@ -476,6 +532,8 @@ export default function Studio({ api }) {
|
||||
if (event && event.tool_call) {
|
||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||
setStreamToolCalls([...toolCalls])
|
||||
accumulated = ''
|
||||
setStreaming('')
|
||||
return
|
||||
}
|
||||
if (event && event.tool_result) {
|
||||
@@ -502,6 +560,11 @@ export default function Studio({ api }) {
|
||||
aiMsg.content = JSON.stringify({
|
||||
content: finalContent,
|
||||
tool_calls: toolCalls.map(tc => tc.call),
|
||||
tool_results: toolCalls.map(tc => ({
|
||||
tool_call_id: tc.call?.tool_call_id,
|
||||
result: tc.result?.content || '',
|
||||
is_error: tc.result?.is_error || false,
|
||||
})),
|
||||
})
|
||||
}
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
@@ -539,10 +602,38 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
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 +744,7 @@ export default function Studio({ api }) {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -16,6 +16,12 @@ const en = {
|
||||
switchWindow: 'Switch window',
|
||||
sendMessage: 'Send message',
|
||||
newLine: 'New line',
|
||||
copy: 'Copy',
|
||||
paste: 'Paste',
|
||||
search: 'Search',
|
||||
zoom: 'Zoom +/−',
|
||||
switchTab: 'Switch tab',
|
||||
nextTab: 'Next tab',
|
||||
runCommand: 'Run command',
|
||||
commandHistory: 'Command history',
|
||||
},
|
||||
|
||||
@@ -16,6 +16,12 @@ const fr = {
|
||||
switchWindow: 'Changer de fen\u00eatre',
|
||||
sendMessage: 'Envoyer le message',
|
||||
newLine: 'Nouvelle ligne',
|
||||
copy: 'Copier',
|
||||
paste: 'Coller',
|
||||
search: 'Rechercher',
|
||||
zoom: 'Zoom +/\u2212',
|
||||
switchTab: 'Changer d\u2019onglet',
|
||||
nextTab: 'Onglet suivant',
|
||||
runCommand: 'Ex\u00e9cuter',
|
||||
commandHistory: 'Historique',
|
||||
},
|
||||
|
||||
@@ -155,7 +155,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||
|
||||
.content { flex: 1; overflow: hidden; position: relative; }
|
||||
.content > div { height: 100%; }
|
||||
.content > div { position: absolute; inset: 0; overflow: hidden; }
|
||||
.tab-hidden { display: none; }
|
||||
|
||||
.statusbar {
|
||||
@@ -276,8 +276,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.shell-layout { display: flex; height: 100%; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.shell-layout { display: flex; height: 100%; overflow: hidden; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||||
|
||||
.shell-tabs-bar {
|
||||
display: flex; align-items: center; background: var(--bg-surface);
|
||||
@@ -329,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
.shell-zoom-badge {
|
||||
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
|
||||
color: var(--accent); background: var(--accent-bg);
|
||||
padding: 2px 6px; border-radius: 3px;
|
||||
border: 1px solid var(--accent-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shell-new-tab-wrapper { position: relative; }
|
||||
.shell-new-tab-btn {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
@@ -382,18 +390,57 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
|
||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-instance {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
display: block !important;
|
||||
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
||||
|
||||
.shell-search-bar {
|
||||
position: absolute; top: 8px; right: 12px; z-index: 20;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 4px 6px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||
.shell-search-input {
|
||||
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
||||
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||
font-family: var(--font-mono); outline: none;
|
||||
}
|
||||
.shell-search-input:focus { border-color: var(--accent); }
|
||||
.shell-search-nav {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 4px;
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
|
||||
padding: 0; transition: all 0.1s;
|
||||
}
|
||||
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
|
||||
.shell-search-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 4px;
|
||||
background: transparent; border: none;
|
||||
color: var(--text-disabled); cursor: pointer; padding: 0;
|
||||
}
|
||||
.shell-search-close:hover { color: var(--accent); }
|
||||
.shell-xterm-instance {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.shell-xterm-instance.active {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; }
|
||||
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.connection-dot.off { background: var(--error); }
|
||||
|
||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
|
||||
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
|
||||
|
||||
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
||||
.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 {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
@@ -446,6 +493,21 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.shell-code-actions button:last-child { border-right: none; }
|
||||
.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 {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
@@ -522,6 +584,9 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||
.provider-card-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-setup-hint {
|
||||
@@ -605,7 +670,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
position: relative;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -670,32 +735,38 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
||||
.dash-cmd-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 3px 0; overflow: hidden;
|
||||
}
|
||||
.dash-cmd-shell {
|
||||
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
|
||||
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
|
||||
text-transform: uppercase; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface); cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.dash-cmd-text {
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
|
||||
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
|
||||
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
|
||||
|
||||
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||
.dash-cmd-freq-row {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 3px 4px; border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
|
||||
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
|
||||
|
||||
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.dash-cmd-chip {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border-radius: var(--radius);
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
|
||||
.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); }
|
||||
|
||||
/* Services */
|
||||
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||
@@ -808,7 +879,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-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-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
||||
@@ -868,11 +939,11 @@ input::placeholder { color: var(--text-disabled); }
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
||||
.msg-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: 8px 0 3px; display: block; }
|
||||
.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-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; }
|
||||
.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; } }
|
||||
@@ -1025,3 +1096,76 @@ input::placeholder { color: var(--text-disabled); }
|
||||
word-break: break-word;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* === XTerm Custom Styling === */
|
||||
/* Styles for xterm.js integrated with Muyue theme */
|
||||
.shell-xterm-instance .xterm {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport {
|
||||
background-color: var(--bg-base) !important;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-screen {
|
||||
background-color: var(--bg-base);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for xterm */
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-dim);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
.shell-xterm-instance .xterm-selection {
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* Focus ring styling */
|
||||
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Ensure consistent font rendering */
|
||||
.shell-xterm-instance .xterm .xterm-char-measure-element {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
/* Bell animation styling */
|
||||
.shell-xterm-instance .xterm-bell {
|
||||
animation: xterm-bell-flash 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes xterm-bell-flash {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Cursor styling */
|
||||
.shell-xterm-instance .xterm-cursor {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Link styling for web links addon */
|
||||
.shell-xterm-instance .xterm-link {
|
||||
color: var(--accent-light) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-link:hover {
|
||||
color: var(--accent-muted) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user