Compare commits

..

28 Commits

Author SHA1 Message Date
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
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
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
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
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
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
f9c4cf11ff feat(shell): dedicated System Analyst AI, no code execution, analyze system
All checks were successful
Beta Release / beta (push) Successful in 45s
- New ShellConvStore with persistent history (shell_conversation.json)
- 100k token limit — input grays out, must /clear to continue
- Commands limited to /clear and /help only
- Shell AI has NO tools — read-only analysis, never executes code
- "Analyste Système" panel with system analysis button
- System analysis uses Studio AI to write system_analysis.md,
  prepended as context on every conversation start
- Code blocks show "Copier" and "Terminal" buttons to copy or
  send code directly to the active terminal via WebSocket
- Token bar shows usage with warning at 80%

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:50:06 +02:00
Augustin
eda7293286 fix: keep all tabs mounted, switch via CSS display instead of unmount
All checks were successful
Beta Release / beta (push) Successful in 42s
All 4 tabs (Dashboard, Studio, Shell, Config) are now always mounted
and toggled via .tab-hidden (display:none). This preserves:
- Dashboard graph history across tab switches
- Terminal session state and progress
- Studio chat context
- Config form state

Dashboard polling pauses after 3 ticks when hidden to save resources
and auto-resumes when the tab becomes visible again.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:34:59 +02:00
Augustin
b55feaed09 refactor(config): locale panel with edit/save flow like profile
All checks were successful
Beta Release / beta (push) Successful in 40s
Show current language and keyboard as read-only values, then
"Modifier" button opens chip selection, "Sauvegarder" persists
via API. Centered layout matching profile panel style.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:30:02 +02:00
Augustin
54621bd960 feat(config): split profile into Personal Info + Preferences sections, centered
All checks were successful
Beta Release / beta (push) Successful in 40s
- Profile panel now shows two distinct cards with section titles
- Personal Info (name, pseudo, email, languages) and Preferences
  (editor, shell, theme, etc.) are visually separated
- Content centered with max-width 540px
- Added i18n keys for section titles

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:29:07 +02:00
Augustin
6bad2948c5 feat(studio): improve context compression UI and provider display
All checks were successful
Beta Release / beta (push) Successful in 45s
- Add visual indicator when messages are collapsed (folder icon)
- Add animation to token bar during compression (pulse effect)
- Token bar becomes more compact after compression with "· compressé" label
- Button "voir plus" to expand collapsed messages
- Add 24px spacing at end of feed to avoid last message clipping
- Simplify provider display: show name only, badge "active" instead of key status
- Dashboard: show provider name only without model suffix
- Studio /model: show just provider name, not model
- Z.AI (GLM): mark as crush-only, no external quota check
- Claude: check /usr/bin/claude installation instead of API

💘 Generated with Crush
2026-04-23 21:21:59 +02:00
Augustin
92eb783df0 fix(config): locale panel show active language/keyboard, add save button
All checks were successful
Beta Release / beta (push) Successful in 41s
- Fix language prop passing keyboard value instead of language
- Add save button that persists language + keyboard_layout via API
- Add local toast for save confirmation

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:20:30 +02:00
Augustin
8005e978f0 feat(config): dynamic profile panel, generic save, tabs margin fix
All checks were successful
Beta Release / beta (push) Successful in 44s
- Config tabs now have bottom padding for visual spacing
- Profile panel dynamically renders all config fields (strings, bools,
  arrays, nested objects) — new struct fields appear automatically
- handleSaveProfile uses generic JSON merge via deepMerge, so any
  new Profile field works without handler changes
- RenderFields recursively renders config sections with edit/view modes

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:14:47 +02:00
Augustin
6e76e7dca6 fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota
All checks were successful
Beta Release / beta (push) Successful in 40s
Remove BgGraph background SVGs that were misaligned with foreground graphs.
Add max-height: 270px with overflow-y scroll to quota/processes/commands lists.
Change API quota display from remaining/total to used/total.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:02:53 +02:00
Augustin
e8f6dc4b4d feat(chat): add auto-summarization with token tracking UI
All checks were successful
Beta Release / beta (push) Successful in 47s
Add /summarize command, token usage bar, and summary endpoint.
Add JSON tags to config/platform/scanner structs for API serialization.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 20:42:43 +02:00
Augustin
bb03c9fe2d feat(dashboard): add background graphs to cards and improve layout
All checks were successful
Beta Release / beta (push) Successful in 39s
- Add BgGraph component for subtle background SVG graphs
- Add gradient fills to MiniGraph components
- Track process count over time
- Calculate total API quota usage
- Improve card styling with overlay content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:55:10 +02:00
Augustin
79d082180c feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
All checks were successful
Beta Release / beta (push) Successful in 46s
- 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:46:16 +02:00
Augustin
7682717093 feat(dashboard): add quota monitoring, process list, and command history
All checks were successful
Beta Release / beta (push) Successful in 44s
- 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:24:23 +02:00
Augustin
3948a4c656 refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
All checks were successful
Beta Release / beta (push) Successful in 2m23s
- 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-22 22:58:05 +02:00
Augustin
65804aae4e fix(studio): improve chat context, thinking tags, streaming, and tool results
All checks were successful
Beta Release / beta (push) Successful in 39s
- 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-22 22:37:45 +02:00
Augustin
2e50366cd8 feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard
All checks were successful
Beta Release / beta (push) Successful in 2m24s
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-22 22:22:05 +02:00
Augustin
66b773ff86 feat(agent): refactor AI chat with streaming, agent registry, and tool execution
All checks were successful
Beta Release / beta (push) Successful in 47s
- 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-22 21:19:36 +02:00
Augustin
bc5c2956b4 feat(onboarding): add minimax api key step and AI-powered editor scan
Some checks failed
Beta Release / beta (push) Failing after 22s
- 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-22 21:04:27 +02:00
Augustin
e19122dad9 fix(onboarding): require fields before advancing steps
All checks were successful
Beta Release / beta (push) Successful in 39s
- 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-22 20:58:36 +02:00
Augustin
8b6a7e8bc3 fix: register missing /api/config/reset and /api/starship/apply-theme routes
All checks were successful
Beta Release / beta (push) Successful in 40s
- 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-22 20:57:25 +02:00
Augustin
58f8cb0bd3 fix(config): per-provider form state to avoid field cross-talk
All checks were successful
Beta Release / beta (push) Successful in 38s
- 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-22 20:56:04 +02:00
Augustin
b52feccc17 fix(onboarding): auto-save on done step, keyboard nav, error feedback
All checks were successful
Beta Release / beta (push) Successful in 40s
- 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-22 20:53:12 +02:00
Augustin
5bbac499a7 feat(config): add system panel with reset and starship theme, add onboarding wizard
All checks were successful
Beta Release / beta (push) Successful in 41s
- 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-22 20:36:36 +02:00
19 changed files with 1294 additions and 670 deletions

View File

@@ -4,178 +4,6 @@ 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/).
## v0.3.3
### Changes since v0.3.2
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (328e9e6)
- feat(dashboard): add quota monitoring, process list, and command history (c81ebb4)
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (b0865bc)
- fix(studio): improve chat context, thinking tags, streaming, and tool results (0d8e1b1)
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (485e085)
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (61da803)
- feat(onboarding): add minimax api key step and AI-powered editor scan (65df154)
- fix(onboarding): require fields before advancing steps (b6147dd)
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (275a9a4)
- fix(config): per-provider form state to avoid field cross-talk (e92a2f0)
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (1f12b8a)
- feat(config): add system panel with reset and starship theme, add onboarding wizard (9188231)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.3/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.3/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.3/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.3/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
### Changes since v0.2.1

View File

@@ -206,8 +206,11 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox,
"summarize_at": summarizeThreshold,
"summary": s.convStore.GetSummary(),
})
}
@@ -219,3 +222,16 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.autoSummarize()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": s.convStore.ApproxTokenCount(),
"summary": s.convStore.GetSummary(),
})
}

View File

@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
Pseudo string `json:"pseudo"`
Email string `json:"email"`
Editor string `json:"editor"`
Shell string `json:"shell"`
currentJSON, err := json.Marshal(s.config.Profile)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
var currentMap map[string]interface{}
json.Unmarshal(currentJSON, &currentMap)
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name != "" {
s.config.Profile.Name = body.Name
}
if body.Pseudo != "" {
s.config.Profile.Pseudo = body.Pseudo
}
if body.Email != "" {
s.config.Profile.Email = body.Email
}
if body.Editor != "" {
s.config.Profile.Preferences.Editor = body.Editor
}
if body.Shell != "" {
s.config.Profile.Preferences.Shell = body.Shell
}
deepMerge(currentMap, updates)
mergedJSON, _ := json.Marshal(currentMap)
json.Unmarshal(mergedJSON, &s.config.Profile)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
func deepMerge(dst, src map[string]interface{}) {
for k, sv := range src {
if dv, ok := dst[k]; ok {
dstMap, dOk := dv.(map[string]interface{})
srcMap, sOk := sv.(map[string]interface{})
if dOk && sOk {
deepMerge(dstMap, srcMap)
continue
}
}
dst[k] = sv
}
}
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)

View File

@@ -477,25 +477,16 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
}
}
case "zai":
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()
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
q.Healthy = true
q.Data = map[string]interface{}{"note": "crush-only"}
case "claude", "anthropic":
// Claude Code n'a pas d'API externe, vérifier l'installation
claudePath := "/usr/bin/claude"
if _, err := os.Stat(claudePath); err == nil {
q.Healthy = true
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
q.Data = data
q.Healthy = true
}
q.Error = "claude code not installed"
}
default:
q.Error = "quota not supported"

View File

@@ -1,53 +1,24 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
const maxShellToolIterations = 10
type ShellChatRequest struct {
Message string `json:"message"`
Context string `json:"context,omitempty"`
History []string `json:"history,omitempty"`
Cwd string `json:"cwd,omitempty"`
Platform string `json:"platform,omitempty"`
Stream bool `json:"stream"`
}
type ShellChatResponse struct {
Content string `json:"content,omitempty"`
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
}
type ToolCallInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
Result *toolResponseData `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
func toString(v interface{}) string {
if v == nil {
return ""
}
s, _ := v.(string)
return s
}
func toBool(v interface{}) bool {
if v == nil {
return false
}
b, _ := v.(bool)
return b
Message string `json:"message"`
Context string `json:"context,omitempty"`
Cwd string `json:"cwd,omitempty"`
Platform string `json:"platform,omitempty"`
Stream bool `json:"stream"`
}
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
@@ -56,6 +27,11 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return
}
if s.shellConvStore.AtLimit() {
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
return
}
var req ShellChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
@@ -67,142 +43,237 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return
}
s.shellConvStore.Add("user", req.Message)
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
orb.SetTools(s.agentToolsJSON)
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
if req.Stream {
s.handleShellChatStream(w, orb, req)
s.handleShellChatStreamV2(w, orb)
} else {
s.handleShellChatNonStream(w, orb, req)
s.handleShellChatNonStreamV2(w, orb)
}
}
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
var sb strings.Builder
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec:
- Exécuter des commandes shell
- Expliquer des erreurs de commandes
- Suggérer des commandes appropriées pour la tâche demandée
- Lire et explorer des fichiers
- Configurer l'environnement de développement
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses.
RÈGLES STRICTES:
- Tu ne peux JAMAIS exécuter de commande ou de code
- Tu ne peux que analyser, expliquer, et proposer des solutions
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
`)
if req.Cwd != "" {
sb.WriteString("Répertoire courant: " + req.Cwd + "\n")
analysis := LoadSystemAnalysis()
if analysis != "" {
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
sb.WriteString(analysis)
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
}
if req.Platform != "" {
sb.WriteString("Plateforme: " + req.Platform + "\n")
}
if req.Context != "" {
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
}
if len(req.History) > 0 {
sb.WriteString("\nDernières commandes exécutées:\n")
for _, h := range req.History {
sb.WriteString(" " + h + "\n")
}
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
if hostname, err := os.Hostname(); err == nil {
sb.WriteString("Hostname: " + hostname + "\n")
}
return sb.String()
}
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: req.Message},
// Rebuild history into orchestrator
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] { // all except last user msg
if m.Role == "system" {
continue
}
// Pre-load orchestrator history
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
lastUserMsg := history[len(history)-1].Content
var toolCalls []ToolCallInfo
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
sseWriter.Write(data)
var finalContent string
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
finalContent = chunk
sseWriter.Write(map[string]interface{}{"content": chunk})
if canFlush {
flusher.Flush()
}
if tc, ok := data["tool_call"].(map[string]interface{}); ok {
argsMap := make(map[string]interface{})
if args, ok := tc["args"].(string); ok {
json.Unmarshal([]byte(args), &argsMap)
}
toolCalls = append(toolCalls, ToolCallInfo{
ID: toString(tc["tool_call_id"]),
Name: toString(tc["name"]),
Args: argsMap,
})
}
if tr, ok := data["tool_result"].(map[string]interface{}); ok {
tcID := toString(tr["tool_call_id"])
for i := range toolCalls {
if toolCalls[i].ID == tcID {
if err, ok := tr["is_error"].(bool); ok && err {
toolCalls[i].Error = toString(tr["content"])
} else {
toolCalls[i].Result = &toolResponseData{
Content: toString(tr["content"]),
IsError: toBool(tr["is_error"]),
}
}
break
}
}
}
})
finalContent, _, _, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
if finalContent == "" && len(toolCalls) > 0 {
finalContent = "(opérations terminées)"
content := result
if content == "" {
content = finalContent
}
writeJSONResp, _ := json.Marshal(ShellChatResponse{
Content: finalContent,
ToolCalls: toolCalls,
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
sseWriter.Write(map[string]interface{}{
"done": "true",
"tokens": s.shellConvStore.ApproxTokens(),
})
sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
}
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: req.Message},
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] {
if m.Role == "system" {
continue
}
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
lastUserMsg := history[len(history)-1].Content
finalContent, err := engine.RunNonStream(ctx, messages)
result, err := orb.Send(lastUserMsg)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
writeJSON(w, map[string]interface{}{
"content": result,
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.shellConvStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.shellConvStore.ApproxTokens(),
"max_tokens": shellMaxTokens,
"at_limit": s.shellConvStore.AtLimit(),
})
}
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.shellConvStore.Clear()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": 0,
})
}
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
writeJSON(w, ShellChatResponse{
Content: finalContent,
ToolCalls: nil,
var sysInfo strings.Builder
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
if hostname, err := os.Hostname(); err == nil {
sysInfo.WriteString("Hostname: " + hostname + "\n")
}
if user := os.Getenv("USER"); user != "" {
sysInfo.WriteString("User: " + user + "\n")
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "model name") {
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
break
}
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
}
}
}
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
lines := strings.Split(string(out), "\n")
if len(lines) >= 2 {
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
}
}
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
lines := strings.Split(string(out), "\n")
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
for i := 1; i < len(lines) && i <= 10; i++ {
fields := strings.Fields(lines[i])
if len(fields) >= 11 {
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
}
}
}
if s.scanResult != nil {
sysInfo.WriteString("\nOutils installés:\n")
for _, t := range s.scanResult.Tools {
status := "✗"
if t.Installed {
status = "✓"
}
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
}
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
1. Un résumé de l'état du système
2. Les points d'attention (performance, sécurité, configuration)
3. Des recommandations spécifiques d'optimisation
4. Les outils manquants qui pourraient être utiles
5. L'état du réseau et des connexions
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
` + sysInfo.String()
result, err := orb.Send(analysisPrompt)
if err != nil {
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
return
}
SaveSystemAnalysis(result)
writeJSON(w, map[string]interface{}{
"status": "ok",
"analysis": result,
})
}
}

View File

@@ -17,6 +17,7 @@ type Server struct {
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
@@ -46,6 +47,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore()
s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
@@ -85,9 +87,13 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
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/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)

View File

@@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const shellMaxTokens = 100000
const shellCharsPerToken = 4
type ShellMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type ShellConvStore struct {
mu sync.RWMutex
path string
msgs []ShellMessage
}
func NewShellConvStore() *ShellConvStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "shell_conversation.json")
s := &ShellConvStore{path: path}
s.load()
return s
}
func (s *ShellConvStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
s.msgs = []ShellMessage{}
return
}
json.Unmarshal(data, &s.msgs)
if s.msgs == nil {
s.msgs = []ShellMessage{}
}
}
func (s *ShellConvStore) save() {
data, _ := json.MarshalIndent(s.msgs, "", " ")
os.MkdirAll(filepath.Dir(s.path), 0755)
os.WriteFile(s.path, data, 0600)
}
func (s *ShellConvStore) Get() []ShellMessage {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]ShellMessage, len(s.msgs))
copy(out, s.msgs)
return out
}
func (s *ShellConvStore) Add(role, content string) ShellMessage {
s.mu.Lock()
defer s.mu.Unlock()
msg := ShellMessage{
ID: time.Now().Format("20060102150405.000"),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
s.msgs = append(s.msgs, msg)
s.save()
return msg
}
func (s *ShellConvStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = []ShellMessage{}
s.save()
}
func (s *ShellConvStore) ApproxTokens() int {
s.mu.RLock()
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
}
return total
}
func (s *ShellConvStore) AtLimit() bool {
return s.ApproxTokens() >= shellMaxTokens
}
func LoadSystemAnalysis() string {
dir, err := config.ConfigDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
if err != nil {
return ""
}
return string(data)
}
func SaveSystemAnalysis(content string) error {
dir, err := config.ConfigDir()
if err != nil {
return err
}
os.MkdirAll(dir, 0755)
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
}

View File

@@ -12,66 +12,66 @@ import (
)
type Profile struct {
Name string `yaml:"name"`
Pseudo string `yaml:"pseudo"`
Email string `yaml:"email"`
Languages []string `yaml:"languages"`
Name string `yaml:"name" json:"name"`
Pseudo string `yaml:"pseudo" json:"pseudo"`
Email string `yaml:"email" json:"email"`
Languages []string `yaml:"languages" json:"languages"`
Preferences struct {
Editor string `yaml:"editor"`
Shell string `yaml:"shell"`
Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"`
Language string `yaml:"language"`
KeyboardLayout string `yaml:"keyboard_layout"`
} `yaml:"preferences"`
Editor string `yaml:"editor" json:"editor"`
Shell string `yaml:"shell" json:"shell"`
Theme string `yaml:"theme" json:"theme"`
DefaultAI string `yaml:"default_ai" json:"default_ai"`
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
Language string `yaml:"language" json:"language"`
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
} `yaml:"preferences" json:"preferences"`
}
type AIProvider struct {
Name string `yaml:"name"`
APIKey string `yaml:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty"`
Model string `yaml:"model"`
Active bool `yaml:"active"`
Name string `yaml:"name" json:"name"`
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
Model string `yaml:"model" json:"model"`
Active bool `yaml:"active" json:"active"`
}
type ToolConfig struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
AutoUpdate bool `yaml:"auto_update"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
}
type SSHConnection struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty"`
Name string `yaml:"name" json:"name"`
Host string `yaml:"host" json:"host"`
Port int `yaml:"port" json:"port"`
User string `yaml:"user" json:"user"`
Password string `yaml:"password,omitempty" json:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
}
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"`
AI struct {
Providers []AIProvider `yaml:"providers"`
} `yaml:"ai"`
Tools []ToolConfig `yaml:"tools"`
Providers []AIProvider `yaml:"providers" json:"providers"`
} `yaml:"ai" json:"ai"`
Tools []ToolConfig `yaml:"tools" json:"tools"`
BMAD struct {
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Global bool `yaml:"global"`
} `yaml:"bmad"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
Global bool `yaml:"global" json:"global"`
} `yaml:"bmad" json:"bmad"`
Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh"`
FontSize int `yaml:"font_size"`
FontFamily string `yaml:"font_family"`
Theme string `yaml:"theme"`
} `yaml:"terminal"`
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
FontSize int `yaml:"font_size" json:"font_size"`
FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"`
}
type TerminalTheme struct {

View File

@@ -24,12 +24,12 @@ const (
)
type SystemInfo struct {
OS OS
Arch Arch
IsWSL bool
Shell string
Terminal string
PackageManager string
OS OS `json:"os"`
Arch Arch `json:"arch"`
IsWSL bool `json:"is_wsl"`
Shell string `json:"shell"`
Terminal string `json:"terminal"`
PackageManager string `json:"package_manager"`
}
func Detect() SystemInfo {

View File

@@ -14,27 +14,27 @@ import (
)
type ToolStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Path string `yaml:"path"`
Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
Path string `yaml:"path" json:"path"`
Latest string `yaml:"latest" json:"latest"`
NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
Category string `yaml:"category" json:"category"`
}
type RuntimeStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
}
type ScanResult struct {
System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"`
System platform.SystemInfo `yaml:"system" json:"system"`
Tools []ToolStatus `yaml:"tools" json:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
GitConfigured bool `yaml:"git_configured" json:"git_configured"`
}
var (

View File

@@ -56,6 +56,10 @@ const api = {
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
getShellChatHistory: () => request('/shell/chat/history'),
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
sendChat: (message, stream = true, onChunk, signal) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
@@ -103,8 +107,6 @@ const api = {
sendShellChat: (message, context = {}, stream = true, onChunk) => {
const payload = {
message,
context: context.context || '',
history: context.history || [],
cwd: context.cwd || '',
platform: context.platform || '',
stream,
@@ -126,7 +128,6 @@ const api = {
const reader = res.body.getReader()
const decoder = new TextDecoder()
let full = ''
let toolCalls = []
while (true) {
const { done, value } = await reader.read()
if (done) break
@@ -136,27 +137,15 @@ const api = {
try {
const data = JSON.parse(line.slice(6))
if (data.error) { reject(new Error(data.error)); return }
if (data.done) {
resolve({ content: full, tool_calls: toolCalls })
return
}
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
if (data.content) {
full += data.content
full = data.content
if (onChunk) onChunk(full, data)
} else if (data.tool_call) {
toolCalls.push(data.tool_call)
if (onChunk) onChunk(full, data, toolCalls)
} else if (data.tool_result) {
const idx = toolCalls.findIndex(tc => tc.tool_call_id === data.tool_result.id)
if (idx >= 0) {
toolCalls[idx].result = data.tool_result
}
if (onChunk) onChunk(full, data, toolCalls)
}
} catch {}
}
}
resolve({ content: full, tool_calls: toolCalls })
resolve({ content: full })
}).catch(reject)
})
},

View File

@@ -92,16 +92,6 @@ export default function App() {
config: [],
}), [layout, t])
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard api={api} refreshRef={dashRefreshRef} />
case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} />
default: return null
}
}
return (
<div className="app-layout">
<header className="header">
@@ -143,8 +133,11 @@ export default function App() {
</span>
</header>
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
{renderContent()}
<main className="content">
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main>
<footer className="statusbar">

View File

@@ -34,14 +34,7 @@ export default function Config({ api }) {
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
setProfileForm({
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
@@ -190,8 +183,8 @@ export default function Config({ api }) {
)}
{activePanel === 'locale' && (
<PanelLocale
language={keyboard} layouts={layouts}
setLanguage={setLanguage} setKeyboard={setKeyboard}
language={language} keyboard={keyboard} layouts={layouts}
api={api}
t={t}
/>
)}
@@ -209,57 +202,135 @@ export default function Config({ api }) {
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
const updateField = (path, value) => {
setProfileForm(prev => {
const next = JSON.parse(JSON.stringify(prev))
const keys = path.split('.')
let target = next
for (let i = 0; i < keys.length - 1; i++) {
if (target[keys[i]] == null) target[keys[i]] = {}
target = target[keys[i]]
}
target[keys[keys.length - 1]] = value
return next
})
}
const profile = editProfile ? profileForm : config?.profile
if (!profile) {
return (
<div className="config-profile-center">
<div className="config-card">
<div className="empty-state">{t('config.loadingProfile')}</div>
</div>
</div>
)
}
const personalKeys = Object.entries(profile).filter(([k, v]) => k !== 'preferences' && typeof v !== 'object')
const personalObj = Object.fromEntries(personalKeys)
const preferences = profile.preferences || null
return (
<div className="config-card">
{config?.profile && !editProfile ? (
<>
<div className="config-card-row">
<span className="config-card-label">{t('config.name')}</span>
<span className="config-card-value">{config.profile.name || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.pseudo')}</span>
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.email')}</span>
<span className="config-card-value">{config.profile.email || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.editor')}</span>
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.shell')}</span>
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.languages')}</span>
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
</div>
<div className="config-card-actions">
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
</div>
</>
) : editProfile ? (
<>
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
<div className="config-card-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
<div className="config-profile-center">
<div className="config-card">
<div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
<RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
</div>
<div className="config-card">
<div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
{preferences ? (
<RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
) : (
<div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}></span></div>
)}
</div>
<div className="config-card">
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
{editProfile ? (
<>
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</>
) : (
<button className="primary sm" onClick={() => {
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
setEditProfile(true)
}}>{t('config.editProfile')}</button>
)}
</div>
</div>
</div>
)
}
function RenderFields({ obj, path, editing, onChange, t }) {
if (!obj || typeof obj !== 'object') return null
return Object.entries(obj).filter(([, v]) => v === null || typeof v !== 'object').map(([key, value]) => {
const fieldPath = path ? `${path}.${key}` : key
const label = getFieldLabel(key, t)
if (editing) {
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
</label>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
</div>
)
}
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
</div>
)
}
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
</div>
)
}
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
</div>
)
})
}
function getFieldLabel(key, t) {
const translated = t(`config.${key}`)
if (translated !== `config.${key}`) return translated
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null)
@@ -281,19 +352,21 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null)
}
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
return (
<div className="config-providers-list">
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
{providers.map((p, i) => {
{displayed.map((p, i) => {
const isEditing = editProvider === p.name
const isValidationTarget = validationStatus?.provider === p.name
const currentModel = providerForm[p.name]?.model || p.model
return (
<div key={i} className="config-card provider-card-v2">
<div className="provider-card-top">
<div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span>
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>}
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
<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>}
</div>
@@ -306,7 +379,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<input
className="config-form-input"
type="password"
placeholder={t('config.tokenPlaceholder')}
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
@@ -321,17 +394,29 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<button
className="sm primary"
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')}
</button>
{isValidationTarget && validationStatus?.valid && (
{isEditing && (
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)}
</div>
</div>
<div className="provider-card-meta" style={{ marginTop: 8 }}>
<span className="mono">{p.model || '—'}</span>
<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>
</div>
</div>
@@ -399,35 +484,93 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
)
}
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
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-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 ${language === lang.id ? 'active' : ''}`}
onClick={() => setLanguage(lang.id)}
>
{lang.name}
</div>
))}
<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>
<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 ${keyboard === l.id ? 'active' : ''}`}
onClick={() => setKeyboard(l.id)}
>
{l.name}
{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>
@@ -435,30 +578,82 @@ function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t
}
function PanelSkills({ skillList, t }) {
const [selected, setSelected] = useState(null)
if (skillList.length === 0) {
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
}
return (
<div className="config-card">
{skillList.length === 0 ? (
<div className="empty-state">
{t('config.noSkills')}
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
</div>
) : (
skillList.map((s, i) => (
<div key={i} className="config-skill-row">
<span className="config-skill-name">{s.name}</span>
<span className="badge neutral">{s.target || 'both'}</span>
{s.version && <span className="badge" style={{ fontSize: 10 }}>{s.version}</span>}
{s.category && <span className="badge" style={{ fontSize: 10, opacity: 0.7 }}>{s.category}</span>}
<span className="config-skill-desc">{s.description}</span>
{s.dependencies && s.dependencies.length > 0 && (
<div style={{ marginTop: 4, fontSize: 10, color: 'var(--muted)' }}>
deps: {s.dependencies.map(d => d.name).join(', ')}
</div>
)}
<>
<div className="skill-tiles">
{skillList.map((s, i) => (
<div key={i} className="skill-tile" onClick={() => setSelected(s)}>
<div className="skill-tile-name">{s.name}</div>
<div className="skill-tile-desc">{s.description}</div>
<div className="skill-tile-tags">
{s.target && <span className="badge neutral">{s.target}</span>}
{s.version && <span className="badge">{s.version}</span>}
{s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
</div>
</div>
))
))}
</div>
{selected && (
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
<div className="skill-detail-header">
<span className="skill-detail-name">{selected.name}</span>
<button className="ghost sm" onClick={() => setSelected(null)}></button>
</div>
<div className="skill-detail-body">
<div className="skill-detail-section">
<div className="skill-detail-label">Description</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
</div>
<div className="skill-detail-section">
<div className="skill-detail-label">Métadonnées</div>
<div className="skill-detail-meta">
{selected.target && <span className="badge neutral">{selected.target}</span>}
{selected.version && <span className="badge">{selected.version}</span>}
{selected.category && <span className="badge">{selected.category}</span>}
{selected.author && <span className="badge ghost">{selected.author}</span>}
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
</div>
</div>
{selected.tags && selected.tags.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Tags</div>
<div className="chip-row">
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
</div>
</div>
)}
{selected.content && (
<div className="skill-detail-section">
<div className="skill-detail-label">Contenu</div>
<div className="skill-detail-content">{selected.content}</div>
</div>
)}
{selected.dependencies && selected.dependencies.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Dépendances</div>
<div className="skill-detail-deps">
{selected.dependencies.map((d, i) => (
<div key={i} className="skill-detail-dep">
<span className="badge">{d.type}</span>
<span>{d.name}</span>
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</>
)
}

View File

@@ -3,6 +3,9 @@ import { useI18n } from '../i18n'
const MAX_POINTS = 30
const POLL_INTERVAL = 5000
const MAX_IDLE_POLLS = 3
function MiniGraph({ data, max, color, label, unit }) {
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
const m = max || Math.max(...data, 1)
@@ -21,6 +24,13 @@ function MiniGraph({ data, max, color, label, unit }) {
<span className="dash-graph-value" style={{ color }}>{last.toFixed(1)}{unit}</span>
</div>
<svg viewBox={`0 0 ${w} ${h}`} className="dash-graph-svg" preserveAspectRatio="none">
<defs>
<linearGradient id={`fg-${color.replace('#','').replace('var(','').replace(')','')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<polygon fill={`url(#fg-${color.replace('#','').replace('var(','').replace(')','')})`} points={`${points} ${w},${h} 0,${h}`} />
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
</svg>
</div>
@@ -29,7 +39,6 @@ function MiniGraph({ data, max, color, label, unit }) {
export default function Dashboard({ api, refreshRef }) {
const { t } = useI18n()
const [dashboardStatus, setDashboardStatus] = useState(null)
const [quota, setQuota] = useState(null)
const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([])
@@ -41,14 +50,12 @@ export default function Dashboard({ api, refreshRef }) {
const loadData = useCallback(async () => {
try {
const [dashData, quotaData, cmdData, procData, metricsData] = await Promise.all([
api.getDashboardStatus().catch(() => null),
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
api.getProvidersQuota().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getSystemMetrics().catch(() => null),
])
setDashboardStatus(dashData)
setQuota(quotaData?.providers || [])
setRecentCmds(cmdData.commands || [])
setProcesses(procData.processes || [])
@@ -67,16 +74,42 @@ export default function Dashboard({ api, refreshRef }) {
useEffect(() => {
loadData()
if (refreshRef) refreshRef.current = loadData
const iv = setInterval(loadData, 5000)
return () => clearInterval(iv)
let active = true
let idleTicks = 0
const iv = setInterval(() => {
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
if (hidden) {
idleTicks++
if (idleTicks >= MAX_IDLE_POLLS) return
} else {
idleTicks = 0
}
if (active) loadData()
}, POLL_INTERVAL)
return () => { active = false; clearInterval(iv) }
}, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax')
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 (
<div className="dash-grid">
{/* CPU / RAM / Network Graphs */}
{/* CPU */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">CPU</span>
@@ -85,18 +118,20 @@ export default function Dashboard({ api, refreshRef }) {
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
</div>
{/* RAM */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">RAM</span>
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)} MB` : '—'}</span>
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div>
{/* Network */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Network</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)} KB/s` : '—'}</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
@@ -114,7 +149,7 @@ export default function Dashboard({ api, refreshRef }) {
<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.remaining}/{m.total}</span>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
))}
{minimax && minimax.data?.models?.length === 0 && (
@@ -136,12 +171,12 @@ export default function Dashboard({ api, refreshRef }) {
{/* Running Processes */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Running Processes</span>
<span className="dash-label">Processes</span>
<span className="dash-count">{processes.length}</span>
</div>
<div className="dash-proc-list">
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
{processes.slice(0, 8).map((p, i) => (
{processes.map((p, i) => (
<div key={i} className="dash-proc-row">
<span className="dash-proc-name">{p.name}</span>
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
@@ -155,9 +190,19 @@ export default function Dashboard({ api, refreshRef }) {
<div className="dash-card-head">
<span className="dash-label">Recent Commands</span>
</div>
{topCmds.length > 0 && (
<div className="dash-cmd-top">
{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>
))}
</div>
)}
<div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.slice(0, 8).map((c, i) => (
{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>
@@ -165,38 +210,6 @@ export default function Dashboard({ api, refreshRef }) {
))}
</div>
</div>
{/* Services */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Services</span>
</div>
{dashboardStatus ? (
<div className="dash-services">
<div className="dash-svc-row">
<span className="dash-svc-name">MCP</span>
<span className="dash-svc-val">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
</div>
<div className="dash-svc-row">
<span className="dash-svc-name">LSP</span>
<span className="dash-svc-val">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
</div>
<div className="dash-svc-row">
<span className="dash-svc-name">Skills</span>
<span className="dash-svc-val">{dashboardStatus.skills?.total || 0} deployed</span>
</div>
{(dashboardStatus.skills?.issues || []).length > 0 && (
<div className="dash-svc-issues">
{(dashboardStatus.skills.issues || []).slice(0, 3).map((issue, i) => (
<div key={i} className="dash-svc-issue"> {issue}</div>
))}
</div>
)}
</div>
) : (
<span className="dash-empty">Loading...</span>
)}
</div>
</div>
)
}

View File

@@ -2,11 +2,12 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send } from 'lucide-react'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
const MAX_TABS = 7
const SHELL_MAX_TOKENS = 100000
const THEMES = {
default: {
@@ -163,17 +164,35 @@ export default function Shell({ api }) {
name: '', host: '', port: 22, user: '', key_path: '',
})
const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: t('shell.aiWelcome') }
])
const [aiMessages, setAiMessages] = useState([])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiTokens, setAiTokens] = useState(0)
const [aiAtLimit, setAiAtLimit] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
useEffect(() => {
if (aiLoadedRef.current) return
aiLoadedRef.current = true
api.getShellChatHistory().then(d => {
if (d.messages && d.messages.length > 0) {
setAiMessages(d.messages)
} else {
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Système Analyste prêt. Tapez /help pour les commandes.' }])
}
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {
setAiMessages([{ role: 'assistant', content: 'Système Analyste prêt.' }])
})
}, [])
useEffect(() => {
api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || [])
@@ -372,57 +391,83 @@ export default function Shell({ api }) {
}
}
const handleAiSend = async () => {
if (!aiInput.trim() || aiLoading) return
const text = aiInput.trim()
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiInput('')
setAiLoading(true)
const sendToTerminal = useCallback((code) => {
const tab = tabs.find(t => t.id === activeTab)
if (!tab) return
const entry = tabsRef.current[tab.id]
if (!entry?.ws || entry.ws.readyState !== WebSocket.OPEN) return
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}, [tabs, activeTab])
const currentTab = tabs.find(t => t.id === activeTab)
const context = {
cwd: currentTab?.cwd || '',
platform: navigator.platform || '',
const handleAiSend = async () => {
if (!aiInput.trim() || aiLoading || aiAtLimit) return
const text = aiInput.trim()
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
}
if (text === '/help') {
setAiMessages(prev => [...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
])
return
}
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiLoading(true)
try {
let accumulated = ''
await api.sendShellChat(text, context, true, (partial, event) => {
if (event && event.tool_call) {
setAiMessages(prev => [...prev, {
role: 'tool',
content: `${t('shell.toolLaunched')}: ${event.tool_call.name || 'tool'}`,
args: event.tool_call.args ? JSON.stringify(event.tool_call.args).slice(0, 100) : '',
}])
return
}
if (event && event.tool_result) {
const resultText = event.tool_result.result?.content || event.tool_result.error || 'completed'
setAiMessages(prev => [...prev, {
role: 'tool_result',
content: resultText,
isError: event.tool_result.result?.is_error,
}])
return
}
if (event && event.done) return
await api.sendShellChat(text, {}, true, (partial) => {
accumulated = partial
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'ai', content: partial, _streaming: true }]
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
})
})
setAiMessages(prev => prev.filter(m => !m._streaming))
if (accumulated) {
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: accumulated }])
}
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated }]
})
// Refresh token count
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {})
} catch (err) {
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
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 () => {
setAnalyzing(true)
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
try {
const d = await api.analyzeSystem()
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
role: 'system',
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
}])
} catch (err) {
setAiMessages(prev => prev.filter(m => m.content !== 'Analyse du système en cours...'))
}
setAnalyzing(false)
}
return (
<div className="shell-layout">
<div className="shell-terminal-col">
@@ -538,13 +583,30 @@ export default function Shell({ api }) {
</div>
<div className="shell-ai-col">
<div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-header">
<span>Analyste Système</span>
<button
className="shell-analyze-btn"
onClick={handleAnalyze}
disabled={analyzing}
title="Analyser le système"
>
<Search size={13} />
{analyzing ? '...' : 'Analyser'}
</button>
</div>
<div className="shell-ai-token-bar">
<div className="shell-ai-token-track">
<div
className={`shell-ai-token-fill ${aiTokens >= SHELL_MAX_TOKENS * 0.8 ? 'warn' : ''}`}
style={{ width: `${Math.min(100, (aiTokens / SHELL_MAX_TOKENS) * 100)}%` }}
/>
</div>
<span className="shell-ai-token-text">{Math.round(aiTokens / 1000)}k/{Math.round(SHELL_MAX_TOKENS / 1000)}k</span>
</div>
<div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}>
{msg.content}
{msg.args && <div className="tool-args">{msg.args}</div>}
</div>
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>
@@ -553,9 +615,10 @@ export default function Shell({ api }) {
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder={t('shell.askAi')}
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
disabled={aiAtLimit && aiInput !== '/clear'}
/>
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
<button className="sm" onClick={handleAiSend} disabled={(!aiInput.trim() && !aiAtLimit) || (aiAtLimit && aiInput !== '/clear')}>{t('shell.send')}</button>
</div>
</div>
@@ -611,3 +674,50 @@ export default function Shell({ api }) {
</div>
)
}
function ShellAIMessage({ msg, sendToTerminal }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const parts = parseMarkdown(msg.content || '')
return (
<div className={`ai-message ${role}`}>
{parts.map((part, i) => {
if (part.type === 'code') {
return (
<div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.code}</code></pre>
<div className="shell-code-actions">
<button onClick={() => navigator.clipboard.writeText(part.code)} title="Copier">
<Copy size={12} /> Copier
</button>
<button onClick={() => sendToTerminal(part.code)} title="Envoyer au terminal">
<Send size={12} /> Terminal
</button>
</div>
</div>
)
}
return <span key={i}>{part.text}</span>
})}
</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

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n'
const RANKS = {
@@ -53,11 +53,9 @@ function renderContent(text) {
}
function formatText(text) {
// First escape HTML entities
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Apply markdown transformations (now with escaped brackets)
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
@@ -66,17 +64,17 @@ 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(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
return html
}
function ThinkingBlock({ content, done }) {
function ThinkingBlock({ content, done, raw }) {
return (
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
<div className="feed-thinking-header">
@@ -86,7 +84,9 @@ function ThinkingBlock({ content, done }) {
<span>Reflexion</span>
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
</div>
<div className="feed-thinking-content">{content}</div>
<div className="feed-thinking-content">
{raw ? <span dangerouslySetInnerHTML={{ __html: content }} /> : content}
</div>
</div>
)
}
@@ -200,7 +200,7 @@ function FeedItem({ msg }) {
<span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
@@ -234,6 +234,16 @@ function StreamingItem({ content, thinking, toolCalls }) {
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
const renderedContent = useMemo(() => {
if (!cleanContent) return []
return renderContent(cleanContent)
}, [cleanContent])
const formattedThinking = useMemo(() => {
if (!thinking) return ''
return formatText(thinking)
}, [thinking])
return (
<div className="feed-item assistant">
<div className="feed-avatar ai-rank">
@@ -246,7 +256,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
</span>
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={thinking} done={false} />}
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
@@ -257,7 +267,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
)}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
{renderedContent.map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
@@ -284,7 +294,11 @@ export default function Studio({ api }) {
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null)
const abortRef = useRef(null)
@@ -297,6 +311,11 @@ export default function Studio({ api }) {
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
}
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
setLoaded(true)
}).catch(() => {
setMessages([
@@ -310,6 +329,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'
@@ -317,6 +350,34 @@ export default function Studio({ api }) {
}
}, [input])
const refreshTokens = useCallback(async () => {
try {
const data = await api.getChatHistory()
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
} catch {}
}, [api])
const handleSummarize = useCallback(async () => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
setContextCollapsed('animating')
try {
const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
setTimeout(() => {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString(), compressed: true }])
setContextCollapsed(true)
setMessagesCollapsed(true)
}, 600)
} catch (err) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
setContextCollapsed(false)
}
}, [api])
const handleClear = useCallback(async () => {
try {
await api.clearChat()
@@ -341,6 +402,7 @@ export default function Studio({ api }) {
'## Commandes Studio',
'',
'- `/clear` - Effacer la conversation',
'- `/summarize` - Résumer la conversation précédente',
'- `/help` - Afficher cette aide',
'- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown',
@@ -359,10 +421,15 @@ export default function Studio({ api }) {
return
}
if (text === '/summarize') {
handleSummarize()
return
}
if (text === '/model') {
api.getProviders().then(data => {
const active = data.providers?.find(p => p.active)
const modelMsg = active ? `Provider: ${active.name}\nModèle: ${active.model}` : 'Aucun provider actif configuré'
const modelMsg = active ? active.name : '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() }])
@@ -474,8 +541,9 @@ export default function Studio({ api }) {
setStreamThinking('')
setStreamToolCalls([])
abortRef.current = null
refreshTokens()
}
}, [input, loading, api, t, handleClear, streaming])
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
const handleStop = useCallback(() => {
if (abortRef.current) {
@@ -483,11 +551,67 @@ export default function Studio({ api }) {
}
}, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model']
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
})
}
}
}
}
const handleToggleCollapsed = useCallback(() => {
setMessagesCollapsed(prev => !prev)
}, [])
const renderMessages = () => {
if (messagesCollapsed && messages.length > 4) {
const visibleCount = 4
const hiddenCount = messages.length - visibleCount
return (
<>
{messages.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
<span className="feed-collapsed-count">clic pour développer</span>
</div>
</>
)
}
return messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))
}
if (!loaded) {
@@ -504,17 +628,43 @@ export default function Studio({ api }) {
return (
<div className="studio-feed-layout">
<div className="studio-feed">
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)}
<div ref={messagesEnd} />
<div className="studio-feed-scroll-wrap">
<div className="studio-feed" ref={feedRef}>
{renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)}
<div ref={messagesEnd} style={{ height: '24px' }} />
</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-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
<div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/>
</div>
<span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{contextCollapsed === true && ' · compressé'}
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
</span>
{contextCollapsed === true && (
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
voir plus
</button>
)}
</div>
<div className="studio-input-row">
<textarea
ref={textareaRef}
@@ -543,7 +693,7 @@ export default function Studio({ api }) {
)}
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear /help /plan /export /model
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model
</div>
</div>
</div>

View File

@@ -182,6 +182,8 @@ const en = {
installed: 'Installed',
missing: 'Missing',
editProfile: 'Edit',
profileInfo: 'Personal Info',
profilePrefs: 'Preferences',
cancel: 'Cancel',
editProvider: 'Configure',
validateKey: 'Validate',

View File

@@ -136,7 +136,7 @@ const fr = {
terminal: 'Terminal',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9ENCES',
skills: 'Compétences',
system: 'Syst\u00e8me',
},
profile: 'Profil',
@@ -160,7 +160,7 @@ const fr = {
save: 'Enregistrer',
saved: 'Enregistr\u00e9 !',
error: 'Erreur',
skills: 'Comp\u00e9ENCES',
skills: 'Compétences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
@@ -182,6 +182,8 @@ const fr = {
installed: 'Install\u00e9',
missing: 'Manquant',
editProfile: 'Modifier',
profileInfo: 'Informations personnelles',
profilePrefs: 'Préférences',
editProvider: 'Configurer',
validateKey: 'Valider',
validating: 'V\u00e9rification...',

View File

@@ -154,7 +154,9 @@ 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; }
.content { flex: 1; overflow: hidden; position: relative; }
.content > div { height: 100%; }
.tab-hidden { display: none; }
.statusbar {
height: 28px;
@@ -392,11 +394,26 @@ input::placeholder { color: var(--text-disabled); }
.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; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
.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;
padding: 4px 10px; border-radius: var(--radius);
background: transparent; border: 1px solid var(--accent-dim);
color: var(--accent); font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.15s;
}
.shell-analyze-btn:hover:not(:disabled) { background: var(--accent-bg); }
.shell-analyze-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.shell-ai-token-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
.shell-ai-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.shell-ai-token-fill.warn { background: var(--warning); }
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
@@ -404,6 +421,31 @@ input::placeholder { color: var(--text-disabled); }
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
.shell-code-block {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
margin: 8px 0 4px; overflow: hidden;
}
.shell-code-block pre {
padding: 10px 12px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
overflow-x: auto; color: var(--text-primary); margin: 0;
}
.shell-code-lang {
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
background: var(--bg-surface); border-bottom: 1px solid var(--border);
text-transform: uppercase; letter-spacing: 0.5px;
}
.shell-code-actions {
display: flex; border-top: 1px solid var(--border); background: var(--bg-surface);
}
.shell-code-actions button {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px;
padding: 5px 0; background: transparent; border: none; border-right: 1px solid var(--border);
color: var(--text-tertiary); font-size: 11px; cursor: pointer; transition: all 0.1s;
font-family: var(--font-sans);
}
.shell-code-actions button:last-child { border-right: none; }
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
.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;
@@ -429,12 +471,16 @@ input::placeholder { color: var(--text-disabled); }
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.config-tabs-bar {
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface);
display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
.config-profile-center {
max-width: 540px; margin: 0 auto; width: 100%;
display: flex; flex-direction: column; gap: 12px;
}
.config-card {
background: var(--bg-card); border: 1px solid var(--border);
@@ -500,10 +546,24 @@ input::placeholder { color: var(--text-disabled); }
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row:last-child { border-bottom: none; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.skill-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.skill-tile { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; cursor: pointer; transition: border-color 0.15s; }
.skill-tile:hover { border-color: var(--accent-dim); }
.skill-tile-name { font-weight: 600; color: var(--text-primary); font-size: 14px; margin-bottom: 6px; }
.skill-tile-desc { font-size: 12px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.skill-tile-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.skill-detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 50; display: flex; align-items: center; justify-content: center; }
.skill-detail-panel { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 90%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; }
.skill-detail-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.skill-detail-name { font-weight: 600; font-size: 16px; color: var(--text-primary); }
.skill-detail-body { flex: 1; overflow-y: auto; padding: 20px; }
.skill-detail-section { margin-bottom: 16px; }
.skill-detail-label { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.skill-detail-meta { display: flex; gap: 8px; flex-wrap: wrap; }
.skill-detail-content { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
.skill-detail-deps { display: flex; flex-direction: column; gap: 6px; }
.skill-detail-dep { font-size: 12px; color: var(--text-tertiary); display: flex; align-items: center; gap: 8px; }
.skill-detail-dep .badge { font-size: 10px; }
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast {
@@ -535,17 +595,20 @@ input::placeholder { color: var(--text-disabled); }
.dash-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px;
padding: 16px;
height: 100%;
overflow: hidden;
}
.dash-card {
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;
overflow: hidden;
}
.dash-span-2 { grid-column: span 2; }
.dash-card-head {
display: flex; align-items: center; justify-content: space-between;
@@ -574,7 +637,7 @@ input::placeholder { color: var(--text-disabled); }
.dash-tool-tag.missing { color: var(--error); }
/* Quota */
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; }
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
.dash-quota-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
@@ -593,21 +656,21 @@ input::placeholder { color: var(--text-disabled); }
}
/* Processes */
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; }
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
.dash-proc-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0;
}
.dash-proc-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
font-family: var(--font-mono);
font-family: var(--font-mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dash-proc-res {
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); flex-shrink: 0;
}
/* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; }
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
.dash-cmd-row {
display: flex; align-items: center; gap: 6px;
padding: 3px 0; overflow: hidden;
@@ -620,8 +683,20 @@ input::placeholder { color: var(--text-disabled); }
.dash-cmd-text {
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
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-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; }
.dash-svc-row {
@@ -701,7 +776,17 @@ input::placeholder { color: var(--text-disabled); }
/* ── Studio Feed ── */
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
.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-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); }
@@ -726,6 +811,18 @@ input::placeholder { color: var(--text-disabled); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
.feed-compressed-indicator {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; margin: 4px 0;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; }
.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; }
.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-thinking-block {
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
@@ -785,6 +882,22 @@ input::placeholder { color: var(--text-disabled); }
.studio-thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-6px); opacity: 1; } }
.studio-input-area { padding: 12px 20px 8px; border-top: 1px solid var(--border); background: var(--bg-surface); }
.studio-token-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.studio-token-fill.warn { background: var(--warning); }
.studio-token-fill.compressed { height: 2px; }
.studio-token-fill.animating { animation: compress-pulse 0.6s ease-in-out; }
@keyframes compress-pulse {
0% { height: 3px; opacity: 1; }
50% { height: 5px; opacity: 0.8; background: var(--accent-light); }
100% { height: 2px; opacity: 1; }
}
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.studio-token-text.compressed { font-size: 9px; }
.studio-token-track.compressed { height: 2px; }
.studio-token-bar.compressed { margin-bottom: 4px; }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
@@ -809,6 +922,21 @@ input::placeholder { color: var(--text-disabled); }
.studio-stop-btn:hover { opacity: 0.8; }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
/* ── Collapsed Messages ── */
.feed-collapsed-messages {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px; margin: 4px 0;
background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated));
border: 1px dashed var(--border-accent);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); }
.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; }
.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; }
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
/* ── Studio Tool Blocks ── */
.studio-tool-block {
background: var(--bg-surface);