Compare commits

...

40 Commits

Author SHA1 Message Date
CI Bot
c39203cc4b chore: update CHANGELOG for v0.3.4 2026-04-23 21:14:19 +00:00
Augustin
869bf154cc Merge branch 'develop'
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-23 23:13:04 +02:00
Augustin
7ae4017672 fix(ci): replace jq with python3 in release step, add debug output
All checks were successful
Beta Release / beta (push) Successful in 40s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:13:03 +02:00
Augustin
52a785ec9a Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 34s
2026-04-23 23:08:10 +02:00
Augustin
8c540eba93 feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects
All checks were successful
Beta Release / beta (push) Successful in 49s
- Add dedicated AI Terminal tab (non-deletable) shared between user and AI
- Add Z.AI quota display on dashboard via /api/monitor/usage/quota/limit
- Add /model change command in Studio to toggle MiniMax/ZAI
- Apply Studio formatting (formatText, renderContent) to Shell AI messages
- Add render tick refresh for Shell (1s streaming, 5s idle)
- Add analysis viewer modal (Eye button) in Shell panel
- Fix multi-shell tab creation with retry init and settings ref
- Persist shell tabs to localStorage
- Fix line spacing in Studio (line-height 1.7→1.5, cleanup stray <br/>)
- Redirect Config updates to AI terminal via custom events
- Fix CI: delete existing release before recreating
- Bump version to 0.3.4

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:07:54 +02:00
Augustin
0b6d5281df Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 32s
2026-04-23 22:13:03 +02:00
Augustin
1074b019d3 feat(studio): Tab focuses textarea, autocomplete commands
All checks were successful
Beta Release / beta (push) Successful in 41s
- Tab outside textarea focuses it
- Tab inside textarea autocompletes / commands (/clear, /summarize, etc.)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:13:02 +02:00
Augustin
745e03d00a Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 31s
2026-04-23 22:10:55 +02:00
Augustin
2da0cf9421 fix(studio): convert newlines to <br/> in AI message rendering
All checks were successful
Beta Release / beta (push) Successful in 39s
formatText now replaces \n with <br/> so AI responses display
with proper line breaks instead of a single unbroken block.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:10:54 +02:00
Augustin
f88c7a4f3f Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 22:08:41 +02:00
Augustin
9987a586e2 fix(config): replace hardcoded model list with free text input
All checks were successful
Beta Release / beta (push) Successful in 41s
Removed PROVIDER_MODELS hardcoded map. Model is now a simple text
input pre-filled with the current model value.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:08:41 +02:00
Augustin
028fb364ba Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 22:06:22 +02:00
Augustin
2827acfe96 feat(config): providers panel shows only MINIMAX/ZAI with model selector
All checks were successful
Beta Release / beta (push) Successful in 42s
- Only MINIMAX and ZAI displayed (names in uppercase)
- Each provider shows selectable model chips (MiniMax-M2.7, glm-4, etc.)
- Save button always visible when editing, not just after validation
- Removed setup hint text

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:06:21 +02:00
Augustin
85edea9ed9 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Has been cancelled
2026-04-23 22:04:38 +02:00
Augustin
afb6e77c7f feat(dashboard): show top 5 most used commands as clickable chips
All checks were successful
Beta Release / beta (push) Successful in 43s
Top commands (excluding ls/cd/pwd/clear/exit/history) displayed as
large chips with usage count. Click to copy. Full history below.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:04:37 +02:00
Augustin
0232bd7afe Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:57:01 +02:00
Augustin
84be22661b fix: tab containers height, dashboard 2-row grid, studio scroll buttons
All checks were successful
Beta Release / beta (push) Successful in 41s
- .content > div now inherits full height so all tabs fill the viewport
- Dashboard grid uses grid-template-rows: repeat(2, 1fr) for 6 equal tiles
- Studio gets floating scroll-to-top / scroll-to-bottom buttons
- Wrapped studio-feed in scroll-wrap for proper overflow

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:57:00 +02:00
Augustin
49a0f5c8c3 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 31s
2026-04-23 21:50:06 +02:00
Augustin
d3755028fb Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:35:00 +02:00
Augustin
41cbee8928 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:30:02 +02:00
Augustin
1d521cbf90 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Has been cancelled
2026-04-23 21:29:08 +02:00
Augustin
d9d1ec5cb7 Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 33s
2026-04-23 21:20:31 +02:00
Augustin
45884ee75c Merge branch 'develop'
Some checks failed
Stable Release / stable (push) Failing after 32s
2026-04-23 21:14:56 +02:00
Augustin
6f7f588e51 merge develop: resolve conflicts, accept develop versions
Some checks failed
Stable Release / stable (push) Failing after 31s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:03:22 +02:00
Augustin
328e9e6457 feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
All checks were successful
Stable Release / stable (push) Successful in 39s
- Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh
- Add live CPU/RAM/Network SVG graphs with rolling 30-point history
- Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev
- Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring
- Add backend /api/recent-commands reading bash/zsh history
- Add backend /api/running-processes filtering editors/IDEs/languages
- Add sudo/root indicator ( ROOT) in footer when running as root
- Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side)
- Add Ctrl+R shortcut on dashboard for metrics-only refresh
- Make API key mandatory in onboarding, auto-scan editors via AI chat
- Remove manual editor input, only show AI-detected editors
- Bump version to 0.3.3

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
c81ebb4e46 feat(dashboard): add quota monitoring, process list, and command history
- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
b0865bc598 refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
- Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat)
- Add SendWithToolsStream for real-time streaming responses
- Add /help, /plan, /export, /model commands in Studio
- Fix XSS: sanitize HTML after markdown rendering
- Add ConversationStoreMulti for multi-conversation support
- Add Anthropic headers (x-api-key, anthropic-version)
- Add fallback logging when provider switch occurs
- Add API handler tests (handlers_test.go)
- Polish Studio: max-height 200px, word-break on tool args
- Update CLI version to show full info (version, go, platform)

🤖 Generated with Crush

Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
0d8e1b1e1a fix(studio): improve chat context, thinking tags, streaming, and tool results
- Fix cleanThinkingTags to use proper regex instead of naive ReplaceAll
- Send conversation history (last 20 messages + summary) to AI instead of single message
- Store tool results alongside tool calls so history shows complete execution info
- Stream words instead of characters for smoother SSE rendering
- Add stop button to cancel in-progress AI requests (AbortController)
- Fix markdown rendering: add h2 support, use div for bullets
- Add i18n keys for cancel/stop (EN + FR)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
485e085bb0 feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
Major changes:
- Refactor CLI entry point to Cobra commands (root, setup, scan, doctor, install, update, lsp, mcp, skills, config, version)
- Add LSP registry with health checks, auto-install, and editor config generation
- Add MCP registry with editor detection, status tracking, and per-editor configuration
- Add workflow engine with planner and step execution for automated task chains
- Add conversation search, export (Markdown/JSON), and detailed token counting
- Add streaming shell chat handler with tool call/result events
- Add skill validation, dry-run testing, and export endpoints
- Enrich dashboard with Tools/Activity/Status tabs and tool cards grid
- Add PRD documentation
- Complete i18n for both EN and FR

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
61da8039bc feat(agent): refactor AI chat with streaming, agent registry, and tool execution
- Replace old tool-call regex with proper agent registry
- Add streaming chat via SSE (handleStreamChat / handleNonStreamChat)
- Add internal/agent package with tool definitions and execution
- Add orchestrator with system prompt and tool scaffolding
- Add internal/agent/ directory
- Studio.jsx: streaming chat with thinking indicator and tool result rendering
- global.css: chat bubble styles, streaming animation, thinking dots
- handlers_chat.go: full rewrite using new agent/orchestrator architecture

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
65df15498b feat(onboarding): add minimax api key step and AI-powered editor scan
- Add apikey step in onboarding wizard (optional, with validation)
- Add ScanEditors() in scanner package detecting vim/nvim/code/emacs/nano/helix/subl/zed
- Add GET /api/editors endpoint
- Editor step now has scan button to detect installed editors via backend
- MiniMax API key is saved to provider config if provided

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
b6147ddb12 fix(onboarding): require fields before advancing steps
- Validate each step before allowing goNext
- Show required error message on name step if empty
- Clear error on input change

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
275a9a4cc7 fix: register missing /api/config/reset and /api/starship/apply-theme routes
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
e92a2f00f5 fix(config): per-provider form state to avoid field cross-talk
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
1f12b8a4fb fix(onboarding): auto-save on done step, keyboard nav, error feedback
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
Augustin
9188231a05 feat(config): add system panel with reset and starship theme, add onboarding wizard
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:47:00 +02:00
CI Bot
28e5113733 chore: update CHANGELOG for v0.3.2 2026-04-22 18:31:39 +00:00
Augustin
51a599fc83 chore: update CHANGELOG for v0.3.2-beta.1
All checks were successful
Stable Release / stable (push) Successful in 47s
💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:54 +02:00
Augustin
d8384cad00 merge develop into main for v0.3.2-beta.1 2026-04-22 20:29:46 +02:00
CI Bot
5b4a70e690 chore: update CHANGELOG for v0.3.1 2026-04-22 18:21:00 +00:00
13 changed files with 784 additions and 145 deletions

View File

@@ -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!"

View File

@@ -4,6 +4,196 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.3.4
### Changes since v0.3.3
- fix(ci): replace jq with python3 in release step, add debug output (7ae4017)
- feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects (8c540eb)
- feat(studio): Tab focuses textarea, autocomplete commands (1074b01)
- fix(studio): convert newlines to <br/> in AI message rendering (2da0cf9)
- fix(config): replace hardcoded model list with free text input (9987a58)
- feat(config): providers panel shows only MINIMAX/ZAI with model selector (2827acf)
- feat(dashboard): show top 5 most used commands as clickable chips (afb6e77)
- fix: tab containers height, dashboard 2-row grid, studio scroll buttons (84be226)
- feat(shell): dedicated System Analyst AI, no code execution, analyze system (f9c4cf1)
- fix: keep all tabs mounted, switch via CSS display instead of unmount (eda7293)
- refactor(config): locale panel with edit/save flow like profile (b55feae)
- feat(config): split profile into Personal Info + Preferences sections, centered (54621bd)
- feat(studio): improve context compression UI and provider display (6bad294)
- fix(config): locale panel show active language/keyboard, add save button (92eb783)
- feat(config): dynamic profile panel, generic save, tabs margin fix (8005e97)
- fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota (6e76e7d)
- feat(chat): add auto-summarization with token tracking UI (e8f6dc4)
- feat(dashboard): add background graphs to cards and improve layout (bb03c9f)
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (79d0821)
- feat(dashboard): add quota monitoring, process list, and command history (7682717)
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (3948a4c)
- fix(studio): improve chat context, thinking tags, streaming, and tool results (65804aa)
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (2e50366)
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (66b773f)
- feat(onboarding): add minimax api key step and AI-powered editor scan (bc5c295)
- fix(onboarding): require fields before advancing steps (e19122d)
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (8b6a7e8)
- fix(config): per-provider form state to avoid field cross-talk (58f8cb0)
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (b52fecc)
- feat(config): add system panel with reset and starship theme, add onboarding wizard (5bbac49)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.2
### Changes since v0.3.1
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
- chore: bump version to 3.2 (0fe82f6)
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
- feat(studio): add tool execution and hide AI thinking tags (12df184)
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
- feat(shell): restore AI assistant panel (4fd599a)
- fix(terminal): restore terminal input and cursor visibility (bcba593)
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.2-beta.1 (Beta)
### Commits since v0.3.1
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
> This is a **beta** release. Use at your own risk.
## v0.3.1
### Changes since v0.3.0
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
- feat(shell): restore AI assistant panel (2004c15)
- fix(terminal): restore terminal input and cursor visibility (9306152)
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.0 ## v0.3.0
### Changes since v0.2.1 ### Changes since v0.2.1

View File

@@ -477,9 +477,46 @@ 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.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 {
timeLimit := map[string]interface{}{}
for _, l := range limits {
if lm, ok := l.(map[string]interface{}); ok && lm["type"] == "TIME_LIMIT" {
usage, _ := lm["usage"].(float64)
remaining, _ := lm["remaining"].(float64)
total := usage + remaining
timeLimit = map[string]interface{}{
"model": "Z.AI",
"used": usage,
"total": total,
"remaining": remaining,
}
}
}
if len(timeLimit) > 0 {
q.Data = map[string]interface{}{"models": []map[string]interface{}{timeLimit}}
q.Healthy = true q.Healthy = true
q.Data = map[string]interface{}{"note": "crush-only"} }
}
}
}
}
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"

View File

@@ -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})
}

View File

@@ -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)

View File

@@ -7,7 +7,7 @@ import (
const ( const (
Name = "muyue" Name = "muyue"
Version = "0.3.3" Version = "0.3.4"
Author = "La Légion de Muyue" Author = "La Légion de Muyue"
) )

View File

@@ -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 }) })

View File

@@ -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

View File

@@ -65,28 +65,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 () => {
@@ -352,17 +339,20 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null) 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 isValidationTarget = validationStatus?.provider === p.name
const currentModel = providerForm[p.name]?.model || p.model
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>} {isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>} {isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
@@ -376,7 +366,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 +381,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>

View File

@@ -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,6 +93,21 @@ 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 topCmds = (() => {
const counts = {}
for (const c of recentCmds) {
const base = c.cmd.split(/\s+/)[0]
if (EXCLUDE_CMDS.includes(base) || !base) continue
counts[base] = (counts[base] || 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([cmd, count]) => ({ cmd, count }))
})()
return ( return (
<div className="dash-grid"> <div className="dash-grid">
{/* CPU */} {/* CPU */}
@@ -143,10 +159,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>}
@@ -175,6 +200,16 @@ export default function Dashboard({ api, refreshRef }) {
<div className="dash-card-head"> <div className="dash-card-head">
<span className="dash-label">Recent Commands</span> <span className="dash-label">Recent Commands</span>
</div> </div>
{topCmds.length > 0 && (
<div className="dash-cmd-top">
{topCmds.map((c, i) => (
<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">{copiedIdx === i ? '✓ Copié' : c.cmd}</span>
<span className="dash-cmd-chip-count">{c.count}×</span>
</div>
))}
</div>
)}
<div className="dash-cmd-list"> <div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>} {recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => ( {recentCmds.map((c, i) => (

View File

@@ -1,13 +1,62 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback, useMemo } 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, Bot } from 'lucide-react'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
const AI_TAB_ID = 0
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'
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) {
parts.push({ type: 'text', content: text.slice(lastIndex) })
}
return parts
}
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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: {
@@ -143,11 +192,32 @@ 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: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
const [tabs, setTabs] = useState([ 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: AI_TAB_ID, name: 'AI Terminal', type: 'ai', shell: '', connected: false, ai: true },
{ 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) {
const aiTab = savedTabs.find(t => t.ai)
return aiTab ? aiTab.id : savedTabs[0].id
}
return AI_TAB_ID
})
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)
@@ -160,6 +230,8 @@ export default function Shell({ api }) {
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,6 +242,9 @@ 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 [renderTick, setRenderTick] = useState(0)
const aiMessagesRef = useRef(null) const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false) const aiLoadedRef = useRef(false)
@@ -177,6 +252,21 @@ export default function Shell({ api }) {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages]) }, [aiMessages])
useEffect(() => {
const ms = aiLoading ? 1000 : 5000
const iv = setInterval(() => setRenderTick(t => t + 1), ms)
return () => clearInterval(iv)
}, [aiLoading])
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 +283,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 || [])
@@ -213,12 +308,13 @@ export default function Shell({ api }) {
if (tabsRef.current[tabId]) return if (tabsRef.current[tabId]) return
const container = document.getElementById(`terminal-${tabId}`) const container = document.getElementById(`terminal-${tabId}`)
if (!container) return if (!container || container.offsetHeight === 0) 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
@@ -271,26 +367,40 @@ export default function Shell({ api }) {
const tab = tabs.find(t => t.id === activeTab) const tab = tabs.find(t => t.id === activeTab)
if (!tab) return if (!tab) return
const tryInit = (attempt) => {
if (attempt > 10) return
const container = document.getElementById(`terminal-${tab.id}`) const container = document.getElementById(`terminal-${tab.id}`)
if (!container) return if (!container || container.offsetHeight === 0) {
setTimeout(() => tryInit(attempt + 1), 100)
return
}
if (!tabsRef.current[tab.id]) { if (!tabsRef.current[tab.id]) {
const timer = setTimeout(() => {
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(() => {
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit() if (entry) entry.fitAddon.fit()
}) })
} }
tryInit(0)
}, [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(() => { 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
@@ -309,8 +419,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, ai: t.ai || false, 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,14 +438,15 @@ 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, ai: t.ai || false, 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 const tab = tabs.find(t => t.id === tabId)
if (!tab || tab.ai || tabs.length <= 1) return
if (tabsRef.current[tabId]) { if (tabsRef.current[tabId]) {
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId] const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
@@ -392,17 +503,25 @@ export default function Shell({ api }) {
} }
const sendToTerminal = useCallback((code) => { const sendToTerminal = useCallback((code) => {
const tab = tabs.find(t => t.id === activeTab) const aiEntry = tabsRef.current[AI_TAB_ID]
if (!tab) return if (aiEntry?.ws && aiEntry.ws.readyState === WebSocket.OPEN) {
const entry = tabsRef.current[tab.id] aiEntry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
if (!entry?.ws || entry.ws.readyState !== WebSocket.OPEN) return }
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) }, [])
}, [tabs, activeTab])
const focusAiTerminal = useCallback(() => {
setActiveTab(AI_TAB_ID)
setTimeout(() => {
const entry = tabsRef.current[AI_TAB_ID]
if (entry) entry.term.focus()
}, 150)
}, [])
const handleAiSend = async () => { const handleAiSend = async () => {
if (!aiInput.trim() || aiLoading || aiAtLimit) return if (!aiInput.trim() || aiLoading || aiAtLimit) return
const text = aiInput.trim() const text = aiInput.trim()
setAiInput('') setAiInput('')
focusAiTerminal()
if (text === '/clear') { if (text === '/clear') {
try { try {
@@ -453,11 +572,73 @@ export default function Shell({ api }) {
setAiLoading(false) setAiLoading(false)
} }
useEffect(() => {
const handler = (e) => {
const msg = e.detail?.message
if (!msg) return
setAiInput(msg)
setActiveTab(AI_TAB_ID)
setTimeout(() => {
handleAiSendDirect(msg)
}, 100)
}
window.addEventListener('ask-ai-terminal', handler)
return () => window.removeEventListener('ask-ai-terminal', handler)
}, [])
const handleAiSendDirect = async (text) => {
if (!text || aiLoading || aiAtLimit) return
setAiInput('')
if (text === '/clear') {
try {
await api.clearShellChat()
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
setAiTokens(0)
setAiAtLimit(false)
} catch {}
return
}
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiLoading(true)
try {
let accumulated = ''
await api.sendShellChat(text, {}, true, (partial) => {
accumulated = partial
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
})
})
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated }]
})
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {})
} catch (err) {
if (err.message.includes('context limit')) {
setAiAtLimit(true)
}
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
}
setAiLoading(false)
}
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.'
@@ -476,13 +657,14 @@ export default function Shell({ api }) {
{tabs.map((tab, i) => ( {tabs.map((tab, i) => (
<div <div
key={tab.id} key={tab.id}
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`} className={`shell-tab ${activeTab === tab.id ? 'active' : ''} ${tab.ai ? 'ai-tab' : ''}`}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
onDoubleClick={(e) => startRename(tab.id, e)} onDoubleClick={(e) => !tab.ai && startRename(tab.id, e)}
> >
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} /> <span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
{tab.type === 'ssh' && <Globe size={12} />} {tab.ai && <Bot size={12} />}
{tab.type === 'local' && <Monitor size={12} />} {!tab.ai && tab.type === 'ssh' && <Globe size={12} />}
{!tab.ai && tab.type === 'local' && <Monitor size={12} />}
{editingTab === tab.id ? ( {editingTab === tab.id ? (
<input <input
className="shell-tab-rename" className="shell-tab-rename"
@@ -497,7 +679,7 @@ export default function Shell({ api }) {
<span className="shell-tab-name">{tab.name}</span> <span className="shell-tab-name">{tab.name}</span>
)} )}
<span className="shell-tab-index">{i + 1}</span> <span className="shell-tab-index">{i + 1}</span>
{tabs.length > 1 && ( {!tab.ai && tabs.length > 1 && (
<button <button
className="shell-tab-close" className="shell-tab-close"
onClick={(e) => closeTab(tab.id, e)} onClick={(e) => closeTab(tab.id, e)}
@@ -585,6 +767,16 @@ 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>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="shell-analyze-btn"
onClick={() => setShowAnalysis(true)}
disabled={!analysisContent}
title="Voir l'analyse"
>
<Eye size={13} />
Analyse
</button>
<button <button
className="shell-analyze-btn" className="shell-analyze-btn"
onClick={handleAnalyze} onClick={handleAnalyze}
@@ -595,6 +787,7 @@ export default function Shell({ api }) {
{analyzing ? '...' : 'Analyser'} {analyzing ? '...' : 'Analyser'}
</button> </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">
<div <div
@@ -606,7 +799,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}-${renderTick}`} msg={msg} sendToTerminal={sendToTerminal} renderTick={renderTick} />
))} ))}
{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 +815,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 +891,41 @@ export default function Shell({ api }) {
) )
} }
function ShellAIMessage({ msg, sendToTerminal }) { function ShellAIMessage({ msg, sendToTerminal, renderTick }) {
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}-${renderTick}`} 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)} 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}-${renderTick}`} 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 }]
}

View File

@@ -53,11 +53,9 @@ function renderContent(text) {
} }
function formatText(text) { function formatText(text) {
// First escape HTML entities
let html = text let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// 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 +64,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, '')
@@ -299,7 +300,9 @@ export default function Studio({ api }) {
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 }) const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const [contextCollapsed, setContextCollapsed] = useState(false) const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false) const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const [renderTick, setRenderTick] = useState(0)
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
const abortRef = useRef(null) const abortRef = useRef(null)
@@ -330,6 +333,26 @@ 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 ms = loading ? 1000 : 5000
const iv = setInterval(() => setRenderTick(t => t + 1), ms)
return () => clearInterval(iv)
}, [loading])
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'
@@ -379,6 +402,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
@@ -394,6 +425,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',
@@ -413,14 +445,37 @@ export default function Studio({ api }) {
return 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 zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
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 => { api.getProviders().then(data => {
const active = data.providers?.find(p => p.active) 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() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
}).catch(() => { }).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
}) })
}
return return
} }
@@ -538,10 +593,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
})
}
}
} }
} }
@@ -556,7 +639,7 @@ export default function Studio({ api }) {
return ( return (
<> <>
{messages.slice(0, visibleCount).map(msg => ( {messages.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} /> <FeedItem key={`${msg.id}-${renderTick}`} msg={msg} />
))} ))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}> <div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -569,7 +652,7 @@ export default function Studio({ api }) {
) )
} }
return messages.map(msg => ( return messages.map(msg => (
<FeedItem key={msg.id} msg={msg} /> <FeedItem key={`${msg.id}-${renderTick}`} msg={msg} />
)) ))
} }
@@ -587,13 +670,23 @@ export default function Studio({ api }) {
return ( return (
<div className="studio-feed-layout"> <div className="studio-feed-layout">
<div className="studio-feed"> <div className="studio-feed-scroll-wrap">
<div className="studio-feed" ref={feedRef}>
{renderMessages()} {renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && ( {(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} /> <StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)} )}
<div ref={messagesEnd} style={{ height: '24px' }} /> <div ref={messagesEnd} style={{ height: '24px' }} />
</div> </div>
<div className="studio-scroll-btns">
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
</div>
<div className="studio-input-area"> <div className="studio-input-area">
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}> <div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
@@ -642,7 +735,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>

View File

@@ -155,6 +155,7 @@ input::placeholder { color: var(--text-disabled); }
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; } .header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
.content { flex: 1; overflow: hidden; position: relative; } .content { flex: 1; overflow: hidden; position: relative; }
.content > div { height: 100%; }
.tab-hidden { display: none; } .tab-hidden { display: none; }
.statusbar { .statusbar {
@@ -392,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 {
@@ -445,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;
@@ -521,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 {
@@ -594,6 +616,7 @@ input::placeholder { color: var(--text-disabled); }
.dash-grid { .dash-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
height: 100%; height: 100%;
@@ -684,6 +707,19 @@ input::placeholder { color: var(--text-disabled); }
flex: 1; min-width: 0; flex: 1; min-width: 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-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-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
/* Services */ /* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; } .dash-services { display: flex; flex-direction: column; gap: 6px; }
.dash-svc-row { .dash-svc-row {
@@ -763,7 +799,17 @@ input::placeholder { color: var(--text-disabled); }
/* ── Studio Feed ── */ /* ── Studio Feed ── */
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; } .studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
.studio-scroll-btn {
width: 32px; height: 32px; border-radius: 50%; padding: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg-card); border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
opacity: 0.7;
}
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; } .feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; } .feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
.feed-item:hover { background: var(--bg-card); } .feed-item:hover { background: var(--bg-card); }
@@ -785,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; }
@@ -845,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; } }