Compare commits

...

147 Commits

Author SHA1 Message Date
Augustin ROUX
dbb97cc164 Merge pull request 'release: v0.7.0 — Tests pilotés par l'IA' (#4) from release/v0.7.0 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m7s
PR Check / check (pull_request) Successful in 50s
Reviewed-on: #4
2026-04-27 09:20:37 +00:00
Muyue
6d2f174ae8 fix(ci): rename browser_test.go → browsertest.go
All checks were successful
PR Check / check (pull_request) Successful in 54s
The suffix _test.go makes Go treat the file as a test file (only
compiled during 'go test'), so server.go could not see the exported
BrowserTestStore / NewBrowserTestStore / RegisterBrowserTestTool and
the four handler methods at build time. Rename to a non-test name
keeps the same package-level visibility but compiles into the regular
api package.
2026-04-27 11:16:36 +02:00
Augustin ROUX
0d1d8d3ec3 Merge pull request 'release: v0.6.0 — security audit fixes + 7 new features' (#3) from release/v0.6.0 into develop
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Reviewed-on: #3
2026-04-27 09:14:46 +00:00
Muyue
c820d55710 feat: AI-driven browser tests — Tests tab + browser_test agent tool
Some checks failed
PR Check / check (pull_request) Failing after 33s
New feature: give Studio's AI control of any browser tab to test buttons,
read the console, and report which buttons work / fail.

Backend (internal/api/browser_test.go, ~480 LOC):
- WebSocket endpoint /api/ws/browser-test, auth by single-use 5-min token
- BrowserTestStore: session map (capped at 16, LRU evict), token store
- REST: /api/test/snippet (issues token + JS snippet), /api/test/sessions,
  /api/test/console/{id}
- Agent tool 'browser_test' wired into the registry, with actions:
  list_clickables / click / eval / console / current_url / type / wait /
  summary. click returns the console_delta produced during the click.
- Embedded JS runner: opens WS, hooks console + window.onerror +
  unhandledrejection, dispatches dispatcher commands, replies with
  correlation IDs, watches for URL changes.

Frontend:
- New Tests tab (web/src/components/Tests.jsx): snippet copy + connected
  sessions list + live console viewer
- App.jsx: 5th tab + Ctrl+4 shortcut (Config moves to Ctrl+5)
- api/client.js: getTestSnippet / getTestSessions / getTestConsole

Studio prompt:
- internal/agent/prompts/studio_system.md: added browser_test entry to the
  tools table + <browser_test_strategy> section explaining the recommended
  loop (summary → list_clickables → click → check console_delta → report)

Versioning:
- v0.6.0 → v0.7.0
- CHANGELOG.md: full entry under v0.7.0
2026-04-27 11:02:05 +02:00
Muyue
6a7b4d8001 release: v0.6.0 — security audit fixes + 7 new features
All checks were successful
PR Check / check (pull_request) Successful in 57s
Audit corrections (security, concurrency, stability):
- chat_engine: bound resp.Choices[0] access, release tool slot per-iteration
- conversation_multi: synchronous save under existing lock (was racy fire-and-forget)
- workflow/engine: short-circuit on failed deps (no more infinite busy-wait); track failed/skipped status
- handlers_workflow: rune-aware truncate for plan goal (UTF-8 safe)
- server: CORS limited to localhost origins (was wildcard)
- handlers_info / terminal: mask API keys and SSH passwords as "***" in GET responses; preserve stored secret if "***" sent on update
- terminal: sshpass uses -e + SSHPASS env var (was both -p and -e)
- handlers_chat: MaxBytesReader 50 MB on /api/chat
- image_cache: 10 MB cap per image
- handlers_config: font size <= 72; profile-save unmarshal errors propagated
- handlers_info: /lsp/auto-install ProjectDir restricted to user home
- Shell.jsx: parenthesized resize-condition (operator precedence)
- orchestrator_test: CleanAIResponse capitalization (fixes failing vet)

New features:
- platform: detect OS name (Debian, Ubuntu, Windows 11, macOS X.Y) and inject in Studio system prompt next to the date
- agents: default timeout 30 min for crush_run/claude_run (cap also 30 min)
- agents: new cwd, wsl_distro, wsl_user params; on Windows hosts launch via "wsl -d <distro> -u <user> --cd <cwd> --"
- agents: new claude_run tool (mirror of crush_run for Claude Code CLI)
- terminal: list installed WSL distros individually in new-tab menu (Windows only)
- studio: system prompt rewritten around BMAD-METHOD personas + mandatory delegation template
- studio: "Réflexion avancée" toggle — inactive provider produces a preliminary report injected as [RAPPORT PRÉALABLE] context for the active provider
- studio: "Historique compressé" toggle — collapses past tool calls to last action only, with "Tout afficher" expansion
2026-04-27 10:12:11 +02:00
Augustin
0753167fb9 merge: develop into main (v0.5.0)
Some checks failed
Stable Release / stable (push) Failing after 32s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:18:28 +02:00
Augustin
2a6647b5cb chore: bump version to 0.5.0
Some checks failed
Beta Release / beta (push) Failing after 31s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:16:28 +02:00
Augustin
3740454201 feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Stable Release / stable (push) Failing after 33s
- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:36 +02:00
Augustin
d98110ce8a feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:36 +02:00
Augustin
d2bb42b212 feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Beta Release / beta (push) Failing after 33s
- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-27 00:01:22 +02:00
Augustin
e8a289ccf3 feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-26 20:06:20 +02:00
CI Bot
c9f2932147 chore: update CHANGELOG for v0.4.0 2026-04-26 13:22:30 +00:00
Augustin
f05181b2db Merge branch 'main' of https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace
All checks were successful
Stable Release / stable (push) Successful in 57s
2026-04-26 15:21:12 +02:00
Augustin
95e6cdaf41 Merge branch 'develop'
# Conflicts:
#	internal/api/handlers_info.go
#	internal/config/config.go
#	internal/version/version.go
#	web/src/components/App.jsx
#	web/src/components/Config.jsx
#	web/src/components/Dashboard.jsx
#	web/src/components/Shell.jsx
#	web/src/components/Studio.jsx
2026-04-26 15:20:30 +02:00
Augustin
12000e523c fix: token persistence, context windows, CSS tables/bullets/hr, image attachments
All checks were successful
Beta Release / beta (push) Successful in 1m1s
- Fix token count reset on app restart: persist realTokens in conversation.json
- Fix token/context window values: Studio 150K (summarize at 120K), Terminal 100K
- Fix table rendering in terminal tab: correct thead/tbody display model
- Fix copy button always top-right in Studio code blocks
- Add markdown horizontal rule (---) support in Studio and Terminal
- Fix bullet list double dot: remove CSS ::before duplicate bullet point
- Add image attachments support (VLM description, file mentions @file.ext)
- Add sudo detection with cache (sync.Once)
- Fix message content serialization (TextContent wrapper)
- Guide AI to use read_file instead of cat in studio prompt

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-26 15:19:26 +02:00
Augustin
cb3d35756a feat: terminal sudo blocking, token tracking, mermaid & consumption UI
All checks were successful
Beta Release / beta (push) Successful in 1m3s
- Block sudo/doas commands when not running as root
- Add real token counting from API responses
- Track and display consumption by provider/day
- Add Mermaid diagram rendering in Shell and Studio
- Add copy-to-clipboard buttons for code blocks
- Support tables in AI message rendering
- Update system prompt with context (date, time, root status)

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-26 12:43:15 +02:00
Augustin
0830e64ae6 fix(shell,config): terminal font size, AI tools, provider keys
All checks were successful
Beta Release / beta (push) Successful in 56s
- Fix terminal default fontSize from 6px to 14px across all references
- Add terminal tool to shell AI via ChatEngine with tool_call streaming
- Fix provider key detection (apiKey → api_key, baseURL → base_url)
- Add mimo provider migration and validation endpoint
- Bump version to 0.4.0

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 22:03:35 +02:00
CI Bot
b43e3352e7 chore: update CHANGELOG for v0.3.5 2026-04-25 19:25:42 +00:00
Augustin
a60435d002 fix(shell): set default terminal fontSize to 6px
All checks were successful
Stable Release / stable (push) Successful in 48s
All fallbacks were still using 12px. User confirmed 6px is the
correct baseline on their display.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
6b0fcfbd31 fix(shell): default fontSize 10px and init new tabs immediately
- Base font size reduced from 12px to 10px
- New tabs now initialize directly when added (was waiting for
  tab switch because the MutationObserver only fired on visibility
  changes, not on tab additions)
- Zoom level applied to newly created terminals

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
df46b5c14e feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
- Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size
- Zoom badge indicator in tab bar
- All shell shortcuts now shown in statusbar footer
- Added i18n labels for search, zoom, switch tab, next tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
7240813de6 fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
97bfb803a6 fix(shell): enable allowProposedApi for Unicode11 addon
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3104179109 fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e21b47a27c feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM
fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and
inline image protocol. All existing functionality preserved.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
2e98701104 fix(shell): restore all missing imports, constants, and utility functions
- Restore xterm imports (Terminal, FitAddon, WebLinksAddon)
- Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.)
- Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS,
  TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY)
- Restore renderContent() and formatText() utility functions
- Add @xterm/xterm CSS import
- Remove duplicate constants from inside Shell component

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
f9d56de65a fix(shell): add missing Monitor import from lucide-react
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
0e7340891c fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3b819be5ac fix(shell): add missing useI18n import
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
c607943ca3 fix(shell): remove stray 'impo' typo causing ReferenceError
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3312005be4 fix(terminal): improve dimensions handling and add system theme for xterm
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
6cc86b7f89 fix(shell): resolve savedTabs undefined ReferenceError in activeTab init
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
1885616068 fix(terminal): improve dimension calculation and tab init reliability
- Guarantee minimum 24x80 dimensions on WebSocket open
- Force reflow before init attempts
- Multiple fit attempts with increasing delays (0/50/100/200/400ms)
- Validate saved tabs structure from localStorage
- Resize active tab after closing another tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
c8506d4dfc fix(dashboard): show MiMo quota instead of ZAI on dashboard
Replace Z.AI quota display with MiMo provider in the API Quota card.
ZAI is now a hidden fallback and should not appear in the dashboard.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
68acabd6a1 feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback
Add MiMo-V2.5-Pro from Xiaomi Token Plan as a new AI provider with
base URL https://token-plan-ams.xiaomimimo.com/v1. The /model change
command now switches between MiniMax and MiMo only. ZAI is always
placed last in the fallback chain as the provider of ultimate resort.
Config panel shows MiniMax and MiMo cards.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
b80562a669 fix(terminal): use absolute positioning for content panels
height:100% on .content>div fails because .content uses flex:1
without explicit height. Switch to position:absolute;inset:0 which
correctly fills the content area and gives xterm proper container
dimensions for fitAddon.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
c562972da3 feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
3651f62127 fix(shell): prevent Enter in AI chat from leaking to terminal
Stop propagation of Enter keydown in AI input and defer terminal
focus to next event loop tick to prevent xterm from capturing the
same key event.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
18e83479d6 fix(terminal): improve terminal dimensions and fit timing
Use min-height:0 on xterm-wrapper (flex child) instead of height:100%
to properly fill available space in flex layout. Add delayed fit()
calls after initialization to let the layout stabilize before
calculating terminal cell dimensions.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
6596d86db6 fix(terminal): detect shell tab visibility via MutationObserver
Shell is always mounted inside a display:none parent when the app
loads on a different tab. Added MutationObserver on the wrapper to
detect when the shell tab becomes visible and initialize/fit all
pending terminals at that moment. Removed attempt limit so retries
continue until the tab is actually shown.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
9fb5aa8dbf fix(terminal): init all tabs on load, fix excessive zoom
Use visibility:hidden instead of display:none for inactive terminal tabs
so xterm containers retain their dimensions. This allows all terminals
to initialize independently and prevents fitAddon from miscalculating
cell sizes on zero-height containers.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
ab3641d00d fix(terminal): improve tab visibility checks and positioning
- Add null check for container before accessing offsetHeight
- Validate activeTabRef during initialization and fit operations
- Check for display:none as visibility indicator
- Simplify useEffect dependency array
- Use absolute positioning for terminal wrapper/instance

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
5dac191d9a fix(ui): adjust global CSS styles
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e6da61f460 fix(terminal): use display:none instead of visibility for tab hiding
Replace visibility-based hiding with display property for reliable tab
detection. Use offsetParent and offsetHeight checks instead of style
properties to properly detect hidden terminals.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
a994749dcf feat(ui): refactor copy state to Set and add helper functions
- Change copiedIdx (number) to copiedSet (Set) for tracking multiple copied items
- Add copyCmd function to handle clipboard and timeout cleanup
- Add relativeTime function for displaying relative timestamps

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
b394ef9979 feat(ui): add recentUnique to deduplicate recent commands in Dashboard
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
fca53440e6 feat(ui): redesign recent commands display and fix terminal visibility
- Dashboard: add frequency bars for top commands, click-to-copy, time display
- Shell: switch from display:none to visibility:hidden for terminal containers
- CSS: restyle command list with improved hover states and copy indicators

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
0a3123ec17 fix(shell): initialize activeTabRef with activeTab and move useEffect
Reorder code to follow React hooks rules - initialize ref with value
instead of null, then update via useEffect.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e6447f2f5a fix(config): remove unused import, reorder hooks, and improve variable naming
Reorder validateKey function and useEffect to avoid referencing before definition.
Rename loop variable from 't' to 'tool' for clarity.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
16c5ed6dd9 fix(studio): add tool results serialization and improve message handling
- Add tool_results array to AI message content with tool_call_id, result, and is_error
- Convert cleanContent to let for potential reuse
- Reset accumulated and streaming state on tool_call events

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
e8924be182 fix(shell): improve tab reference stability and command queueing
Add refs to track activeTab and pending commands outside render cycle.
Flush queued commands after terminal initialization completes.
Fix sendToTerminal to use stable refs instead of stale state.
Enhance debug logging for tab operations.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
a905f22f1a fix(shell): add debug logging for tab tracking and WebSocket state
Track which tab messages belong to via _tabId field to ensure AI
responses are sent to the correct terminal tab. Add console.log in
initTerminal, sendToTerminal for troubleshooting tab lifecycle issues.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
183dd27407 fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
- Add proper disposal tracking to prevent memory leaks
- Move terminal buffer from localStorage to sessionStorage
- Restore buffer immediately after first WS message
- Fix clear detection logic and error handling
- Add signal parameter support for abortable fetch requests

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
203f57fa31 fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal
- Move defer cleanup after async goroutine setup to prevent premature closure
- Remove unused Password field from terminal sessions struct
- Fix line calculation in clear detection using viewportY instead of baseY
- Add onStateChange callback to connectWebSocket for connection state
- Add tabId parameter to sendToTerminal for targeted tab control
- Simplify ShellAIMessage to use specific tab for command sending

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
a1046da67b fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
- Delay buffer restoration by 300ms to avoid race condition with WebSocket init
- Read current line from terminal buffer on Enter (reliable) instead of keystroke tracking
- Fix streaming to emit full content instead of word-by-word chunks
- Fix WebSocket readyState check in sendToTerminal
- Extract and deduplicate AI message sending logic
- Fix localStorage cleanup on tab close

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
02ee41c12b refactor: remove locale panel, improve provider validation and terminal buffer persistence
- Remove locale panel from config (language/keyboard already handled elsewhere)
- Add per-provider key validation status with auto-check on load
- Add missing tools section with AI-powered installation
- Improve reset confirmation with modal
- Persist terminal buffer to localStorage with auto-save
- Detect clear command to wipe saved buffer
- Remove AI tab concept (commands routed to active tab instead)
- Remove renderTick hacks, use proper message keys

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
06810be9a3 bump: v0.3.5
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
8db3bd7c6b fix: display all quota models, center card content vertically
- Handle all quota types in providersQuota, not just TIME_LIMIT
- Extract model name from model field or type field
- Use explicit limit value when available
- Add vertical center alignment to quota card content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
20237c022f fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering
- Fix AI terminal not initializing (wait for shell col visibility, remove offsetHeight guard)
- Add Shift+Tab to cycle between shell terminals
- Handle unclosed code blocks in renderContent (Shell + Studio)
- Filter irrelevant commands from history (short/non-alpha backend + expanded frontend exclude list)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 21:24:39 +02:00
Augustin
9a218b1904 fix(shell): set default terminal fontSize to 6px
All checks were successful
Beta Release / beta (push) Successful in 49s
All fallbacks were still using 12px. User confirmed 6px is the
correct baseline on their display.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:41:47 +02:00
Augustin
399b845e14 fix(shell): default fontSize 10px and init new tabs immediately
All checks were successful
Beta Release / beta (push) Successful in 48s
- Base font size reduced from 12px to 10px
- New tabs now initialize directly when added (was waiting for
  tab switch because the MutationObserver only fired on visibility
  changes, not on tab additions)
- Zoom level applied to newly created terminals

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:33:49 +02:00
Augustin
436d5c6149 feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
All checks were successful
Beta Release / beta (push) Successful in 48s
- Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size
- Zoom badge indicator in tab bar
- All shell shortcuts now shown in statusbar footer
- Added i18n labels for search, zoom, switch tab, next tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:28:15 +02:00
Augustin
5a9edc076e fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
All checks were successful
Beta Release / beta (push) Successful in 49s
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:19:12 +02:00
Augustin
5bdc7a6429 fix(shell): enable allowProposedApi for Unicode11 addon
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:16:23 +02:00
Augustin
5a0480bae0 fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution
All checks were successful
Beta Release / beta (push) Successful in 49s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:12:05 +02:00
Augustin
80de4dd523 feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Some checks failed
Beta Release / beta (push) Failing after 20s
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM
fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and
inline image protocol. All existing functionality preserved.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:10:15 +02:00
Augustin
de52f4ebd6 fix(shell): restore all missing imports, constants, and utility functions
All checks were successful
Beta Release / beta (push) Successful in 49s
- Restore xterm imports (Terminal, FitAddon, WebLinksAddon)
- Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.)
- Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS,
  TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY)
- Restore renderContent() and formatText() utility functions
- Add @xterm/xterm CSS import
- Remove duplicate constants from inside Shell component

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:02:36 +02:00
Augustin
98ff0dd578 fix(shell): add missing Monitor import from lucide-react
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:56:32 +02:00
Augustin
9a1ff6e8dc fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:53:38 +02:00
Augustin
034b9ee0e4 fix(shell): add missing useI18n import
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:51:54 +02:00
Augustin
c1b1fc653f fix(shell): remove stray 'impo' typo causing ReferenceError
All checks were successful
Beta Release / beta (push) Successful in 44s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:50:12 +02:00
Augustin
50ca75180c fix(terminal): improve dimensions handling and add system theme for xterm
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 21:43:10 +02:00
Augustin
b8aa935bec fix(shell): resolve savedTabs undefined ReferenceError in activeTab init
All checks were successful
Beta Release / beta (push) Successful in 50s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:36:25 +02:00
Augustin
5627ddd2ce fix(terminal): improve dimension calculation and tab init reliability
All checks were successful
Beta Release / beta (push) Successful in 48s
- Guarantee minimum 24x80 dimensions on WebSocket open
- Force reflow before init attempts
- Multiple fit attempts with increasing delays (0/50/100/200/400ms)
- Validate saved tabs structure from localStorage
- Resize active tab after closing another tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:30:07 +02:00
Augustin
d27872572a fix(dashboard): show MiMo quota instead of ZAI on dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
Replace Z.AI quota display with MiMo provider in the API Quota card.
ZAI is now a hidden fallback and should not appear in the dashboard.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:28:22 +02:00
Augustin
7d0f807fb0 feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback
All checks were successful
Beta Release / beta (push) Successful in 57s
Add MiMo-V2.5-Pro from Xiaomi Token Plan as a new AI provider with
base URL https://token-plan-ams.xiaomimimo.com/v1. The /model change
command now switches between MiniMax and MiMo only. ZAI is always
placed last in the fallback chain as the provider of ultimate resort.
Config panel shows MiniMax and MiMo cards.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:22:34 +02:00
Augustin
cbf623b98b fix(terminal): use absolute positioning for content panels
All checks were successful
Beta Release / beta (push) Successful in 50s
height:100% on .content>div fails because .content uses flex:1
without explicit height. Switch to position:absolute;inset:0 which
correctly fills the content area and gives xterm proper container
dimensions for fitAddon.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:13:20 +02:00
Augustin
b85ebb8e54 feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
All checks were successful
Beta Release / beta (push) Successful in 48s
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:10:24 +02:00
Augustin
7cc206dc20 fix(shell): prevent Enter in AI chat from leaking to terminal
All checks were successful
Beta Release / beta (push) Successful in 48s
Stop propagation of Enter keydown in AI input and defer terminal
focus to next event loop tick to prevent xterm from capturing the
same key event.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:07:36 +02:00
Augustin
bf8c0fd380 fix(terminal): improve terminal dimensions and fit timing
All checks were successful
Beta Release / beta (push) Successful in 47s
Use min-height:0 on xterm-wrapper (flex child) instead of height:100%
to properly fill available space in flex layout. Add delayed fit()
calls after initialization to let the layout stabilize before
calculating terminal cell dimensions.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:35:49 +02:00
Augustin
08dc1fd53b fix(terminal): detect shell tab visibility via MutationObserver
All checks were successful
Beta Release / beta (push) Successful in 49s
Shell is always mounted inside a display:none parent when the app
loads on a different tab. Added MutationObserver on the wrapper to
detect when the shell tab becomes visible and initialize/fit all
pending terminals at that moment. Removed attempt limit so retries
continue until the tab is actually shown.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:28:02 +02:00
Augustin
13e937a11b fix(terminal): init all tabs on load, fix excessive zoom
All checks were successful
Beta Release / beta (push) Successful in 46s
Use visibility:hidden instead of display:none for inactive terminal tabs
so xterm containers retain their dimensions. This allows all terminals
to initialize independently and prevents fitAddon from miscalculating
cell sizes on zero-height containers.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:13:21 +02:00
Augustin
3cf701b002 fix(terminal): improve tab visibility checks and positioning
All checks were successful
Beta Release / beta (push) Successful in 48s
- Add null check for container before accessing offsetHeight
- Validate activeTabRef during initialization and fit operations
- Check for display:none as visibility indicator
- Simplify useEffect dependency array
- Use absolute positioning for terminal wrapper/instance

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:59:48 +02:00
Augustin
3a09e0e0c2 fix(ui): adjust global CSS styles
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:38:21 +02:00
Augustin
47fa2e01bb fix(terminal): use display:none instead of visibility for tab hiding
All checks were successful
Beta Release / beta (push) Successful in 49s
Replace visibility-based hiding with display property for reliable tab
detection. Use offsetParent and offsetHeight checks instead of style
properties to properly detect hidden terminals.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:23:54 +02:00
Augustin
401292ec5b feat(ui): refactor copy state to Set and add helper functions
All checks were successful
Beta Release / beta (push) Successful in 46s
- Change copiedIdx (number) to copiedSet (Set) for tracking multiple copied items
- Add copyCmd function to handle clipboard and timeout cleanup
- Add relativeTime function for displaying relative timestamps

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:04:38 +02:00
Augustin
199a7e409a feat(ui): add recentUnique to deduplicate recent commands in Dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:01:08 +02:00
Augustin
c91931f42f feat(ui): redesign recent commands display and fix terminal visibility
All checks were successful
Beta Release / beta (push) Successful in 44s
- Dashboard: add frequency bars for top commands, click-to-copy, time display
- Shell: switch from display:none to visibility:hidden for terminal containers
- CSS: restyle command list with improved hover states and copy indicators

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:53:59 +02:00
Augustin
cbbb224725 fix(shell): initialize activeTabRef with activeTab and move useEffect
All checks were successful
Beta Release / beta (push) Successful in 45s
Reorder code to follow React hooks rules - initialize ref with value
instead of null, then update via useEffect.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:44:02 +02:00
Augustin
8d10d2182e fix(config): remove unused import, reorder hooks, and improve variable naming
All checks were successful
Beta Release / beta (push) Successful in 42s
Reorder validateKey function and useEffect to avoid referencing before definition.
Rename loop variable from 't' to 'tool' for clarity.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:33:09 +02:00
Augustin
e9696ef82b fix(studio): add tool results serialization and improve message handling
All checks were successful
Beta Release / beta (push) Successful in 43s
- Add tool_results array to AI message content with tool_call_id, result, and is_error
- Convert cleanContent to let for potential reuse
- Reset accumulated and streaming state on tool_call events

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:22:54 +02:00
Augustin
1edd4f053a fix(shell): improve tab reference stability and command queueing
All checks were successful
Beta Release / beta (push) Successful in 47s
Add refs to track activeTab and pending commands outside render cycle.
Flush queued commands after terminal initialization completes.
Fix sendToTerminal to use stable refs instead of stale state.
Enhance debug logging for tab operations.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:10:54 +02:00
Augustin
92f943c3e6 fix(shell): add debug logging for tab tracking and WebSocket state
All checks were successful
Beta Release / beta (push) Successful in 46s
Track which tab messages belong to via _tabId field to ensure AI
responses are sent to the correct terminal tab. Add console.log in
initTerminal, sendToTerminal for troubleshooting tab lifecycle issues.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:53:13 +02:00
Augustin
1704b196cf fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
All checks were successful
Beta Release / beta (push) Successful in 52s
- Add proper disposal tracking to prevent memory leaks
- Move terminal buffer from localStorage to sessionStorage
- Restore buffer immediately after first WS message
- Fix clear detection logic and error handling
- Add signal parameter support for abortable fetch requests

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:41:01 +02:00
Augustin
40ec493bae fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal
All checks were successful
Beta Release / beta (push) Successful in 49s
- Move defer cleanup after async goroutine setup to prevent premature closure
- Remove unused Password field from terminal sessions struct
- Fix line calculation in clear detection using viewportY instead of baseY
- Add onStateChange callback to connectWebSocket for connection state
- Add tabId parameter to sendToTerminal for targeted tab control
- Simplify ShellAIMessage to use specific tab for command sending

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:20:48 +02:00
Augustin
233368c954 fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
All checks were successful
Beta Release / beta (push) Successful in 50s
- Delay buffer restoration by 300ms to avoid race condition with WebSocket init
- Read current line from terminal buffer on Enter (reliable) instead of keystroke tracking
- Fix streaming to emit full content instead of word-by-word chunks
- Fix WebSocket readyState check in sendToTerminal
- Extract and deduplicate AI message sending logic
- Fix localStorage cleanup on tab close

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 14:15:14 +02:00
Augustin
00118f0803 refactor: remove locale panel, improve provider validation and terminal buffer persistence
All checks were successful
Beta Release / beta (push) Successful in 47s
- Remove locale panel from config (language/keyboard already handled elsewhere)
- Add per-provider key validation status with auto-check on load
- Add missing tools section with AI-powered installation
- Improve reset confirmation with modal
- Persist terminal buffer to localStorage with auto-save
- Detect clear command to wipe saved buffer
- Remove AI tab concept (commands routed to active tab instead)
- Remove renderTick hacks, use proper message keys

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:49:12 +02:00
Augustin
167ab82978 bump: v0.3.5
All checks were successful
Beta Release / beta (push) Successful in 56s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:16:08 +02:00
Augustin
a23c0c5b94 fix: display all quota models, center card content vertically
Some checks failed
Beta Release / beta (push) Has been cancelled
- Handle all quota types in providersQuota, not just TIME_LIMIT
- Extract model name from model field or type field
- Use explicit limit value when available
- Add vertical center alignment to quota card content

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 13:15:51 +02:00
Augustin
24b31b0b47 fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering
All checks were successful
Beta Release / beta (push) Successful in 45s
- Fix AI terminal not initializing (wait for shell col visibility, remove offsetHeight guard)
- Add Shift+Tab to cycle between shell terminals
- Handle unclosed code blocks in renderContent (Shell + Studio)
- Filter irrelevant commands from history (short/non-alpha backend + expanded frontend exclude list)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 23:24:43 +02:00
CI Bot
c39203cc4b chore: update CHANGELOG for v0.3.4 2026-04-23 21:14:19 +00:00
Augustin
869bf154cc Merge branch 'develop'
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-23 23:13:04 +02:00
Augustin
7ae4017672 fix(ci): replace jq with python3 in release step, add debug output
All checks were successful
Beta Release / beta (push) Successful in 40s
💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

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

💘 Generated with Crush

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

💘 Generated with Crush

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

🤖 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

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

View File

@@ -170,7 +170,7 @@ jobs:
- name: Commit changelog - name: Commit changelog
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
git config user.name "CI Bot" git config user.name "CI Bot"
git config user.email "ci@legion-muyue.fr" git config user.email "ci@legion-muyue.fr"
@@ -181,30 +181,45 @@ jobs:
- name: Create release - name: Create release
env: env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
set -ex
if [ -z "$GITEA_TOKEN" ]; then if [ -z "$GITEA_TOKEN" ]; then
echo "Warning: GITEATOKEN not set, skipping release" echo "Error: GITEA_TOKEN secret is not set"
exit 0 exit 1
fi fi
VERSION=${{ steps.version.outputs.version }} VERSION=${{ steps.version.outputs.version }}
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
BODY=$(cat /tmp/stable_changelog.md) echo "Creating release ${VERSION} at ${API}"
RESPONSE=$(curl -s -X POST "${API}" \
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
if [ -n "$EXISTING" ]; then
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -n "$EXISTING_ID" ]; then
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
fi
fi
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "{
\"tag_name\":\"${VERSION}\", \"tag_name\":\"${VERSION}\",
\"target_commitish\":\"main\", \"target_commitish\":\"main\",
\"name\":\"muyue ${VERSION}\", \"name\":\"muyue ${VERSION}\",
\"body\":$(echo "$BODY" | jq -Rs .), \"body\":${BODY},
\"draft\":false, \"draft\":false,
\"prerelease\":false \"prerelease\":false
}") }")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') HTTP_CODE=$(echo "$RESPONSE" | tail -1)
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: ${HTTP_CODE}"
echo "Response: ${RESPONSE_BODY}"
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -z "$RELEASE_ID" ]; then if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:" echo "Failed to create release"
echo "$RESPONSE"
exit 1 exit 1
fi fi
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
@@ -212,8 +227,12 @@ jobs:
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
filename=$(basename "$file") filename=$(basename "$file")
echo "Uploading ${filename}..." echo "Uploading ${filename}..."
curl -s -X POST "${UPLOAD_URL}" \ UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file};filename=${filename}" > /dev/null -F "attachment=@${file};filename=${filename}")
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
if [ "$UPLOAD_CODE" != "201" ]; then
echo "Upload failed with status ${UPLOAD_CODE}"
fi
done done
echo "Stable release ${VERSION} published!" echo "Stable release ${VERSION} published!"

View File

@@ -4,6 +4,493 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.7.0
### Nouvelle fonctionnalité majeure : Tests pilotés par l'IA
- **Onglet Tests** dédié dans l'UI : génère un snippet JS à coller dans n'importe quelle page web ouverte (Chrome, Firefox, Edge, dev local ou distant).
- **Session WebSocket** authentifiée par token à usage unique (5 min TTL) — la page connectée transmet ses messages console en temps réel et expose une RPC pour cliquer / évaluer / inspecter.
- **Outil agent `browser_test`** disponible pour Studio, avec actions :
- `list_clickables` : énumère tous les éléments cliquables visibles avec un index stable
- `click` : clic par sélecteur CSS ou par index — retourne le **delta console** émis pendant le clic
- `eval` : évalue une expression JS et retourne sa valeur sérialisée
- `console` / `summary` : lit le buffer console (200 dernières entrées)
- `current_url` : URL et titre courants
- `type` : remplit un champ input/textarea (utilise le setter natif pour compatibilité React)
- `wait` : pause asynchrone (max 5s)
- **Stratégie BMAD** intégrée au prompt système Studio : boucle `summary → list_clickables → click → vérifier console_delta → rapport final ✓/✗/⚠`.
- **Multi-sessions** : jusqu'à 16 onglets connectés simultanément ; éviction LRU au-delà.
- **Sécurité** : token consommé à la première connexion ; CheckOrigin libre côté snippet (gating par token uniquement) ; CORS API REST inchangé.
- **Backend** : `internal/api/browser_test.go` (nouveau, ~480 lignes) + 4 routes (`/api/test/snippet`, `/api/test/sessions`, `/api/test/console/{id}`, `/api/ws/browser-test`).
- **Frontend** : `web/src/components/Tests.jsx` (nouveau) + nouvel onglet ⌃4.
## v0.6.0
### Audit & corrections (sécurité, concurrence, stabilité)
- fix(api): empty `resp.Choices[0]` panic in chat engine — bounded check
- fix(api): `defer release()` accumulating inside tool-call loop — release immediately after each tool call
- fix(api): race in `ConversationStoreMulti.Add` (fire-and-forget save under released lock) — synchronous save under existing lock
- fix(workflow): infinite busy-wait in `engine.Execute` when a dependency fails — propagate `StatusFailed`/`StatusSkipped` and short-circuit
- fix(workflow): UTF-8-unsafe slicing of plan goal — rune-aware truncate
- fix(security): CORS `Access-Control-Allow-Origin: *` — restricted to localhost origins
- fix(security): API key disclosure in `/api/providers` — masked as `"***"`; saving handler ignores `"***"` placeholder
- fix(security): SSH password disclosure in `/api/terminal/sessions` — masked; update handler preserves stored password if `"***"` is sent
- fix(security): sshpass `-p` + `-e` mutually-exclusive flags — use only `-e` with `SSHPASS` env var
- fix(security): unbounded chat request body — `MaxBytesReader` 50 MB
- fix(security): unbounded image upload — 10 MB cap in `saveImage`
- fix(security): font size unbounded — capped at 72
- fix(security): `LSP /auto-install` accepted arbitrary `project_dir` — restricted to user home subtree
- fix(api): silent `json.Unmarshal` errors in profile save — propagated
- fix(ui): operator-precedence bug in `Shell.jsx` resize check — parenthesized
### Nouvelles fonctionnalités
- feat(ai): inject OS name (e.g. `Debian 12`, `Windows 11`, `macOS 14.5`) alongside date in Studio system prompt
- feat(agents): default timeout raised to 30 minutes for `crush_run` and `claude_run`; max also 30 min
- feat(agents): new optional params `cwd`, `wsl_distro`, `wsl_user` — agents can be launched in a specific directory, and on Windows hosts inside a specific WSL distribution under a specific user
- feat(agents): new `claude_run` tool (mirrors `crush_run` for the Claude Code CLI)
- feat(terminal): WSL distros listed individually as quick-launch entries in the new-tab menu (Windows hosts only)
- feat(studio): system prompt rewritten around the BMAD-METHOD (Analyst/PM/Architect/SM/Dev/QA personas + mandatory `[OBJECTIF]/[CONTEXTE]/[CONTRAINTES]/[LIVRABLE]/[CRITÈRE D'ACCEPTATION]` template for any agent delegation)
- feat(studio): "Réflexion avancée" toggle — when enabled, the inactive AI provider produces a preliminary report that is injected as `[RAPPORT PRÉALABLE]` context into the active provider's prompt
- feat(studio): "Historique compressé" toggle — collapses past tool calls and keeps only the last visible action per assistant message, with `Tout afficher` to expand
### Bug fix CI
- fix(test): `cleanAIResponse``CleanAIResponse` in `orchestrator_test.go` (was failing `go vet`)
## v0.4.0
### Changes since v0.3.5
- fix: token persistence, context windows, CSS tables/bullets/hr, image attachments (12000e5)
- feat: terminal sudo blocking, token tracking, mermaid & consumption UI (cb3d357)
- fix(shell,config): terminal font size, AI tools, provider keys (0830e64)
- chore: update CHANGELOG for v0.3.5 (b43e335)
- fix(shell): set default terminal fontSize to 6px (a60435d)
- fix(shell): default fontSize 10px and init new tabs immediately (6b0fcfb)
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (df46b5c)
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (7240813)
- fix(shell): enable allowProposedApi for Unicode11 addon (97bfb80)
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (3104179)
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (e21b47a)
- fix(shell): restore all missing imports, constants, and utility functions (2e98701)
- fix(shell): add missing Monitor import from lucide-react (f9d56de)
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (0e73408)
- fix(shell): add missing useI18n import (3b819be)
- fix(shell): remove stray 'impo' typo causing ReferenceError (c607943)
- fix(terminal): improve dimensions handling and add system theme for xterm (3312005)
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (6cc86b7)
- fix(terminal): improve dimension calculation and tab init reliability (1885616)
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (c8506d4)
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (68acabd)
- fix(terminal): use absolute positioning for content panels (b80562a)
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (c562972)
- fix(shell): prevent Enter in AI chat from leaking to terminal (3651f62)
- fix(terminal): improve terminal dimensions and fit timing (18e8347)
- fix(terminal): detect shell tab visibility via MutationObserver (6596d86)
- fix(terminal): init all tabs on load, fix excessive zoom (9fb5aa8)
- fix(terminal): improve tab visibility checks and positioning (ab3641d)
- fix(ui): adjust global CSS styles (5dac191)
- fix(terminal): use display:none instead of visibility for tab hiding (e6da61f)
- feat(ui): refactor copy state to Set and add helper functions (a994749)
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (b394ef9)
- feat(ui): redesign recent commands display and fix terminal visibility (fca5344)
- fix(shell): initialize activeTabRef with activeTab and move useEffect (0a3123e)
- fix(config): remove unused import, reorder hooks, and improve variable naming (e6447f2)
- fix(studio): add tool results serialization and improve message handling (16c5ed6)
- fix(shell): improve tab reference stability and command queueing (e8924be)
- fix(shell): add debug logging for tab tracking and WebSocket state (a905f22)
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (183dd27)
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (203f57f)
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (a1046da)
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (02ee41c)
- bump: v0.3.5 (06810be)
- fix: display all quota models, center card content vertically (8db3bd7)
- fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering (20237c0)
- fix(shell): set default terminal fontSize to 6px (9a218b1)
- fix(shell): default fontSize 10px and init new tabs immediately (399b845)
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (436d5c6)
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (5a9edc0)
- fix(shell): enable allowProposedApi for Unicode11 addon (5bdc7a6)
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (5a0480b)
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (80de4dd)
- fix(shell): restore all missing imports, constants, and utility functions (de52f4e)
- fix(shell): add missing Monitor import from lucide-react (98ff0dd)
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (9a1ff6e)
- fix(shell): add missing useI18n import (034b9ee)
- fix(shell): remove stray 'impo' typo causing ReferenceError (c1b1fc6)
- fix(terminal): improve dimensions handling and add system theme for xterm (50ca751)
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (b8aa935)
- fix(terminal): improve dimension calculation and tab init reliability (5627ddd)
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (d278725)
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (7d0f807)
- fix(terminal): use absolute positioning for content panels (cbf623b)
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (b85ebb8)
- fix(shell): prevent Enter in AI chat from leaking to terminal (7cc206d)
- fix(terminal): improve terminal dimensions and fit timing (bf8c0fd)
- fix(terminal): detect shell tab visibility via MutationObserver (08dc1fd)
- fix(terminal): init all tabs on load, fix excessive zoom (13e937a)
- fix(terminal): improve tab visibility checks and positioning (3cf701b)
- fix(ui): adjust global CSS styles (3a09e0e)
- fix(terminal): use display:none instead of visibility for tab hiding (47fa2e0)
- feat(ui): refactor copy state to Set and add helper functions (401292e)
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (199a7e4)
- feat(ui): redesign recent commands display and fix terminal visibility (c91931f)
- fix(shell): initialize activeTabRef with activeTab and move useEffect (cbbb224)
- fix(config): remove unused import, reorder hooks, and improve variable naming (8d10d21)
- fix(studio): add tool results serialization and improve message handling (e9696ef)
- fix(shell): improve tab reference stability and command queueing (1edd4f0)
- fix(shell): add debug logging for tab tracking and WebSocket state (92f943c)
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (1704b19)
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (40ec493)
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (233368c)
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (00118f0)
- chore: update CHANGELOG for v0.3.4 (c39203c)
- 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)
- chore: update CHANGELOG for v0.3.2 (28e5113)
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
- chore: update CHANGELOG for v0.3.1 (5b4a70e)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/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.4.0/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.4.0/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.4.0/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.5
### Changes since v0.3.5
- fix(shell): set default terminal fontSize to 6px (a60435d)
- fix(shell): default fontSize 10px and init new tabs immediately (6b0fcfb)
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (df46b5c)
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (7240813)
- fix(shell): enable allowProposedApi for Unicode11 addon (97bfb80)
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (3104179)
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (e21b47a)
- fix(shell): restore all missing imports, constants, and utility functions (2e98701)
- fix(shell): add missing Monitor import from lucide-react (f9d56de)
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (0e73408)
- fix(shell): add missing useI18n import (3b819be)
- fix(shell): remove stray 'impo' typo causing ReferenceError (c607943)
- fix(terminal): improve dimensions handling and add system theme for xterm (3312005)
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (6cc86b7)
- fix(terminal): improve dimension calculation and tab init reliability (1885616)
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (c8506d4)
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (68acabd)
- fix(terminal): use absolute positioning for content panels (b80562a)
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (c562972)
- fix(shell): prevent Enter in AI chat from leaking to terminal (3651f62)
- fix(terminal): improve terminal dimensions and fit timing (18e8347)
- fix(terminal): detect shell tab visibility via MutationObserver (6596d86)
- fix(terminal): init all tabs on load, fix excessive zoom (9fb5aa8)
- fix(terminal): improve tab visibility checks and positioning (ab3641d)
- fix(ui): adjust global CSS styles (5dac191)
- fix(terminal): use display:none instead of visibility for tab hiding (e6da61f)
- feat(ui): refactor copy state to Set and add helper functions (a994749)
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (b394ef9)
- feat(ui): redesign recent commands display and fix terminal visibility (fca5344)
- fix(shell): initialize activeTabRef with activeTab and move useEffect (0a3123e)
- fix(config): remove unused import, reorder hooks, and improve variable naming (e6447f2)
- fix(studio): add tool results serialization and improve message handling (16c5ed6)
- fix(shell): improve tab reference stability and command queueing (e8924be)
- fix(shell): add debug logging for tab tracking and WebSocket state (a905f22)
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (183dd27)
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (203f57f)
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (a1046da)
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (02ee41c)
- bump: v0.3.5 (06810be)
- fix: display all quota models, center card content vertically (8db3bd7)
- fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering (20237c0)
- chore: update CHANGELOG for v0.3.4 (c39203c)
- 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)
- chore: update CHANGELOG for v0.3.2 (28e5113)
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
- chore: update CHANGELOG for v0.3.1 (5b4a70e)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/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.5/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.5/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.5/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.4
### Changes since v0.3.3
- fix(ci): replace jq with python3 in release step, add debug output (7ae4017)
- feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects (8c540eb)
- feat(studio): Tab focuses textarea, autocomplete commands (1074b01)
- fix(studio): convert newlines to <br/> in AI message rendering (2da0cf9)
- fix(config): replace hardcoded model list with free text input (9987a58)
- feat(config): providers panel shows only MINIMAX/ZAI with model selector (2827acf)
- feat(dashboard): show top 5 most used commands as clickable chips (afb6e77)
- fix: tab containers height, dashboard 2-row grid, studio scroll buttons (84be226)
- feat(shell): dedicated System Analyst AI, no code execution, analyze system (f9c4cf1)
- fix: keep all tabs mounted, switch via CSS display instead of unmount (eda7293)
- refactor(config): locale panel with edit/save flow like profile (b55feae)
- feat(config): split profile into Personal Info + Preferences sections, centered (54621bd)
- feat(studio): improve context compression UI and provider display (6bad294)
- fix(config): locale panel show active language/keyboard, add save button (92eb783)
- feat(config): dynamic profile panel, generic save, tabs margin fix (8005e97)
- fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota (6e76e7d)
- feat(chat): add auto-summarization with token tracking UI (e8f6dc4)
- feat(dashboard): add background graphs to cards and improve layout (bb03c9f)
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (79d0821)
- feat(dashboard): add quota monitoring, process list, and command history (7682717)
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (3948a4c)
- fix(studio): improve chat context, thinking tags, streaming, and tool results (65804aa)
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (2e50366)
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (66b773f)
- feat(onboarding): add minimax api key step and AI-powered editor scan (bc5c295)
- fix(onboarding): require fields before advancing steps (e19122d)
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (8b6a7e8)
- fix(config): per-provider form state to avoid field cross-talk (58f8cb0)
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (b52fecc)
- feat(config): add system panel with reset and starship theme, add onboarding wizard (5bbac49)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.2
### Changes since v0.3.1
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
- chore: bump version to 3.2 (0fe82f6)
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
- feat(studio): add tool execution and hide AI thinking tags (12df184)
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
- feat(shell): restore AI assistant panel (4fd599a)
- fix(terminal): restore terminal input and cursor visibility (bcba593)
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.2-beta.1 (Beta)
### Commits since v0.3.1
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
> This is a **beta** release. Use at your own risk.
## v0.3.1
### Changes since v0.3.0
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
- feat(shell): restore AI assistant panel (2004c15)
- fix(terminal): restore terminal input and cursor visibility (9306152)
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-arm64.zip) |
The binary includes both CLI and Desktop modes.
Run `muyue` for TUI, `muyue desktop` for web UI.
### Install
**Linux (x86_64)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz | tar xz
chmod +x muyue-linux-amd64
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
```
**macOS (Apple Silicon)**
```bash
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
chmod +x muyue-darwin-arm64
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
```
**Windows (x86_64)**
```powershell
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.3.0 ## v0.3.0
### Changes since v0.2.1 ### Changes since v0.2.1

1073
CRUSH_ARCHITECTURE_REPORT.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,55 @@ package agent
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync"
"time" "time"
) )
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
func stripANSI(s string) string {
return ansiRegex.ReplaceAllString(s, "")
}
var (
sudoCache bool
sudoCacheSet bool
sudoCacheOnce sync.Once
)
func NeedsSudoPassword() bool {
sudoCacheOnce.Do(func() {
if os.Geteuid() == 0 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "sudo", "-n", "true").Run()
sudoCacheSet = true
sudoCache = err != nil
} else {
sudoCache = true
sudoCacheSet = true
}
})
return sudoCache
}
type TerminalParams struct { type TerminalParams struct {
Command string `json:"command" description:"The shell command to execute"` Command string `json:"command" description:"The shell command to execute"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"` Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
} }
type TerminalResponse struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
SudoBlocked bool `json:"sudo_blocked,omitempty"`
Command string `json:"command,omitempty"`
}
func NewTerminalTool() (*ToolDefinition, error) { func NewTerminalTool() (*ToolDefinition, error) {
return NewTool("terminal", return NewTool("terminal",
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.", "Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
@@ -22,6 +60,39 @@ func NewTerminalTool() (*ToolDefinition, error) {
return TextErrorResponse("command is required"), nil return TextErrorResponse("command is required"), nil
} }
if NeedsSudoPassword() {
trimmed := strings.TrimSpace(p.Command)
lower := strings.ToLower(trimmed)
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
anywhereBlocked := false
blockedCmd := ""
if !prefixBlocked {
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
if strings.Contains(lower, pattern) {
anywhereBlocked = true
blockedCmd = kw
break
}
}
if anywhereBlocked {
break
}
}
}
if prefixBlocked || anywhereBlocked {
elevCmd := blockedCmd
if prefixBlocked {
elevCmd = strings.Fields(trimmed)[0]
}
return ToolResponse{
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, elevCmd),
IsError: true,
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
}, nil
}
}
timeout := time.Duration(p.Timeout) * time.Second timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 { if timeout == 0 {
timeout = 60 * time.Second timeout = 60 * time.Second
@@ -39,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
result := string(output) result := string(output)
result = stripANSI(result)
if len(result) > 10000 { if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]" result = result[:10000] + "\n... [truncated]"
} }
@@ -52,21 +124,35 @@ func NewTerminalTool() (*ToolDefinition, error) {
} }
type CrushRunParams struct { type CrushRunParams struct {
Task string `json:"task" description:"The task description for Crush to execute"` Task string `json:"task" description:"The task description for Crush to execute"`
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
} }
func NewCrushRunTool() (*ToolDefinition, error) { func NewCrushRunTool() (*ToolDefinition, error) {
return NewTool("crush_run", return NewTool("crush_run",
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Returns the agent's final output.", "Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Optionally pass cwd to run in a specific directory, or wsl_distro/wsl_user to launch inside a WSL distribution under a specific user (Windows hosts only). Returns the agent's final output.",
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) { func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
if p.Task == "" { if p.Task == "" {
return TextErrorResponse("task is required"), nil return TextErrorResponse("task is required"), nil
} }
ctx, cancel := context.WithTimeout(ctx, 300*time.Second) timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 1800 * time.Second
}
if timeout > 1800*time.Second {
timeout = 1800 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "crush", "run", p.Task) cmd, prepErr := buildAgentCommand(ctx, "crush", []string{"run", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
if prepErr != nil {
return TextErrorResponse(prepErr.Error()), nil
}
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
result := string(output) result := string(output)
@@ -75,7 +161,66 @@ func NewCrushRunTool() (*ToolDefinition, error) {
} }
if err != nil { if err != nil {
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil errMsg := fmt.Sprintf("Crush error: %v", err)
if ctx.Err() == context.DeadlineExceeded {
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
}
if result != "" {
errMsg += "\n\n" + result
}
return TextErrorResponse(errMsg), nil
}
return TextResponse(result), nil
})
}
type ClaudeRunParams struct {
Task string `json:"task" description:"The task description for Claude Code to execute"`
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
}
func NewClaudeRunTool() (*ToolDefinition, error) {
return NewTool("claude_run",
"Delegate a complex coding task to the Claude Code CLI agent. Claude has access to file editing, code search, bash execution. Use for multi-step coding tasks. Same cwd/wsl_distro/wsl_user options as crush_run.",
func(ctx context.Context, p ClaudeRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 1800 * time.Second
}
if timeout > 1800*time.Second {
timeout = 1800 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd, prepErr := buildAgentCommand(ctx, "claude", []string{"-p", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
if prepErr != nil {
return TextErrorResponse(prepErr.Error()), nil
}
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 15000 {
result = result[:15000] + "\n... [truncated]"
}
if err != nil {
errMsg := fmt.Sprintf("Claude error: %v", err)
if ctx.Err() == context.DeadlineExceeded {
errMsg = fmt.Sprintf("Claude timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
}
if result != "" {
errMsg += "\n\n" + result
}
return TextErrorResponse(errMsg), nil
} }
return TextResponse(result), nil return TextResponse(result), nil
@@ -284,6 +429,7 @@ func DefaultRegistry() *Registry {
tools := []*ToolDefinition{ tools := []*ToolDefinition{
must(NewTerminalTool()), must(NewTerminalTool()),
must(NewCrushRunTool()), must(NewCrushRunTool()),
must(NewClaudeRunTool()),
must(NewReadFileTool()), must(NewReadFileTool()),
must(NewListFilesTool()), must(NewListFilesTool()),
must(NewSearchFilesTool()), must(NewSearchFilesTool()),

View File

@@ -26,6 +26,43 @@ func detectShell() string {
return "/bin/sh" return "/bin/sh"
} }
var validIdentifier = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// buildAgentCommand assembles an agent execution command, optionally launching it
// inside a WSL distribution (Windows host only) and applying a working directory.
// On non-Windows hosts, wsl_* parameters are ignored.
func buildAgentCommand(ctx context.Context, bin string, args []string, cwd, wslDistro, wslUser string) (*exec.Cmd, error) {
if wslDistro != "" && runtime.GOOS == "windows" {
if !validIdentifier.MatchString(wslDistro) {
return nil, fmt.Errorf("invalid wsl_distro: %q", wslDistro)
}
if wslUser != "" && !validIdentifier.MatchString(wslUser) {
return nil, fmt.Errorf("invalid wsl_user: %q", wslUser)
}
wslArgs := []string{"-d", wslDistro}
if wslUser != "" {
wslArgs = append(wslArgs, "-u", wslUser)
}
if cwd != "" {
wslArgs = append(wslArgs, "--cd", cwd)
}
wslArgs = append(wslArgs, "--")
wslArgs = append(wslArgs, bin)
wslArgs = append(wslArgs, args...)
return exec.CommandContext(ctx, "wsl", wslArgs...), nil
}
cmd := exec.CommandContext(ctx, bin, args...)
if cwd != "" {
dir := expandHome(cwd)
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
return nil, fmt.Errorf("cwd does not exist or is not a directory: %s", cwd)
}
cmd.Dir = dir
}
return cmd, nil
}
func expandHome(path string) string { func expandHome(path string) string {
if path == "" { if path == "" {
return "" return ""

View File

@@ -1,6 +1,43 @@
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur. Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur, et tu es spécialisé dans la **construction de prompts** selon la **méthode BMAD** (Breakthrough Method for Agile AI-Driven Development — https://github.com/bmad-code-org/BMAD-METHOD).
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev. Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est double :
1. Aider l'utilisateur à configurer, gérer et optimiser son environnement dev (avec les outils ci-dessous).
2. Construire pour lui des prompts structurés et actionnables avant d'exécuter une tâche complexe ou de la déléguer à un agent (`crush_run`, `claude_run`).
## Méthode BMAD — principes appliqués à chaque réponse
BMAD organise le travail IA comme une équipe agile : chaque demande est traitée avec une persona spécifique (Analyst, PM, Architect, SM, Dev, QA) puis exécutée. Tu n'as pas besoin de jouer toutes les personas — applique simplement leurs réflexes :
- **Analyst** : reformule l'objectif réel derrière la demande en 1 phrase. S'il est ambigu, choisis l'interprétation la plus probable et indique-la au début.
- **PM** : découpe en livrables concrets (épopée → stories). Pas plus de 3-5 stories pour une demande, chaque story doit être indépendamment livrable.
- **Architect** : pour toute story qui touche au code, identifie les fichiers concernés, les contraintes (compat, style, perf, sécurité) et les risques avant d'écrire.
- **SM (Scrum Master)** : si tu délègues à `crush_run`/`claude_run`, fournis un prompt **autonome** : objectif, contraintes, fichiers cibles, critère d'acceptation. Pas de référence à la conversation parente — l'agent ne la voit pas.
- **Dev** : exécute story par story. Vérifie chaque livraison avant de passer à la suivante.
- **QA** : avant de répondre "fini", relis l'objectif initial et confirme qu'il est atteint.
## Format d'un prompt BMAD délégué
Quand tu construis un prompt pour `crush_run`/`claude_run`, suis ce gabarit :
```
[OBJECTIF] <une phrase, l'objectif final>
[CONTEXTE] <fichiers/dossiers concernés, ce qui existe déjà>
[CONTRAINTES] <ne pas faire X, préserver Y, respecter style Z>
[LIVRABLE] <fichier(s) modifié(s), comportement attendu>
[CRITÈRE D'ACCEPTATION] <comment savoir que c'est fini>
```
Ce gabarit est **obligatoire** pour toute délégation à un agent. Il évite que l'agent erre, suppose, ou produise du code hors-périmètre.
<critical_rules>
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils immédiatement. Ne dis pas "je pourrais faire X" — fais-le.
2. **SOIS AUTONOME** — Ne pose pas de questions si tu peux chercher, lire, déduire. Essaie plusieurs approches avant de bloquer. Ne t'arrête que pour les erreurs bloquantes réelles (credentials manquants, permissions, etc.).
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule ("Voici...", "Je vais..."), pas de postambule ("N'hésitez pas...", "J'espère que..."). Réponse directe. Un mot quand c'est suffisant.
4. **GÈRE LES ERREURS** — Si un outil échoue, essaie 2-3 approches alternatives avant de rapporter l'échec. Lis le message d'erreur complet, isole la cause racine.
5. **NE DEVINE PAS** — Lis les fichiers avant d'éditer. Utilise les outils pour obtenir les informations manquantes (lire, chercher, grep).
6. **CONFIDENTIALITÉ** — Ne révèle jamais les clés API, mots de passe, tokens ou informations sensibles.
7. **LANGUE** — Réponds dans la même langue que l'utilisateur.
</critical_rules>
## Environnement ## Environnement
@@ -13,32 +50,93 @@ Muyue gère :
## Outils disponibles ## Outils disponibles
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le. | Outil | Usage |
|-------|-------|
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
| **claude_run** | Déléguer une tâche complexe à Claude Code CLI |
| **read_file** | Lire le contenu d'un fichier |
| **list_files** | Lister les fichiers d'un répertoire |
| **search_files** | Chercher des fichiers par motif (glob) |
| **grep_content** | Chercher du texte dans les fichiers |
| **get_config** | Lire la configuration Muyue |
| **set_provider** | Configurer un fournisseur IA |
| **manage_ssh** | Gérer les connexions SSH |
| **web_fetch** | Récupérer le contenu d'une URL |
| **browser_test** | Piloter un onglet de navigateur de l'utilisateur (clic, eval, lecture console) — voir `<browser_test_strategy>` ci-dessous |
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.) <browser_test_strategy>
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug) Quand l'utilisateur demande de **tester** une UI / une page (ses boutons, ses formulaires, son comportement), utilise `browser_test`. La page cible doit déjà être connectée via le snippet de l'onglet "Tests" — sinon, l'outil te le dira et tu demandes à l'utilisateur de coller le snippet.
- **read_file** : Lire le contenu d'un fichier
- **list_files** : Lister les fichiers d'un répertoire
- **search_files** : Chercher des fichiers par motif (glob)
- **grep_content** : Chercher du texte dans le contenu des fichiers
- **get_config** : Lire la configuration Muyue
- **set_provider** : Configurer un fournisseur IA
- **manage_ssh** : Gérer les connexions SSH
- **web_fetch** : Récupérer le contenu d'une URL
## Règles Boucle recommandée :
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils pour le faire. Ne dis pas "je pourrais faire X" — fais-le. 1. `browser_test` action `summary` — voir l'URL, le titre et les dernières erreurs console déjà présentes.
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe. 2. `browser_test` action `list_clickables` — récupérer la liste indexée des boutons / liens / inputs cliquables.
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire. 3. Pour chaque cible : `browser_test` action `click` (avec `index` ou `selector`).
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur. 4. Immédiatement après chaque clic, **regarde le `console_delta` retourné** : c'est la liste des messages console émis pendant le clic. `level: "error"` = bouton cassé.
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.) 5. Vérifie aussi `current_url` retourné — un changement d'URL inattendu peut signaler un bug.
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses. 6. Si l'élément ouvre un dialog ou modifie le DOM, refais `list_clickables` pour découvrir les nouveaux éléments.
7. **Langue** — Réponds dans la même langue que l'utilisateur. 7. Pour les inputs : utilise `type` avant `click` sur le bouton de soumission.
8. À la fin, fournis un **rapport** structuré : ✓ boutons OK / ✗ boutons cassés (avec le message d'erreur exact) / ⚠ boutons disabled ou non trouvés.
Astuces :
- Préfère cliquer **par `index`** que par sélecteur — le sélecteur change avec le DOM, l'index reste stable jusqu'au prochain `list_clickables`.
- Entre deux actions sensibles, `wait` 200-500 ms si la page a des transitions / fetches asynchrones.
- N'utilise jamais `eval` pour cliquer si `click` suffit.
</browser_test_strategy>
<tool_strategy>
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
- **Délégation intelligente** — Pour les tâches complexes (refactoring, création de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaîner des commandes terminal
- **Lecture de fichiers** — Utilise TOUJOURS `read_file` pour lire le contenu d'un fichier. N'utilise PAS `terminal` avec `cat` pour lire des fichiers — `read_file` est plus rapide, plus précis, et consomme moins de tokens
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
</tool_strategy>
<decision_making>
- Décide par toi-même : cherche, lis, déduis, agis
- Ne demande confirmation que pour : actions destructrices (suppression, overwrite), plusieurs approches valides avec des trade-offs importants
- Si bloqué : documente (a) ce que tu as essayé, (b) pourquoi tu es bloqué, (c) l'action minimale requise
- Ne t'arrête jamais pour : tâche trop grosse (découpe), trop de fichiers (change-les), complexité (gère-la)
</decision_making>
<error_recovery>
1. Lis le message d'erreur complet
2. Comprends la cause racine
3. Essaie une approche différente (pas la même)
4. Cherche du code similaire qui fonctionne
5. Applique un correctif ciblé
6. Vérifie que ça marche
7. Pour chaque erreur, essaie au moins 2-3 stratégies avant de conclure que c'est bloquant
</error_recovery>
## Format des réponses ## Format des réponses
- Code : utilise des blocs markdown - **Code** : blocs markdown avec le langage spécifié
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes - **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
- Erreurs : explique clairement et propose une solution - **Erreurs** : explique clairement la cause et propose une solution concrète
- Succès : confirme brièvement ce qui a été fait - **Succès** : confirme brièvement ce qui a été fait (1 ligne)
- **Multi-fichiers** : liste les fichiers modifiés avec `fichier:ligne` pour les références
## Diagrammes Mermaid
Tu peux utiliser des diagrammes Mermaid pour visualiser des architectures, flux, séquences, etc.
Utilise un bloc code avec le langage `mermaid` :
```mermaid
graph TD
A[Début] --> B{Décision}
B -->|Oui| C[Action]
B -->|Non| D[Fin]
```
Types utiles :
- `graph TD/LR` — Architecture, flux de données
- `sequenceDiagram` — Interactions entre composants
- `flowchart` — Processus et décisions
- `classDiagram` — Structures de données
- `erDiagram` — Schémas de base de données
- `gantt` — Planning et timelines
Utilise Mermaid quand ça apporte de la clarté : architecture complexe, flux multi-étapes, relations entre entités. Ne l'utilise pas pour du texte simple.

612
internal/api/browsertest.go Normal file
View File

@@ -0,0 +1,612 @@
package api
// Browser-test feature: an out-of-process page (the user's target tab)
// connects to Muyue via WebSocket using a short-lived token, and exposes a
// thin RPC: Studio's AI can list clickable elements, click them, evaluate JS,
// read the recent console buffer, and observe what changes after each action.
//
// Threat model: an injected snippet runs in the user's chosen page only, with
// the same origin as that page; the WS endpoint is bound to localhost and
// gated by a 5-minute token issued by the local Muyue server.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/agent"
)
const (
browserTestTokenTTL = 5 * time.Minute
browserTestCommandTTL = 30 * time.Second
browserTestConsoleMax = 200
browserTestSessionsMax = 16
)
// BrowserTestSession represents one connected browser tab.
type BrowserTestSession struct {
ID string
URL string
Title string
conn *websocket.Conn
mu sync.Mutex
console []ConsoleEntry
pending map[string]chan json.RawMessage
pendingMu sync.Mutex
connectedAt time.Time
writeMu sync.Mutex
}
// ConsoleEntry is a captured console message from the connected page.
type ConsoleEntry struct {
Level string `json:"level"` // log, info, warn, error, debug
Message string `json:"message"`
Time string `json:"time"`
}
// BrowserTestStore manages active sessions + pending one-shot connect tokens.
type BrowserTestStore struct {
mu sync.RWMutex
sessions map[string]*BrowserTestSession
tokens map[string]time.Time
tokensMu sync.Mutex
}
func NewBrowserTestStore() *BrowserTestStore {
return &BrowserTestStore{
sessions: map[string]*BrowserTestSession{},
tokens: map[string]time.Time{},
}
}
// IssueToken creates a single-use token used by the snippet to authenticate.
func (s *BrowserTestStore) IssueToken() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
tok := hex.EncodeToString(buf)
s.tokensMu.Lock()
now := time.Now()
for k, v := range s.tokens {
if now.Sub(v) > browserTestTokenTTL {
delete(s.tokens, k)
}
}
s.tokens[tok] = now
s.tokensMu.Unlock()
return tok
}
// ConsumeToken validates and removes a token in one step.
func (s *BrowserTestStore) ConsumeToken(tok string) bool {
s.tokensMu.Lock()
defer s.tokensMu.Unlock()
t, ok := s.tokens[tok]
if !ok {
return false
}
delete(s.tokens, tok)
return time.Since(t) <= browserTestTokenTTL
}
// Register inserts a new session, evicting the oldest if at capacity.
func (s *BrowserTestStore) Register(session *BrowserTestSession) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.sessions) >= browserTestSessionsMax {
var oldestID string
var oldest time.Time
for id, sess := range s.sessions {
if oldestID == "" || sess.connectedAt.Before(oldest) {
oldestID = id
oldest = sess.connectedAt
}
}
if old, ok := s.sessions[oldestID]; ok {
old.conn.Close()
delete(s.sessions, oldestID)
}
}
s.sessions[session.ID] = session
}
func (s *BrowserTestStore) Remove(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[id]; ok {
sess.conn.Close()
delete(s.sessions, id)
}
}
func (s *BrowserTestStore) Get(id string) *BrowserTestSession {
s.mu.RLock()
defer s.mu.RUnlock()
return s.sessions[id]
}
// Pick returns the requested session by ID, or the most-recently-connected
// session if id is empty. Returns nil if no session matches.
func (s *BrowserTestStore) Pick(id string) *BrowserTestSession {
s.mu.RLock()
defer s.mu.RUnlock()
if id != "" {
return s.sessions[id]
}
var picked *BrowserTestSession
for _, sess := range s.sessions {
if picked == nil || sess.connectedAt.After(picked.connectedAt) {
picked = sess
}
}
return picked
}
func (s *BrowserTestStore) List() []map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]map[string]interface{}, 0, len(s.sessions))
for _, sess := range s.sessions {
out = append(out, map[string]interface{}{
"id": sess.ID,
"url": sess.URL,
"title": sess.Title,
"connected_at": sess.connectedAt.Format(time.RFC3339),
})
}
return out
}
// Send issues an RPC command to the browser session and waits up to TTL for
// the matching reply. Returns the raw payload or an error.
func (sess *BrowserTestSession) Send(action string, params map[string]interface{}) (json.RawMessage, error) {
cid := newCorrelationID()
ch := make(chan json.RawMessage, 1)
sess.pendingMu.Lock()
sess.pending[cid] = ch
sess.pendingMu.Unlock()
defer func() {
sess.pendingMu.Lock()
delete(sess.pending, cid)
sess.pendingMu.Unlock()
}()
cmd := map[string]interface{}{
"id": cid,
"action": action,
"params": params,
}
sess.writeMu.Lock()
err := sess.conn.WriteJSON(cmd)
sess.writeMu.Unlock()
if err != nil {
return nil, fmt.Errorf("write: %w", err)
}
select {
case payload := <-ch:
return payload, nil
case <-time.After(browserTestCommandTTL):
return nil, fmt.Errorf("browser session did not reply within %s", browserTestCommandTTL)
}
}
// AppendConsole records a console line, trimming to the buffer cap.
func (sess *BrowserTestSession) AppendConsole(level, message string) {
sess.mu.Lock()
defer sess.mu.Unlock()
sess.console = append(sess.console, ConsoleEntry{
Level: level,
Message: message,
Time: time.Now().Format(time.RFC3339),
})
if len(sess.console) > browserTestConsoleMax {
sess.console = sess.console[len(sess.console)-browserTestConsoleMax:]
}
}
// SnapshotConsole returns a copy of the current console buffer.
func (sess *BrowserTestSession) SnapshotConsole() []ConsoleEntry {
sess.mu.Lock()
defer sess.mu.Unlock()
out := make([]ConsoleEntry, len(sess.console))
copy(out, sess.console)
return out
}
func newCorrelationID() string {
buf := make([]byte, 8)
rand.Read(buf)
return hex.EncodeToString(buf)
}
// HTTP handlers --------------------------------------------------------------
func (s *Server) handleBrowserTestSnippet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
tok := s.browserTestStore.IssueToken()
host := r.Host
if host == "" {
host = "127.0.0.1"
}
scheme := "ws"
if r.TLS != nil {
scheme = "wss"
}
wsURL := fmt.Sprintf("%s://%s/api/ws/browser-test?token=%s", scheme, host, tok)
snippet := buildBrowserTestSnippet(wsURL)
writeJSON(w, map[string]interface{}{
"token": tok,
"ws_url": wsURL,
"snippet": snippet,
"expires_in": int(browserTestTokenTTL / time.Second),
})
}
func (s *Server) handleBrowserTestSessions(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
writeJSON(w, map[string]interface{}{
"sessions": s.browserTestStore.List(),
})
}
func (s *Server) handleBrowserTestConsole(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/test/console/")
sess := s.browserTestStore.Pick(id)
if sess == nil {
writeError(w, "no active browser test session", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"url": sess.URL,
"console": sess.SnapshotConsole(),
})
}
// browserTestUpgrader accepts any origin: the connection is gated by a
// short-lived token issued to the local UI, not by Origin checking.
var browserTestUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func (s *Server) handleBrowserTestWS(w http.ResponseWriter, r *http.Request) {
tok := r.URL.Query().Get("token")
if tok == "" || !s.browserTestStore.ConsumeToken(tok) {
writeError(w, "invalid or expired token", http.StatusUnauthorized)
return
}
conn, err := browserTestUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
conn.SetReadLimit(2 << 20)
// Read the hello message: page sends {"type":"hello","url":"...","title":"..."}.
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
var hello struct {
Type string `json:"type"`
URL string `json:"url"`
Title string `json:"title"`
}
if err := conn.ReadJSON(&hello); err != nil || hello.Type != "hello" {
conn.WriteJSON(map[string]string{"type": "error", "message": "expected hello"})
conn.Close()
return
}
conn.SetReadDeadline(time.Time{})
id := newCorrelationID()
sess := &BrowserTestSession{
ID: id,
URL: hello.URL,
Title: hello.Title,
conn: conn,
pending: map[string]chan json.RawMessage{},
connectedAt: time.Now(),
}
s.browserTestStore.Register(sess)
defer s.browserTestStore.Remove(id)
// Acknowledge with the assigned session ID.
sess.writeMu.Lock()
conn.WriteJSON(map[string]string{"type": "registered", "session_id": id})
sess.writeMu.Unlock()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var msg struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Level string `json:"level,omitempty"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "console":
sess.AppendConsole(msg.Level, msg.Text)
case "url_change":
sess.mu.Lock()
sess.URL = msg.URL
sess.mu.Unlock()
case "reply":
sess.pendingMu.Lock()
ch, ok := sess.pending[msg.ID]
sess.pendingMu.Unlock()
if ok {
select {
case ch <- msg.Data:
default:
}
}
case "ping":
sess.writeMu.Lock()
conn.WriteJSON(map[string]string{"type": "pong"})
sess.writeMu.Unlock()
}
}
}
// Agent tool -----------------------------------------------------------------
// BrowserTestParams is the schema exposed to the AI for the browser_test tool.
type BrowserTestParams struct {
Action string `json:"action" description:"One of: list_clickables, click, eval, console, current_url, wait, type, summary"`
SessionID string `json:"session_id,omitempty" description:"Browser session id (optional, defaults to most recent)"`
Selector string `json:"selector,omitempty" description:"CSS selector for click/type actions"`
Index int `json:"index,omitempty" description:"Alternative to selector: index into the last list_clickables result (0-based)"`
Expr string `json:"expr,omitempty" description:"JS expression to evaluate (eval action only)"`
Text string `json:"text,omitempty" description:"Text to type (type action only)"`
WaitMs int `json:"wait_ms,omitempty" description:"Milliseconds to wait (wait action only, max 5000)"`
Tail int `json:"tail,omitempty" description:"Console action: how many recent lines to return (default 50, max 200)"`
}
// RegisterBrowserTestTool wires the agent tool against a session store.
func RegisterBrowserTestTool(reg *agent.Registry, store *BrowserTestStore) error {
tool, err := agent.NewTool("browser_test",
"Drive the user's connected browser tab for end-to-end testing. Available actions: list_clickables (returns indexed clickable elements), click (by selector or index), eval (run a JS expression and return result), console (read recent console output, ideal to spot errors after a click), current_url, wait (sleep ms before next check), type (set value on an input), summary (URL+title+last console entries). Always start with list_clickables; click; then console to verify no errors.",
func(ctx context.Context, p BrowserTestParams) (agent.ToolResponse, error) {
sess := store.Pick(p.SessionID)
if sess == nil {
return agent.TextErrorResponse("no active browser session — ask the user to paste the snippet from the Tests tab in their target page"), nil
}
action := strings.ToLower(strings.TrimSpace(p.Action))
switch action {
case "":
return agent.TextErrorResponse("action is required"), nil
case "list_clickables", "click", "eval", "current_url", "type":
case "console", "summary", "wait":
default:
return agent.TextErrorResponse("unknown action: " + p.Action), nil
}
if action == "console" {
tail := p.Tail
if tail <= 0 {
tail = 50
}
if tail > browserTestConsoleMax {
tail = browserTestConsoleMax
}
entries := sess.SnapshotConsole()
if len(entries) > tail {
entries = entries[len(entries)-tail:]
}
out, _ := json.MarshalIndent(map[string]interface{}{
"session_id": sess.ID,
"console": entries,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
if action == "summary" {
entries := sess.SnapshotConsole()
if len(entries) > 20 {
entries = entries[len(entries)-20:]
}
out, _ := json.MarshalIndent(map[string]interface{}{
"session_id": sess.ID,
"url": sess.URL,
"title": sess.Title,
"recent_console": entries,
}, "", " ")
return agent.TextResponse(string(out)), nil
}
if action == "wait" {
ms := p.WaitMs
if ms <= 0 {
ms = 200
}
if ms > 5000 {
ms = 5000
}
select {
case <-ctx.Done():
return agent.TextErrorResponse("cancelled"), nil
case <-time.After(time.Duration(ms) * time.Millisecond):
}
return agent.TextResponse(fmt.Sprintf("waited %dms", ms)), nil
}
// Capture console snapshot length before so we can return only the delta
// after the action — useful so the AI can spot errors caused by the click.
pre := len(sess.SnapshotConsole())
params := map[string]interface{}{}
if p.Selector != "" {
params["selector"] = p.Selector
}
if p.Index > 0 || (action == "click" && p.Selector == "") {
params["index"] = p.Index
}
if p.Expr != "" {
params["expr"] = p.Expr
}
if p.Text != "" {
params["text"] = p.Text
}
payload, err := sess.Send(action, params)
if err != nil {
return agent.TextErrorResponse(err.Error()), nil
}
// Console delta: messages logged during this command.
post := sess.SnapshotConsole()
var delta []ConsoleEntry
if len(post) > pre {
delta = post[pre:]
}
result := map[string]interface{}{
"action": action,
"reply": json.RawMessage(payload),
"console_delta": delta,
"current_url": sess.URL,
}
out, _ := json.MarshalIndent(result, "", " ")
return agent.TextResponse(string(out)), nil
})
if err != nil {
return err
}
return reg.Register(tool)
}
// Snippet generator ----------------------------------------------------------
func buildBrowserTestSnippet(wsURL string) string {
// Note: this is the JS injected into the user's target page. It opens the
// WS, hooks console, and dispatches commands. Kept terse on purpose.
return `(function(){
if (window.__muyueTestRunner) { console.log('[Muyue] runner already attached'); return; }
var WS_URL = ` + jsString(wsURL) + `;
var ws = new WebSocket(WS_URL);
var lastList = [];
function send(obj){ try{ ws.send(JSON.stringify(obj)); }catch(e){} }
function reply(id, data){ send({type:'reply', id:id, data:data}); }
function safeText(el){
var t = (el.innerText || el.textContent || '').trim();
if (t.length > 80) t = t.slice(0,80)+'…';
return t;
}
function describe(el){
var sel = el.id ? '#'+el.id : el.tagName.toLowerCase();
if (!el.id && el.className && typeof el.className === 'string') {
sel += '.' + el.className.trim().split(/\s+/).slice(0,2).join('.');
}
var label = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('name') || '';
return { tag: el.tagName.toLowerCase(), selector: sel, text: safeText(el), label: label, type: el.getAttribute('type')||'', disabled: !!el.disabled };
}
function list(){
var els = Array.from(document.querySelectorAll('button, a[href], input[type=submit], input[type=button], [role=button], [onclick]'));
lastList = els.filter(function(e){ var r=e.getBoundingClientRect(); return r.width>0 && r.height>0; });
return lastList.map(describe).map(function(d,i){ d.index = i; return d; });
}
function clickEl(el){
if (!el) return { ok:false, error:'element not found' };
if (el.disabled) return { ok:false, error:'element is disabled' };
try { el.scrollIntoView({block:'center'}); el.click(); return { ok:true }; }
catch(e){ return { ok:false, error:String(e) }; }
}
function dispatch(msg){
var p = msg.params || {};
switch(msg.action){
case 'list_clickables': return list();
case 'click': {
var el;
if (p.selector) el = document.querySelector(p.selector);
else if (typeof p.index === 'number') el = lastList[p.index];
return clickEl(el);
}
case 'eval': {
try { var r = (0,eval)(p.expr); return { ok:true, value: serialize(r) }; }
catch(e){ return { ok:false, error:String(e) }; }
}
case 'current_url': return { url: location.href, title: document.title };
case 'type': {
var el = p.selector ? document.querySelector(p.selector) : (lastList[p.index]);
if (!el) return { ok:false, error:'element not found' };
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
try { setter && setter.set ? setter.set.call(el, p.text||'') : (el.value = p.text||''); }
catch(e){ el.value = p.text||''; }
el.dispatchEvent(new Event('input', {bubbles:true}));
el.dispatchEvent(new Event('change', {bubbles:true}));
return { ok:true };
}
}
return { ok:false, error:'unknown action' };
}
function serialize(v){
if (v === undefined) return 'undefined';
try { return JSON.parse(JSON.stringify(v)); }
catch(e){ return String(v); }
}
['log','info','warn','error','debug'].forEach(function(lvl){
var orig = console[lvl];
console[lvl] = function(){
try {
var parts = Array.from(arguments).map(function(a){
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch(e){ return String(a); }
});
send({type:'console', level: lvl, text: parts.join(' ')});
} catch(e){}
return orig.apply(console, arguments);
};
});
window.addEventListener('error', function(e){
send({type:'console', level:'error', text:'window.onerror: '+(e.message||e.error||'unknown')});
});
window.addEventListener('unhandledrejection', function(e){
send({type:'console', level:'error', text:'unhandledrejection: '+String(e.reason)});
});
var lastUrl = location.href;
setInterval(function(){
if (location.href !== lastUrl){ lastUrl = location.href; send({type:'url_change', url: lastUrl}); }
}, 500);
ws.onopen = function(){ send({type:'hello', url: location.href, title: document.title}); };
ws.onmessage = function(ev){
try { var msg = JSON.parse(ev.data); }
catch(e){ return; }
if (msg.type === 'registered') { console.log('[Muyue] connected — session', msg.session_id); return; }
if (msg.action) reply(msg.id, dispatch(msg));
};
ws.onclose = function(){ console.log('[Muyue] runner disconnected'); window.__muyueTestRunner = null; };
window.__muyueTestRunner = { ws: ws, list: list };
})();`
}
func jsString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}

View File

@@ -3,8 +3,8 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
@@ -14,6 +14,9 @@ const (
MaxToolIterations = 15 MaxToolIterations = 15
) )
// ToolLimiter checks if a tool call is allowed and returns a release function.
type ToolLimiter func(toolName string) (release func(), err error)
// ChatEngine handles chat interactions with tool execution. // ChatEngine handles chat interactions with tool execution.
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go. // This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
type ChatEngine struct { type ChatEngine struct {
@@ -22,6 +25,8 @@ type ChatEngine struct {
tools json.RawMessage tools json.RawMessage
onChunk func(map[string]interface{}) onChunk func(map[string]interface{})
stream bool stream bool
limiter ToolLimiter
TotalTokens int
} }
// NewChatEngine creates a new ChatEngine instance. // NewChatEngine creates a new ChatEngine instance.
@@ -44,6 +49,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
ce.onChunk = fn ce.onChunk = fn
} }
// SetLimiter sets the tool call limiter for agent concurrency control.
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
ce.limiter = l
}
// RunWithTools executes the chat loop with tool calls. // RunWithTools executes the chat loop with tool calls.
// Returns final content, tool calls, tool results, and error. // Returns final content, tool calls, tool results, and error.
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) { func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
@@ -72,16 +82,19 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
return finalContent, allToolCalls, allToolResults, err return finalContent, allToolCalls, allToolResults, err
} }
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, allToolCalls, allToolResults, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0] choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content) content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" { if content != "" {
words := strings.Fields(content) if ce.onChunk != nil {
for _, w := range words { ce.onChunk(map[string]interface{}{"content": content})
chunk := w
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"content": chunk})
}
} }
finalContent = content finalContent = content
} }
@@ -92,7 +105,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
assistantMsg := orchestrator.Message{ assistantMsg := orchestrator.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls, ToolCalls: choice.Message.ToolCalls,
} }
messages = append(messages, assistantMsg) messages = append(messages, assistantMsg)
@@ -115,7 +128,40 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments), Arguments: json.RawMessage(tc.Function.Arguments),
} }
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
limResultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": limitErr.Error(),
"is_error": true,
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": limitErr.Error(),
"is_error": true,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call) result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil { if execErr != nil {
result = agent.ToolResponse{ result = agent.ToolResponse{
Content: execErr.Error(), Content: execErr.Error(),
@@ -128,6 +174,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
"content": result.Content, "content": result.Content,
"is_error": result.IsError, "is_error": result.IsError,
} }
if result.Meta != nil {
for k, v := range result.Meta {
resultData[k] = v
}
}
allToolResults = append(allToolResults, map[string]interface{}{ allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID, "tool_call_id": tc.ID,
"name": tc.Function.Name, "name": tc.Function.Name,
@@ -142,7 +193,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "tool", Role: "tool",
Content: result.Content, Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID, ToolCallID: tc.ID,
Name: tc.Function.Name, Name: tc.Function.Name,
}) })
@@ -154,6 +205,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
return finalContent, allToolCalls, allToolResults, nil return finalContent, allToolCalls, allToolResults, nil
} }
// ProviderName returns the name of the active provider used by the engine.
func (ce *ChatEngine) ProviderName() string {
return ce.orchestrator.ProviderName()
}
// RunNonStream executes chat without streaming content to client. // RunNonStream executes chat without streaming content to client.
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) { func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
var finalContent string var finalContent string
@@ -164,8 +220,15 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
return finalContent, err return finalContent, err
} }
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0] choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content) content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" { if content != "" {
finalContent = content finalContent = content
@@ -177,7 +240,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
assistantMsg := orchestrator.Message{ assistantMsg := orchestrator.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls, ToolCalls: choice.Message.ToolCalls,
} }
messages = append(messages, assistantMsg) messages = append(messages, assistantMsg)
@@ -189,7 +252,25 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments), Arguments: json.RawMessage(tc.Function.Arguments),
} }
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call) result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil { if execErr != nil {
result = agent.ToolResponse{ result = agent.ToolResponse{
Content: execErr.Error(), Content: execErr.Error(),
@@ -199,7 +280,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "tool", Role: "tool",
Content: result.Content, Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID, ToolCallID: tc.ID,
Name: tc.Function.Name, Name: tc.Function.Name,
}) })
@@ -244,6 +325,5 @@ func SetupSSEHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }

127
internal/api/consumption.go Normal file
View File

@@ -0,0 +1,127 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/muyue/muyue/internal/config"
)
type consumptionEntry struct {
Date string `json:"date"`
Tokens int `json:"tokens"`
Requests int `json:"requests"`
}
type providerConsumption struct {
Name string `json:"name"`
Daily []consumptionEntry `json:"daily"`
Total int `json:"total_tokens"`
Requests int `json:"total_requests"`
}
type consumptionStore struct {
mu sync.Mutex
providers map[string]*providerConsumption
}
func newConsumptionStore() *consumptionStore {
cs := &consumptionStore{
providers: make(map[string]*providerConsumption),
}
cs.load()
cs.prune()
return cs
}
func (cs *consumptionStore) Record(providerName string, tokens int) {
if tokens <= 0 || providerName == "" {
return
}
cs.mu.Lock()
defer cs.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
p, ok := cs.providers[providerName]
if !ok {
p = &providerConsumption{Name: providerName}
cs.providers[providerName] = p
}
p.Total += tokens
p.Requests++
if len(p.Daily) > 0 && p.Daily[len(p.Daily)-1].Date == today {
p.Daily[len(p.Daily)-1].Tokens += tokens
p.Daily[len(p.Daily)-1].Requests++
} else {
p.Daily = append(p.Daily, consumptionEntry{
Date: today,
Tokens: tokens,
Requests: 1,
})
}
cs.save()
}
func (cs *consumptionStore) GetAll() map[string]*providerConsumption {
cs.mu.Lock()
defer cs.mu.Unlock()
result := make(map[string]*providerConsumption)
for k, v := range cs.providers {
pc := *v
daily := make([]consumptionEntry, len(v.Daily))
copy(daily, v.Daily)
pc.Daily = daily
result[k] = &pc
}
return result
}
func (cs *consumptionStore) prune() {
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
for _, p := range cs.providers {
filtered := make([]consumptionEntry, 0)
for _, d := range p.Daily {
if d.Date >= cutoff {
filtered = append(filtered, d)
}
}
p.Daily = filtered
}
}
func (cs *consumptionStore) filePath() string {
dir, err := config.ConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "consumption.json")
}
func (cs *consumptionStore) load() {
fp := cs.filePath()
if fp == "" {
return
}
data, err := os.ReadFile(fp)
if err != nil {
return
}
json.Unmarshal(data, &cs.providers)
}
func (cs *consumptionStore) save() {
fp := cs.filePath()
if fp == "" {
return
}
data, _ := json.Marshal(cs.providers)
os.WriteFile(fp, data, 0644)
}

View File

@@ -13,15 +13,57 @@ import (
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
) )
const maxTokensApprox = 100000 const contextWindowTokens = 150000
const summarizeThreshold = 80000 const summarizeRatio = 0.80
const charsPerToken = 4 const charsPerToken = 4
func extractDisplayContent(role, content string) string {
if role != "assistant" {
return content
}
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
ToolResults []struct {
Name string `json:"name"`
Result string `json:"result"`
} `json:"tool_results"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return content
}
var sb strings.Builder
if parsed.Content != "" {
sb.WriteString(parsed.Content)
}
for _, tc := range parsed.ToolCalls {
sb.WriteString("\n[")
sb.WriteString(tc.Name)
sb.WriteString("] ")
sb.WriteString(tc.Args)
}
for _, tr := range parsed.ToolResults {
sb.WriteString("\n[result")
if tr.Name != "" {
sb.WriteString(":")
sb.WriteString(tr.Name)
}
sb.WriteString("] ")
sb.WriteString(tr.Result)
}
return sb.String()
}
type FeedMessage struct { type FeedMessage struct {
ID string `json:"id"` ID string `json:"id"`
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Time string `json:"time"` Time string `json:"time"`
Images []string `json:"images,omitempty"`
Summarized bool `json:"summarized,omitempty"`
} }
type Conversation struct { type Conversation struct {
@@ -126,14 +168,38 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
return msg return msg
} }
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
Images: imageIDs,
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() { func (cs *ConversationStore) Clear() {
cs.mu.Lock() cs.mu.Lock()
defer cs.mu.Unlock() defer cs.mu.Unlock()
var imageIDs []string
for _, m := range cs.conv.Messages {
imageIDs = append(imageIDs, m.Images...)
}
cs.conv.Messages = []FeedMessage{} cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = "" cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339) cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339) cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save() cs.save()
go cleanupImages(imageIDs)
} }
func (cs *ConversationStore) SetSummary(summary string) { func (cs *ConversationStore) SetSummary(summary string) {
@@ -143,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) {
cs.save() cs.save()
} }
func (cs *ConversationStore) TrimOld(keepCount int) { func (cs *ConversationStore) MarkSummarized(upToIndex int) {
cs.mu.Lock() cs.mu.Lock()
defer cs.mu.Unlock() defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount { if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
return return
} }
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:] for i := 0; i < upToIndex; i++ {
cs.conv.Messages[i].Summarized = true
}
cs.save() cs.save()
} }
@@ -166,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
} }
for _, m := range cs.conv.Messages { for _, m := range cs.conv.Messages {
count := utf8.RuneCountInString(m.Content) / charsPerToken if m.Role == "system" || m.Summarized {
continue
}
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
result.byMessage += count result.byMessage += count
result.byRole[m.Role] += count result.byRole[m.Role] += count
} }
@@ -181,7 +252,7 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
} }
func (cs *ConversationStore) NeedsSummarization() bool { func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > summarizeThreshold return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
} }
func (cs *ConversationStore) Search(query string) []SearchResult { func (cs *ConversationStore) Search(query string) []SearchResult {

View File

@@ -222,9 +222,9 @@ func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
Time: time.Now().Format(time.RFC3339), Time: time.Now().Format(time.RFC3339),
} }
conv.Messages = append(conv.Messages, msg) conv.Messages = append(conv.Messages, msg)
go cs.saveCurrent() // Fire and forget cs.saveCurrent()
return msg return msg
} }

View File

@@ -0,0 +1,172 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"
"github.com/muyue/muyue/internal/orchestrator"
)
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Task string `json:"task"`
Tool string `json:"tool,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Task == "" {
writeError(w, "task is required", http.StatusBadRequest)
return
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(buildAITaskSystemPrompt())
orb.SetTools(s.shellAgentToolsJSON)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
messages := []orchestrator.Message{
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
}
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
return
}
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
parsed := parseAIJSONResponse(finalContent)
writeJSON(w, map[string]interface{}{
"status": "ok",
"raw": finalContent,
"result": parsed,
"tokens": engine.TotalTokens,
})
}
func buildAITaskSystemPrompt() string {
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
IMPORTANT RULES:
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
- Always run the actual commands needed to complete the task.
- Be thorough: check versions, verify installations, compare with latest releases.
OS: %s/%s
Date: %s
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
}
func buildAITaskPrompt(task, tool string) string {
switch task {
case "check_tools":
return `Check the following tools on this system. For each tool, determine:
1. Is it installed? Run "which <tool>" or "<tool> --version"
2. If installed, what is the current version?
3. What is the latest available version? Check GitHub releases API or official sources.
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
{
"tools": [
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
]
}`
case "install_tool":
return fmt.Sprintf(`Install the tool "%s" on this system.
Steps:
1. Check if it's already installed: run "which %s" and "%s --version"
2. If not installed, determine the best installation method for this OS
3. Run the installation command
4. Verify the installation succeeded
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"installed": true/false,
"version": "installed version or empty",
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool, tool)
case "update_tool":
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
Steps:
1. Check current version: run "%s --version"
2. Find the latest version available
3. Run the update/upgrade command
4. Verify the new version
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"previous_version": "old version",
"version": "new version",
"updated": true/false,
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool)
default:
return task
}
}
func parseAIJSONResponse(content string) interface{} {
cleaned := content
if idx := strings.Index(cleaned, "```json"); idx != -1 {
cleaned = cleaned[idx+7:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
cleaned = cleaned[idx+3:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
}
cleaned = strings.TrimSpace(cleaned)
jsonStart := strings.Index(cleaned, "{")
jsonEnd := strings.LastIndex(cleaned, "}")
if jsonStart != -1 && jsonEnd > jsonStart {
cleaned = cleaned[jsonStart : jsonEnd+1]
}
var result interface{}
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
return map[string]interface{}{
"raw": content,
"error": "failed to parse AI response as JSON",
}
}
return result
}

View File

@@ -1,26 +1,143 @@
package api package api
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/platform"
) )
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`) var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
var fileMentionRegex = regexp.MustCompile(`@(\S+\.[a-zA-Z0-9]+)`)
type ImageAttachment struct {
Data string `json:"data"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
}
func resolveFileMentions(text string) string {
return fileMentionRegex.ReplaceAllStringFunc(text, func(match string) string {
filePath := match[1:]
if strings.HasPrefix(filePath, "~/") {
if home, err := os.UserHomeDir(); err == nil {
filePath = filepath.Join(home, filePath[2:])
}
}
if !filepath.IsAbs(filePath) {
if home, err := os.UserHomeDir(); err == nil {
filePath = filepath.Join(home, filePath)
}
}
data, err := os.ReadFile(filePath)
if err != nil {
return match + fmt.Sprintf(" (erreur: fichier non trouve)")
}
content := string(data)
if len(content) > 50000 {
content = content[:50000] + "\n... (tronque a 50Ko)"
}
return fmt.Sprintf("[Fichier: %s]\n%s\n[Fin du fichier: %s]", filepath.Base(filePath), content, filepath.Base(filePath))
})
}
var vlmClient = &http.Client{Timeout: 60 * time.Second}
func (s *Server) describeImages(images []ImageAttachment) []string {
var apiKey string
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Active {
apiKey = s.config.AI.Providers[i].APIKey
break
}
}
if apiKey == "" {
return nil
}
descriptions := make([]string, 0, len(images))
for _, img := range images {
desc, err := s.callVLM(apiKey, img)
if err != nil {
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
} else {
descriptions = append(descriptions, desc)
}
}
return descriptions
}
func (s *Server) callVLM(apiKey string, img ImageAttachment) (string, error) {
payload := map[string]string{
"prompt": "Describe this image in detail. Include all text, UI elements, code, diagrams, or data visible. Be thorough and specific.",
"image_url": img.Data,
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal vlm request: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.minimax.io/v1/coding_plan/vlm", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create vlm request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := vlmClient.Do(req)
if err != nil {
return "", fmt.Errorf("vlm request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read vlm response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("vlm API error (%d): %s", resp.StatusCode, string(respBody))
}
var result struct {
Content string `json:"content"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("parse vlm response: %w", err)
}
if result.Content == "" {
return "(empty description)", nil
}
return result.Content, nil
}
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed) writeError(w, "POST only", http.StatusMethodNotAllowed)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
var body struct { var body struct {
Message string `json:"message"` Message string `json:"message"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
Images []ImageAttachment `json:"images"`
AdvancedReflection bool `json:"advanced_reflection"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
@@ -30,8 +147,44 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, "no message", http.StatusMethodNotAllowed) writeError(w, "no message", http.StatusMethodNotAllowed)
return return
} }
if len(body.Images) > 3 {
writeError(w, "max 3 images", http.StatusBadRequest)
return
}
s.convStore.Add("user", body.Message) enrichedMessage := resolveFileMentions(body.Message)
var imageIDs []string
if len(body.Images) > 0 {
descriptions := s.describeImages(body.Images)
var imgContext strings.Builder
for i, desc := range descriptions {
imgContext.WriteString(fmt.Sprintf("\n[Image %d (%s): %s]\n", i+1, body.Images[i].Filename, desc))
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
if err != nil {
_ = err
} else {
imageIDs = append(imageIDs, id)
}
}
enrichedMessage = imgContext.String() + enrichedMessage
}
displayMsg := body.Message
if len(body.Images) > 0 {
imgNames := make([]string, len(body.Images))
for i, img := range body.Images {
imgNames[i] = img.Filename
}
displayMsg += " [" + strings.Join(imgNames, ", ") + "]"
}
if len(imageIDs) > 0 {
s.convStore.AddWithImages("user", displayMsg, imageIDs)
} else {
s.convStore.Add("user", displayMsg)
}
if s.convStore.NeedsSummarization() { if s.convStore.NeedsSummarization() {
s.autoSummarize() s.autoSummarize()
@@ -42,13 +195,34 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusServiceUnavailable) writeError(w, err.Error(), http.StatusServiceUnavailable)
return return
} }
orb.SetSystemPrompt(agent.StudioSystemPrompt()) var studioPrompt strings.Builder
studioPrompt.WriteString(agent.StudioSystemPrompt())
sysInfo := platform.Detect()
osName := sysInfo.OSName
if osName == "" {
osName = string(sysInfo.OS)
}
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\nSystème: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05"), osName))
canSudo := !agent.NeedsSudoPassword()
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
if !canSudo {
studioPrompt.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
} else {
studioPrompt.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
}
orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON) orb.SetTools(s.agentToolsJSON)
if body.AdvancedReflection {
if report, ok := s.runReflectionReport(enrichedMessage); ok {
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
}
}
if body.Stream { if body.Stream {
s.handleStreamChat(w, orb, body.Message) s.handleStreamChat(w, orb, enrichedMessage)
} else { } else {
s.handleNonStreamChat(w, orb, body.Message) s.handleNonStreamChat(w, orb, enrichedMessage)
} }
} }
@@ -64,6 +238,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
messages := s.buildContextMessages(userMessage) messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) { engine.OnChunk(func(data map[string]interface{}) {
if data == nil { if data == nil {
return return
@@ -92,6 +267,8 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
} }
s.convStore.Add("assistant", storeContent) s.convStore.Add("assistant", storeContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
sseWriter.Write(map[string]interface{}{"done": "true"}) sseWriter.Write(map[string]interface{}{"done": "true"})
} }
@@ -100,6 +277,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
messages := s.buildContextMessages(userMessage) messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages) finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
@@ -107,6 +285,9 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
} }
s.convStore.Add("assistant", finalContent) s.convStore.Add("assistant", finalContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]string{"content": finalContent}) writeJSON(w, map[string]string{"content": finalContent})
} }
@@ -114,53 +295,95 @@ func cleanThinkingTags(content string) string {
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, "")) return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
} }
const contextWindowMessages = 20 // runReflectionReport runs the inactive AI provider on the user message to
// produce a preliminary analysis report that the active provider will then
// use as additional context. Returns ("", false) if no inactive provider is
// configured or on error — the caller falls back to a normal chat flow.
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
orb, err := orchestrator.NewForInactiveProvider(s.config)
if err != nil {
return "", false
}
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
resp, err := orb.SendNoTools(userMessage)
if err != nil {
return "", false
}
return strings.TrimSpace(resp), true
}
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message { func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
history := s.convStore.Get() history := s.convStore.Get()
start := 0
if len(history) > contextWindowMessages { sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
start = len(history) - contextWindowMessages toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
responseMargin := 4000
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
available := contextWindowTokens - overhead
if available < 1000 {
available = 1000
} }
messages := make([]orchestrator.Message, 0, len(history[start:])+1) included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
if history[i].Summarized {
break
}
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
hasSummarized := false
for i := 0; i < start; i++ {
if history[i].Summarized {
hasSummarized = true
break
}
}
if start > 0 {
_ = start
}
messages := make([]orchestrator.Message, 0, included+2)
summary := s.convStore.GetSummary() summary := s.convStore.GetSummary()
if summary != "" { if summary != "" && (start > 0 || hasSummarized) {
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "system", Role: "system",
Content: "Résumé de la conversation précédente:\n" + summary, Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
}) })
} }
for _, m := range history[start:] { for _, m := range history[start:] {
content := m.Content if m.Role == "system" {
if m.Role == "assistant" {
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
content = parsed.Content
}
}
role := m.Role
if role == "system" {
continue continue
} }
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: role, Role: m.Role,
Content: content, Content: orchestrator.TextContent(displayContent),
}) })
} }
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "user", Role: "user",
Content: userMessage, Content: orchestrator.TextContent(userMessage),
}) })
return messages return messages
@@ -195,8 +418,7 @@ func (s *Server) autoSummarize() {
} }
s.convStore.SetSummary(result) s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half) s.convStore.MarkSummarized(half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
} }
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
@@ -208,8 +430,8 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"messages": messages, "messages": messages,
"tokens": s.convStore.ApproxTokenCount(), "tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox, "max_tokens": contextWindowTokens,
"summarize_at": summarizeThreshold, "summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
"summary": s.convStore.GetSummary(), "summary": s.convStore.GetSummary(),
}) })
} }

View File

@@ -5,7 +5,21 @@ import (
"net/http" "net/http"
) )
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.` const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
CONSERVE :
- Les décisions techniques prises et leur rationale
- Les configurations modifiées (noms exacts, valeurs)
- Les fichiers/chemins manipulés
- Les erreurs rencontrées et leurs résolutions
- Le contexte nécessaire pour continuer
ÉLIMINE :
- Les échanges de politesse
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
- Les sorties d'outils brutes (garde seulement les conclusions)
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
func writeJSON(w http.ResponseWriter, data interface{}) { func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)

View File

@@ -53,32 +53,41 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, "no config", http.StatusNotFound) writeError(w, "no config", http.StatusNotFound)
return return
} }
var body struct {
Name string `json:"name"` currentJSON, err := json.Marshal(s.config.Profile)
Pseudo string `json:"pseudo"` if err != nil {
Email string `json:"email"` writeError(w, err.Error(), http.StatusInternalServerError)
Editor string `json:"editor"` return
Shell string `json:"shell"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { var currentMap map[string]interface{}
if err := json.Unmarshal(currentJSON, &currentMap); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var updates map[string]interface{}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
return return
} }
if body.Name != "" { if err := json.Unmarshal(body, &updates); err != nil {
s.config.Profile.Name = body.Name writeError(w, err.Error(), http.StatusBadRequest)
return
} }
if body.Pseudo != "" {
s.config.Profile.Pseudo = body.Pseudo deepMerge(currentMap, updates)
mergedJSON, err := json.Marshal(currentMap)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
} }
if body.Email != "" { if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil {
s.config.Profile.Email = body.Email writeError(w, err.Error(), http.StatusInternalServerError)
} return
if body.Editor != "" {
s.config.Profile.Preferences.Editor = body.Editor
}
if body.Shell != "" {
s.config.Profile.Preferences.Shell = body.Shell
} }
if err := config.Save(s.config); err != nil { if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@@ -86,6 +95,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"}) 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) { func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" { if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed) writeError(w, "PUT only", http.StatusMethodNotAllowed)
@@ -113,7 +136,7 @@ func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
found := false found := false
for i := range s.config.AI.Providers { for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Name == body.Name { if s.config.AI.Providers[i].Name == body.Name {
if body.APIKey != "" { if body.APIKey != "" && body.APIKey != "***" {
s.config.AI.Providers[i].APIKey = body.APIKey s.config.AI.Providers[i].APIKey = body.APIKey
} }
if body.Model != "" { if body.Model != "" {
@@ -164,6 +187,14 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
writeError(w, "api_key required", http.StatusBadRequest) writeError(w, "api_key required", http.StatusBadRequest)
return return
} }
if body.APIKey == "***" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
body.APIKey = p.APIKey
break
}
}
}
baseURL := body.BaseURL baseURL := body.BaseURL
if baseURL == "" { if baseURL == "" {
@@ -178,6 +209,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
switch body.Name { switch body.Name {
case "minimax": case "minimax":
baseURL = "https://api.minimax.io/v1" baseURL = "https://api.minimax.io/v1"
case "mimo":
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
case "openai": case "openai":
baseURL = "https://api.openai.com/v1" baseURL = "https://api.openai.com/v1"
case "anthropic": case "anthropic":
@@ -255,7 +288,7 @@ func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Reque
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
return return
} }
if body.FontSize > 0 { if body.FontSize > 0 && body.FontSize <= 72 {
s.config.Terminal.FontSize = body.FontSize s.config.Terminal.FontSize = body.FontSize
} }
if body.FontFamily != "" { if body.FontFamily != "" {
@@ -324,30 +357,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
body.Theme = s.config.Terminal.PromptTheme body.Theme = s.config.Terminal.PromptTheme
} }
cfgDir, err := config.ConfigDir() themeFile := ApplyStarshipTheme(body.Theme)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) s.config.Terminal.PromptTheme = body.Theme
return config.Save(s.config)
}
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func ApplyStarshipTheme(theme string) string {
cfgDir, _ := config.ConfigDir()
starshipDir := filepath.Join(cfgDir, "starship") starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil { os.MkdirAll(starshipDir, 0755)
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := filepath.Join(starshipDir, "starship.toml") themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme) themeContent := getStarshipThemeConfig(theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil { os.WriteFile(themeFile, []byte(themeContent), 0644)
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
shellRCs := []string{ for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
if _, err := os.Stat(rc); err != nil { if _, err := os.Stat(rc); err != nil {
continue continue
} }
@@ -364,10 +392,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
f.Close() f.Close()
} }
s.config.Terminal.PromptTheme = body.Theme return themeFile
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
} }
func getStarshipThemeConfig(theme string) string { func getStarshipThemeConfig(theme string) string {

View File

@@ -8,9 +8,11 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
@@ -23,7 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"name": version.Name, "name": version.Name,
"version": version.Version, "version": version.Version,
"author": version.Author, "author": version.Author,
"sudo": os.Geteuid() == 0, "sudo": !agent.NeedsSudoPassword(),
}) })
} }
@@ -78,8 +80,23 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
writeError(w, "no config", http.StatusNotFound) writeError(w, "no config", http.StatusNotFound)
return return
} }
masked := make([]map[string]interface{}, 0, len(s.config.AI.Providers))
for _, p := range s.config.AI.Providers {
entry := map[string]interface{}{
"name": p.Name,
"model": p.Model,
"base_url": p.BaseURL,
"active": p.Active,
}
if p.APIKey != "" {
entry["api_key"] = "***"
} else {
entry["api_key"] = ""
}
masked = append(masked, entry)
}
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"providers": s.config.AI.Providers, "providers": masked,
}) })
} }
@@ -89,6 +106,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
for i := range list {
list[i].Deployed = skills.IsDeployed(list[i].Name)
}
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"skills": list, "skills": list,
"count": len(list), "count": len(list),
@@ -198,9 +218,20 @@ func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&body) json.NewDecoder(r.Body).Decode(&body)
} }
home, _ := os.UserHomeDir()
if body.ProjectDir == "" { if body.ProjectDir == "" {
home, _ := os.UserHomeDir()
body.ProjectDir = home body.ProjectDir = home
} else {
abs, err := filepath.Abs(body.ProjectDir)
if err != nil {
writeError(w, "invalid project_dir", http.StatusBadRequest)
return
}
body.ProjectDir = abs
if home != "" && !strings.HasPrefix(abs, home+string(filepath.Separator)) && abs != home {
writeError(w, "project_dir must be within user home", http.StatusBadRequest)
return
}
} }
results, err := lsp.AutoInstallForProject(body.ProjectDir) results, err := lsp.AutoInstallForProject(body.ProjectDir)
@@ -493,10 +524,88 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
resp.Body.Close() resp.Body.Close()
var data map[string]interface{} var data map[string]interface{}
if json.Unmarshal(body, &data) == nil { if json.Unmarshal(body, &data) == nil {
q.Data = data if d, ok := data["data"].(map[string]interface{}); ok {
q.Healthy = true if limits, ok := d["limits"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
for _, l := range limits {
if lm, ok := l.(map[string]interface{}); ok {
name := "Z.AI"
if model, ok := lm["model"].(string); ok && model != "" {
name = model
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
name = t
}
usage, _ := lm["usage"].(float64)
remaining, _ := lm["remaining"].(float64)
limitVal, hasLimit := lm["limit"].(float64)
total := usage + remaining
if hasLimit && limitVal > 0 {
total = limitVal
}
if total > 0 {
models = append(models, map[string]interface{}{
"model": name,
"used": usage,
"total": total,
"remaining": remaining,
})
}
}
}
if len(models) > 0 {
q.Data = map[string]interface{}{"models": models}
q.Healthy = true
}
}
}
} }
} }
case "mimo":
q.Healthy = p.APIKey != ""
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
mimoBase := p.BaseURL
if mimoBase == "" {
mimoBase = "https://token-plan-ams.xiaomimimo.com/v1"
}
req, _ := http.NewRequest("GET", strings.TrimRight(mimoBase, "/")+"/models", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if modelList, ok := data["data"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
for _, m := range modelList {
if mm, ok := m.(map[string]interface{}); ok {
id, _ := mm["id"].(string)
if id != "" {
models = append(models, map[string]interface{}{
"model": id,
})
}
}
}
q.Data = map[string]interface{}{"models": models, "available": len(models)}
q.Healthy = true
}
}
}
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 {
q.Error = "claude code not installed"
}
default: default:
q.Error = "quota not supported" q.Error = "quota not supported"
} }
@@ -505,6 +614,15 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"providers": results}) writeJSON(w, map[string]interface{}{"providers": results})
} }
func (s *Server) handleProvidersConsumption(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
data := s.consumption.GetAll()
writeJSON(w, map[string]interface{}{"providers": data})
}
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
type cmdEntry struct { type cmdEntry struct {
@@ -525,10 +643,11 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
shell = "zsh" shell = "zsh"
} }
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
start := len(lines) - 25 start := len(lines) - 50
if start < 0 { if start < 0 {
start = 0 start = 0
} }
for i := len(lines) - 1; i >= start; i-- { for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i]) line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@@ -545,6 +664,15 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
if line == "" { if line == "" {
continue continue
} }
base := strings.Fields(line)[0]
if len(base) < 2 {
continue
}
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
continue
}
entries = append(entries, cmdEntry{Cmd: line, Shell: shell}) entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
} }
} }

View File

@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "all deployed"}) writeJSON(w, map[string]string{"status": "all deployed"})
} }
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name is required", http.StatusBadRequest)
return
}
if err := skills.Undeploy(body.Name); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
}
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" { if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed) writeError(w, "GET only", http.StatusMethodNotAllowed)

View File

@@ -3,51 +3,25 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os"
"os/exec"
"runtime"
"strings" "strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
) )
const maxShellToolIterations = 10
type ShellChatRequest struct { type ShellChatRequest struct {
Message string `json:"message"` Message string `json:"message"`
Context string `json:"context,omitempty"` Context string `json:"context,omitempty"`
History []string `json:"history,omitempty"` Cwd string `json:"cwd,omitempty"`
Cwd string `json:"cwd,omitempty"` Platform string `json:"platform,omitempty"`
Platform string `json:"platform,omitempty"` Stream bool `json:"stream"`
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
} }
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) { func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
@@ -56,6 +30,11 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return return
} }
if s.shellConvStore.AtLimit() {
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
return
}
var req ShellChatRequest var req ShellChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
@@ -67,6 +46,8 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return return
} }
s.shellConvStore.Add("user", req.Message)
orb, err := orchestrator.New(s.config) orb, err := orchestrator.New(s.config)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable) writeError(w, err.Error(), http.StatusServiceUnavailable)
@@ -74,61 +55,59 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
} }
orb.SetSystemPrompt(s.buildShellSystemPrompt(req)) orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
orb.SetTools(s.agentToolsJSON) orb.SetTools(s.shellAgentToolsJSON)
if req.Stream { if req.Stream {
s.handleShellChatStream(w, orb, req) s.handleShellChatStream(w, orb)
} else { } else {
s.handleShellChatNonStream(w, orb, req) s.handleShellChatNonStream(w, orb)
} }
} }
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string { func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
var sb strings.Builder 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: sb.WriteString(shellSystemPromptBase)
- 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
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses. analysis := LoadSystemAnalysis()
if analysis != "" {
sb.WriteString("<system_context>\n")
sb.WriteString(analysis)
sb.WriteString("\n</system_context>\n\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")
}
if user := os.Getenv("USER"); user != "" {
sb.WriteString("User: " + user + "\n")
}
if req.Cwd != "" { canSudo := !agent.NeedsSudoPassword()
sb.WriteString("Répertoire courant: " + req.Cwd + "\n") sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
} if canSudo {
if req.Platform != "" { sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
sb.WriteString("Plateforme: " + req.Platform + "\n") } else {
} sb.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\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")
}
} }
now := time.Now()
sb.WriteString(fmt.Sprintf("Date: %s\nHeure: %s\n", now.Format("02/01/2006"), now.Format("15:04:05")))
return sb.String() return sb.String()
} }
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
SetupSSEHeaders(w) SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher) flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w) sseWriter := NewSSEWriter(w)
ctx := context.Background() ctx := context.Background()
messages := []orchestrator.Message{ messages := s.buildShellContextMessages()
{Role: "user", Content: req.Message},
}
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
var toolCalls []ToolCallInfo
engine.OnChunk(func(data map[string]interface{}) { engine.OnChunk(func(data map[string]interface{}) {
if data == nil { if data == nil {
return return
@@ -137,72 +116,261 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
if canFlush { if canFlush {
flusher.Flush() 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) finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil { if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()}) sseWriter.Write(map[string]interface{}{"error": err.Error()})
return return
} }
if finalContent == "" && len(toolCalls) > 0 { storeContent := finalContent
finalContent = "(opérations terminées)" if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{
"content": storeContent,
"tool_calls": allToolCalls,
"tool_results": allToolResults,
}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
} }
s.shellConvStore.Add("assistant", storeContent)
writeJSONResp, _ := json.Marshal(ShellChatResponse{ s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
Content: finalContent,
ToolCalls: toolCalls, 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) { func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
ctx := context.Background() ctx := context.Background()
messages := []orchestrator.Message{ messages := s.buildShellContextMessages()
{Role: "user", Content: req.Message},
}
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages) finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
if finalContent == "" { s.shellConvStore.Add("assistant", finalContent)
finalContent = "(tool calls completed, no text response)"
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]interface{}{
"content": finalContent,
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) buildShellContextMessages() []orchestrator.Message {
history := s.shellConvStore.Get()
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
}
sysTokens += 100
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
responseMargin := 4000
overhead := sysTokens + toolsTokens + responseMargin
available := shellMaxTokens - overhead
if available < 1000 {
available = 1000
} }
writeJSON(w, ShellChatResponse{ included := 0
Content: finalContent, tokensUsed := 0
ToolCalls: nil, for i := len(history) - 1; i >= 0; i-- {
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
if start > 0 {
_ = start
}
messages := make([]orchestrator.Message, 0, included)
for _, m := range history[start:] {
if m.Role == "system" {
continue
}
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
Role: m.Role,
Content: orchestrator.TextContent(displayContent),
})
}
return messages
}
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
}
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 et génère un rapport structuré en markdown.
STRUCTURE REQUISE :
## État du système
- Résumé en 2-3 phrases de l'état général (OK/Attention/Critique)
## Points d'attention
Liste les problèmes détectés par priorité :
- **CRITIQUE** : problèmes de sécurité, espace disque < 10%, mémoire < 10%
- **ATTENTION** : CPU élevé, services en échec, config non-optimale
- **INFO** : améliorations possibles, mises à jour disponibles
## Recommandations
Pour chaque point d'attention, donne UNE commande ou action corrective concrète.
## Outils manquants
Liste les outils utiles non installés avec la commande d'installation.
## Réseau
- Interfaces actives, ports en écoute, connectivité
RÈGLES :
- Pas de blabla générique — sois spécifique à CE système
- Inclus les valeurs numériques réelles (%, Go, MHz)
- Max 1500 mots
- Le rapport sert de contexte persistant 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,
})
}
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
analysis := LoadSystemAnalysis()
if analysis == "" {
writeJSON(w, map[string]interface{}{"analysis": nil})
return
}
writeJSON(w, map[string]interface{}{"analysis": analysis})
}

View File

@@ -144,7 +144,7 @@ func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
engine, _ = workflow.NewEngine(s.agentRegistry) engine, _ = workflow.NewEngine(s.agentRegistry)
} }
wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps) wf := engine.Create("Plan: "+truncateString(body.Goal, 30), body.Goal, "plan_execute", steps)
writeJSON(w, wf) writeJSON(w, wf)
} }
@@ -188,7 +188,6 @@ func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *work
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher) flusher, canFlush := w.(http.Flusher)
@@ -250,9 +249,10 @@ func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "approved"}) writeJSON(w, map[string]string{"status": "approved"})
} }
func min(a, b int) int { func truncateString(s string, max int) string {
if a < b { runes := []rune(s)
return a if len(runes) <= max {
return s
} }
return b return string(runes[:max])
} }

106
internal/api/image_cache.go Normal file
View File

@@ -0,0 +1,106 @@
package api
import (
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/muyue/muyue/internal/config"
)
var imageDir string
func init() {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
imageDir = filepath.Join(dir, "images")
os.MkdirAll(imageDir, 0755)
}
var imageCounter uint64
func saveImage(dataURI, filename, mimeType string) (string, error) {
parts := strings.SplitN(dataURI, ",", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid data URI")
}
encoded := parts[1]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
if len(decoded) > 10*1024*1024 {
return "", fmt.Errorf("image too large (max 10MB)")
}
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
ext := ".png"
switch mimeType {
case "image/jpeg":
ext = ".jpg"
case "image/webp":
ext = ".webp"
}
filePath := filepath.Join(imageDir, id+ext)
if err := os.WriteFile(filePath, decoded, 0600); err != nil {
return "", fmt.Errorf("write image: %w", err)
}
return id + ext, nil
}
func imagePath(id string) string {
return filepath.Join(imageDir, filepath.Base(id))
}
func cleanupImages(ids []string) {
for _, id := range ids {
p := imagePath(id)
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
_ = err
}
}
}
func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/images/")
if id == "" {
writeError(w, "image id required", http.StatusBadRequest)
return
}
filePath := imagePath(id)
if _, err := os.Stat(filePath); err != nil {
writeError(w, "image not found", http.StatusNotFound)
return
}
ext := strings.ToLower(filepath.Ext(id))
switch ext {
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, filePath)
}

View File

@@ -2,24 +2,34 @@ package api
import ( import (
"encoding/json" "encoding/json"
"log" "fmt"
"net/http" "net/http"
"os/exec"
"strings" "strings"
"sync/atomic"
"github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow" "github.com/muyue/muyue/internal/workflow"
) )
type Server struct { type Server struct {
config *config.MuyueConfig config *config.MuyueConfig
scanResult *scanner.ScanResult scanResult *scanner.ScanResult
mux *http.ServeMux mux *http.ServeMux
convStore *ConversationStore convStore *ConversationStore
agentRegistry *agent.Registry shellConvStore *ShellConvStore
agentToolsJSON json.RawMessage consumption *consumptionStore
workflowEngine *workflow.Engine agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
browserTestStore *BrowserTestStore
activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32
} }
func NewServer(cfg *config.MuyueConfig) *Server { func NewServer(cfg *config.MuyueConfig) *Server {
@@ -39,18 +49,34 @@ func NewServer(cfg *config.MuyueConfig) *Server {
} }
// Save initial config to establish the file for first-time usage // Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil { if err := config.Save(defaultCfg); err != nil {
log.Printf("config: initial save failed: %v", err) _ = err
} }
cfg = defaultCfg cfg = defaultCfg
} }
s.config = cfg s.config = cfg
s.scanResult = scanner.ScanSystem() s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore() s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore()
s.consumption = newConsumptionStore()
s.agentRegistry = agent.DefaultRegistry() s.agentRegistry = agent.DefaultRegistry()
s.browserTestStore = NewBrowserTestStore()
if err := RegisterBrowserTestTool(s.agentRegistry, s.browserTestStore); err != nil {
// Tool registration only fails for duplicate names — non-fatal
_ = err
}
tools := s.agentRegistry.OpenAITools() tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools) toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON) s.agentToolsJSON = json.RawMessage(toolsJSON)
s.shellAgentRegistry = agent.NewRegistry()
terminalTool, _ := agent.NewTerminalTool()
s.shellAgentRegistry.Register(terminalTool)
shellTools := s.shellAgentRegistry.OpenAITools()
shellToolsJSON, _ := json.Marshal(shellTools)
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry) s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.initStarship()
s.routes() s.routes()
return s return s
} }
@@ -82,6 +108,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme) s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider) s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate) s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/images/", s.handleServeImage)
s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
@@ -89,6 +116,10 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/tool/call", s.handleToolCall) s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList) s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat) 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/shell/analysis", s.handleShellAnalysisGet)
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate) s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList) s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet) s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
@@ -101,6 +132,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation) s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall) s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy) s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections) s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest) s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
@@ -114,20 +146,30 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport) s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport) s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus) s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands) s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses) s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics) s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
s.mux.HandleFunc("/api/test/snippet", s.handleBrowserTestSnippet)
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
} }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/ws/") { if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
s.mux.ServeHTTP(w, r) s.mux.ServeHTTP(w, r)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -135,3 +177,53 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
s.mux.ServeHTTP(w, r) s.mux.ServeHTTP(w, r)
} }
func isAllowedOrigin(origin string) bool {
if origin == "" {
return false
}
switch {
case strings.HasPrefix(origin, "http://127.0.0.1"),
strings.HasPrefix(origin, "http://localhost"),
strings.HasPrefix(origin, "http://[::1]"),
strings.HasPrefix(origin, "https://127.0.0.1"),
strings.HasPrefix(origin, "https://localhost"),
strings.HasPrefix(origin, "https://[::1]"):
return true
}
return false
}
const maxCrushAgents = 2
const maxClaudeAgents = 2
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
var counter *atomic.Int32
var max int32
switch toolName {
case "crush_run":
counter = &s.activeCrushAgents
max = maxCrushAgents
case "claude_run":
counter = &s.activeClaudeAgents
max = maxClaudeAgents
default:
return func() {}, nil
}
current := counter.Add(1)
if current > max {
counter.Add(-1)
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
}
return func() { counter.Add(-1) }, nil
}
func (s *Server) initStarship() {
if _, err := exec.LookPath("starship"); err != nil {
inst := installer.New(s.config)
if result := inst.InstallTool("starship"); !result.Success {
return
}
}
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
}

View File

@@ -0,0 +1,185 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const shellMaxTokens = 100000
const shellCharsPerToken = 4
const shellSystemPromptBase = `Tu es l'**Analyste Système** de Muyue. Tu es un expert en administration système, DevOps et développement.
<critical_rules>
1. **AGIS, ne décris pas** — Utilise l'outil terminal pour exécuter, ne te contente pas de proposer des commandes.
2. **SOIS AUTONOME** — Cherche les infos manquantes via des commandes avant de demander à l'utilisateur. Essaie plusieurs approches avant de bloquer.
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule. Réponse directe et technique.
4. **GÈRE LES ERREURS** — Si une commande échoue, lis l'erreur, comprends la cause, essaie une approche alternative. 2-3 tentatives avant de rapporter.
5. **SÉCURITÉ** — Ne révèle jamais de credentials. Demande confirmation avant les commandes destructrices (rm -rf, format, etc.).
6. **LANGUE** — Réponds dans la même langue que l'utilisateur.
</critical_rules>
<tool_usage>
Outil disponible : **terminal** — Exécute des commandes shell sur le système local.
Stratégies :
- **Diagnostique** — Enchaîne les commandes de diagnostic (ps, df, free, top, journalctl, dmesg, netstat, ss, etc.)
- **Parallélisme** — Combine les commandes avec && ou ; quand elles sont indépendantes
- **Filtrage** — Utilise grep, awk, sort, head pour extraire l'essentiel des sorties volumineuses
- **Non-interactif** — Préfère les commandes non-interactives (apt install -y, non pas apt install)
- **Troncature** — Si le résultat dépasse 2000 caractères, résume les points clés au lieu de tout afficher
</tool_usage>
<decision_making>
- Décide par toi-même : exécute des commandes pour comprendre l'état du système
- Ne demande confirmation que pour les actions destructrices
- Si tu ne connais pas la commande exacte, exécute la commande avec --help pour la trouver
- Si bloqué : documente ce que tu as essayé, pourquoi, et l'action minimale requise
- Ne t'arrête jamais pour une tâche complexe — découpe en étapes et exécute-les
</decision_making>
<error_recovery>
1. Lis le message d'erreur complet (stderr + stdout)
2. Identifie la cause racine (permissions, paquet manquant, config, service)
3. Essaie : vérifier le service, vérifier les logs, chercher le paquet, tester la connexion
4. Propose une solution concrète, pas générique
</error_recovery>
<response_format>
- **Commandes** : blocs markdown avec le langage (bash, sh, etc.)
- **Résultats** : résume les métriques clés, pas de dump complet
- **Erreurs** : cause + solution en 1-2 lignes
- **Succès** : confirmation en 1 ligne
- **Analyses** : markdown structuré avec sections si nécessaire
</response_format>
<mermaid>
Tu peux utiliser des diagrammes Mermaid pour visualiser :
- Architecture système (graph TD/LR)
- Flux réseau (sequenceDiagram)
- Processus (flowchart)
- Timeline (gantt)
Utilise un bloc de code avec le langage mermaid quand ça clarifie l'explication. Pas pour du texte simple.
</mermaid>
`
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 {
if m.Role == "system" {
continue
}
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
}
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
total += utf8.RuneCountInString(analysis) / 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

@@ -3,11 +3,11 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
@@ -48,7 +48,6 @@ type wsMessage struct {
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("ws upgrade: %v", err)
return return
} }
defer conn.Close() defer conn.Close()
@@ -56,26 +55,23 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
var initMsg wsMessage var initMsg wsMessage
_, raw, err := conn.ReadMessage() _, raw, err := conn.ReadMessage()
if err != nil { if err != nil {
log.Printf("terminal: read init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"}) conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return return
} }
log.Printf("terminal: init message received: %s", string(raw))
if err := json.Unmarshal(raw, &initMsg); err != nil { if err := json.Unmarshal(raw, &initMsg); err != nil {
log.Printf("terminal: unmarshal init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"}) conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return return
} }
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
var cmd *exec.Cmd var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" { if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct { var sshConf struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
User string `json:"user"` User string `json:"user"`
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
Password string `json:"password"`
} }
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil { if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"}) conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
@@ -98,61 +94,71 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
} }
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host)) sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
cmd = exec.Command("ssh", sshArgs...) if sshConf.Password != "" {
sshpassPath, err := exec.LookPath("sshpass")
if err == nil {
args := append([]string{"-e"}, "ssh")
args = append(args, sshArgs...)
cmd = exec.Command(sshpassPath, args...)
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else { } else {
shell := strings.TrimSpace(initMsg.Data) shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
if shell == "" { if shell == "" {
shell = detectShell() shell = detectShell()
log.Printf("terminal: auto-detected shell=%q", shell)
} }
if shell == "" { if shell == "" {
log.Printf("terminal: no shell detected, falling back to /bin/sh")
shell = "/bin/sh" shell = "/bin/sh"
} }
if path, err := exec.LookPath(shell); err == nil { // Support "wsl -d <distro>" shell strings sent from the UI quick-access.
shell = path if extra, ok := parseWSLShell(shell); ok {
log.Printf("terminal: resolved shell path=%q", shell) wslPath, err := exec.LookPath("wsl")
} if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "wsl not found on this host"})
return
}
cmd = exec.Command(wslPath, extra...)
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
if _, err := os.Stat(shell); err != nil { if _, err := os.Stat(shell); err != nil {
log.Printf("terminal: shell stat failed: %v for %q", err, shell) conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)}) return
return }
}
shellName := filepath.Base(shell) shellName := filepath.Base(shell)
switch shellName { switch shellName {
case "wsl": case "wsl":
cmd = exec.Command(shell, "--shell-type", "login") cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh": case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile") cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish": case "fish":
cmd = exec.Command(shell, "--login") cmd = exec.Command(shell, "--login")
default: default:
cmd = exec.Command(shell) cmd = exec.Command(shell)
}
} }
} }
cmd.Env = append(os.Environ(), "TERM=xterm-256color") if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
ptmx, err := pty.Start(cmd) ptmx, err := pty.Start(cmd)
if err != nil { if err != nil {
log.Printf("terminal: pty start failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()}) conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return return
} }
log.Printf("terminal: pty started successfully")
defer func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
var once sync.Once var once sync.Once
cleanup := func() { cleanup := func() {
@@ -164,6 +170,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
} }
}) })
} }
defer cleanup()
go func() { go func() {
buf := make([]byte, 4096) buf := make([]byte, 4096)
@@ -171,8 +178,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
n, err := ptmx.Read(buf) n, err := ptmx.Read(buf)
if err != nil { if err != nil {
cleanup() cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return return
} }
if err := conn.WriteJSON(wsMessage{ if err := conn.WriteJSON(wsMessage{
@@ -219,8 +224,15 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" { if r.Method == "GET" {
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
for i, c := range s.config.Terminal.SSH {
masked[i] = c
if masked[i].Password != "" {
masked[i].Password = "***"
}
}
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"ssh": s.config.Terminal.SSH, "ssh": masked,
"system": detectSystemTerminals(), "system": detectSystemTerminals(),
}) })
return return
@@ -234,8 +246,8 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
User string `json:"user"` User string `json:"user"`
Password string `json:"password"`
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
@@ -249,12 +261,36 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
body.Port = 22 body.Port = 22
} }
for i, c := range s.config.Terminal.SSH {
if c.Name == body.Name {
password := body.Password
if password == "***" {
password = c.Password
}
s.config.Terminal.SSH[i] = config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: password,
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
}
conn := config.SSHConnection{ conn := config.SSHConnection{
Name: body.Name, Name: body.Name,
Host: body.Host, Host: body.Host,
Port: body.Port, Port: body.Port,
User: body.User, User: body.User,
KeyPath: body.KeyPath, KeyPath: body.KeyPath,
Password: body.Password,
} }
if s.config.Terminal.SSH == nil { if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{} s.config.Terminal.SSH = []config.SSHConnection{}
@@ -306,6 +342,87 @@ func detectShell() string {
return "/bin/sh" return "/bin/sh"
} }
// listWSLDistros returns the list of installed WSL distribution names.
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
func listWSLDistros() []string {
if runtime.GOOS != "windows" {
return nil
}
out, err := exec.Command("wsl", "--list", "--quiet").Output()
if err != nil {
return nil
}
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
raw := stripUTF16ToASCII(out)
var distros []string
seen := make(map[string]bool)
for _, line := range strings.Split(raw, "\n") {
name := strings.TrimSpace(line)
if name == "" || seen[name] {
continue
}
// Skip default-marker arrows or annotations.
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
if name == "" || !validWSLName.MatchString(name) {
continue
}
seen[name] = true
distros = append(distros, name)
}
return distros
}
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
// to pass to the wsl binary. Returns ok=false otherwise.
func parseWSLShell(shell string) ([]string, bool) {
parts := strings.Fields(shell)
if len(parts) < 3 || parts[0] != "wsl" {
return nil, false
}
args := []string{}
i := 1
for i < len(parts) {
switch parts[i] {
case "-d", "--distribution":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-d", parts[i+1])
i += 2
case "-u", "--user":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-u", parts[i+1])
i += 2
default:
return nil, false
}
}
if len(args) == 0 {
return nil, false
}
return args, true
}
func stripUTF16ToASCII(b []byte) string {
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
var out []byte
for i := 0; i < len(b); i++ {
c := b[i]
if c == 0 {
continue
}
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
out = append(out, c)
}
}
return string(out)
}
func detectSystemTerminals() []map[string]string { func detectSystemTerminals() []map[string]string {
var terminals []map[string]string var terminals []map[string]string
@@ -318,10 +435,17 @@ func detectSystemTerminals() []map[string]string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil { if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{ terminals = append(terminals, map[string]string{
"type": "local", "type": "local",
"name": "WSL", "name": "WSL (default)",
"shell": "wsl", "shell": "wsl",
}) })
for _, distro := range listWSLDistros() {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL: " + distro,
"shell": "wsl -d " + distro,
})
}
} }
if _, err := exec.LookPath("powershell"); err == nil { if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{ terminals = append(terminals, map[string]string{

View File

@@ -2,7 +2,6 @@ package config
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
@@ -128,6 +127,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
}, },
} }
func migrateProviders(cfg *MuyueConfig) {
defaults := Default().AI.Providers
for _, dp := range defaults {
found := false
for _, p := range cfg.AI.Providers {
if p.Name == dp.Name {
found = true
break
}
}
if !found {
cfg.AI.Providers = append(cfg.AI.Providers, dp)
}
}
}
func GetTerminalTheme(name string) TerminalTheme { func GetTerminalTheme(name string) TerminalTheme {
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok { if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
return theme return theme
@@ -146,7 +161,7 @@ func ConfigDir() (string, error) {
if _, err := os.Stat(legacyDir); err == nil { if _, err := os.Stat(legacyDir); err == nil {
if _, err := os.Stat(dir); err != nil { if _, err := os.Stat(dir); err != nil {
if err := os.Rename(legacyDir, dir); err != nil { if err := os.Rename(legacyDir, dir); err != nil {
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err) _ = err
} }
} }
} }
@@ -206,6 +221,8 @@ func Load() (*MuyueConfig, error) {
} }
} }
migrateProviders(&cfg)
return &cfg, nil return &cfg, nil
} }
@@ -269,6 +286,12 @@ func Default() *MuyueConfig {
BaseURL: "https://api.minimax.io/v1", BaseURL: "https://api.minimax.io/v1",
Active: true, Active: true,
}, },
{
Name: "mimo",
Model: "mimo-v2.5-pro",
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
Active: false,
},
{ {
Name: "zai", Name: "zai",
Model: "glm", Model: "glm",
@@ -297,6 +320,7 @@ func Default() *MuyueConfig {
cfg.Terminal.CustomPrompt = true cfg.Terminal.CustomPrompt = true
cfg.Terminal.PromptTheme = "zerotwo" cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
return cfg return cfg
} }

View File

@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@@ -17,17 +16,53 @@ import (
) )
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`) var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
const maxHistorySize = 100 const maxHistorySize = 100
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content,omitempty"` Content json.RawMessage `json:"content,omitempty"`
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"` ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
} }
func TextContent(s string) json.RawMessage {
b, _ := json.Marshal(s)
return b
}
func PartsContent(parts []ContentPart) json.RawMessage {
b, _ := json.Marshal(parts)
return b
}
func (m Message) ContentString() string {
var s string
if json.Unmarshal(m.Content, &s) == nil {
return s
}
return string(m.Content)
}
type ToolCallMsg struct { type ToolCallMsg struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
@@ -107,6 +142,37 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
}, nil }, nil
} }
// NewForProvider builds an orchestrator using a specific (non-active) provider,
// for the Advanced Reflection feature where the inactive provider produces a
// preliminary report before the active provider answers. Excludes the currently
// active provider from selection — picks the first other configured provider
// with a non-empty API key.
func NewForInactiveProvider(cfg *config.MuyueConfig) (*Orchestrator, error) {
var activeName string
for _, p := range cfg.AI.Providers {
if p.Active {
activeName = p.Name
break
}
}
for i := range cfg.AI.Providers {
p := &cfg.AI.Providers[i]
if p.Name == activeName {
continue
}
if p.APIKey == "" {
continue
}
return &Orchestrator{
config: cfg,
provider: p,
client: sharedHTTPClient,
history: []Message{},
}, nil
}
return nil, fmt.Errorf("no inactive provider with API key configured")
}
func (o *Orchestrator) SetSystemPrompt(prompt string) { func (o *Orchestrator) SetSystemPrompt(prompt string) {
o.systemPrompt = prompt o.systemPrompt = prompt
} }
@@ -139,11 +205,38 @@ func (o *Orchestrator) GetHistory() []Message {
return out return out
} }
// SendNoTools issues a one-shot, history-less request to this orchestrator's
// provider. Used by the Advanced Reflection feature so the inactive provider
// can produce a preliminary report without contaminating the active
// orchestrator's history or invoking tools.
func (o *Orchestrator) SendNoTools(userMessage string) (string, error) {
messages := make([]Message, 0, 2)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
}
messages = append(messages, Message{Role: "user", Content: TextContent(userMessage)})
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: messages,
Stream: false,
}
chatResp, _, err := o.sendWithFallback(reqBody, "")
if err != nil {
return "", err
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("empty response from provider")
}
return CleanAIResponse(chatResp.Choices[0].Message.Content), nil
}
func (o *Orchestrator) Send(userMessage string) (string, error) { func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "user", Role: "user",
Content: userMessage, Content: TextContent(userMessage),
}) })
if len(o.history) > maxHistorySize { if len(o.history) > maxHistorySize {
@@ -152,7 +245,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
messages := make([]Message, 0, len(o.history)+1) messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
messages = append(messages, o.history...) messages = append(messages, o.history...)
@@ -169,11 +262,11 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
return "", err return "", err
} }
content := cleanAIResponse(chatResp.Choices[0].Message.Content) content := CleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "assistant", Role: "assistant",
Content: content, Content: TextContent(content),
}) })
_ = providerName _ = providerName
o.histMu.Unlock() o.histMu.Unlock()
@@ -185,7 +278,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "user", Role: "user",
Content: userMessage, Content: TextContent(userMessage),
}) })
if len(o.history) > maxHistorySize { if len(o.history) > maxHistorySize {
@@ -194,7 +287,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
messages := make([]Message, 0, len(o.history)+1) messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
messages = append(messages, o.history...) messages = append(messages, o.history...)
@@ -269,11 +362,11 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
return fullContent.String(), fmt.Errorf("read stream: %w", err) return fullContent.String(), fmt.Errorf("read stream: %w", err)
} }
content := cleanAIResponse(fullContent.String()) content := CleanAIResponse(fullContent.String())
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "assistant", Role: "assistant",
Content: content, Content: TextContent(content),
}) })
o.histMu.Unlock() o.histMu.Unlock()
@@ -283,7 +376,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) { func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1) fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
fullMessages = append(fullMessages, messages...) fullMessages = append(fullMessages, messages...)
@@ -314,7 +407,7 @@ type ChunkCallback func(content string, toolCalls []ToolCallMsg)
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) { func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1) fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
fullMessages = append(fullMessages, messages...) fullMessages = append(fullMessages, messages...)
@@ -360,6 +453,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
var fullContent strings.Builder var fullContent strings.Builder
var accumulatedToolCalls []ToolCallMsg var accumulatedToolCalls []ToolCallMsg
var totalTokens int var totalTokens int
var insideToolBlock bool
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
@@ -383,7 +477,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
chunk := chatResp.Choices[0].Delta.Content chunk := chatResp.Choices[0].Delta.Content
if chunk != "" { if chunk != "" {
fullContent.WriteString(chunk) fullContent.WriteString(chunk)
onChunk(chunk, nil) cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
if cleanedChunk != "" {
onChunk(cleanedChunk, nil)
}
} }
// Handle delta tool calls // Handle delta tool calls
@@ -435,15 +532,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
}{}, }{},
} }
finalContent := cleanAIResponse(fullContent.String()) finalContent := CleanAIResponse(fullContent.String())
finalResp.Choices[0].Message.Content = finalContent finalResp.Choices[0].Message.Content = finalContent
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
return finalResp, nil return finalResp, nil
} }
func cleanAIResponse(content string) string { func CleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "") content = thinkRegex.ReplaceAllString(content, "")
content = providerToolBlockRegex.ReplaceAllString(content, "")
content = providerTagRegex.ReplaceAllString(content, "")
content = xmlToolTagRegex.ReplaceAllString(content, "")
content = bracketToolCallRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
var clean []string var clean []string
inBlock := false inBlock := false
@@ -466,6 +567,35 @@ func cleanAIResponse(content string) string {
return result return result
} }
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
func CleanStreamChunk(chunk string, insideBlock *bool) string {
if *insideBlock {
// Check for closing tag
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Check for opening tool_call block
if streamBlockStartRegex.MatchString(chunk) {
*insideBlock = true
// If closing tag also in same chunk, emit nothing
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Clean individual tags and bracket calls
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
return cleaned
}
func getProviderBaseURL(name string) string { func getProviderBaseURL(name string) string {
switch name { switch name {
case "minimax": case "minimax":
@@ -476,6 +606,8 @@ func getProviderBaseURL(name string) string {
return "https://api.openai.com/v1" return "https://api.openai.com/v1"
case "zai": case "zai":
return "https://api.z.ai/v1" return "https://api.z.ai/v1"
case "mimo":
return "https://token-plan-ams.xiaomimimo.com/v1"
default: default:
return "" return ""
} }
@@ -503,11 +635,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
if o.provider != nil { if o.provider != nil {
providerOrder = append(providerOrder, o.provider) providerOrder = append(providerOrder, o.provider)
} }
var zaiProvider *config.AIProvider
for _, p := range providers { for _, p := range providers {
if o.provider == nil || p.Name != o.provider.Name { if o.provider == nil || p.Name != o.provider.Name {
providerOrder = append(providerOrder, p) if p.Name == "zai" {
zaiProvider = p
} else {
providerOrder = append(providerOrder, p)
}
} }
} }
if zaiProvider != nil {
providerOrder = append(providerOrder, zaiProvider)
}
var lastErr error var lastErr error
var triedProviders []string var triedProviders []string
@@ -578,6 +718,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
return &chatResp, prov.Name, nil return &chatResp, prov.Name, nil
} }
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
return nil, "", lastErr return nil, "", lastErr
} }

View File

@@ -65,11 +65,11 @@ func TestCleanAIResponse(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := cleanAIResponse(tt.input) result := CleanAIResponse(tt.input)
result = strings.TrimSpace(result) result = strings.TrimSpace(result)
expected := strings.TrimSpace(tt.expected) expected := strings.TrimSpace(tt.expected)
if result != expected { if result != expected {
t.Errorf("cleanAIResponse(%q) = %q, want %q", tt.input, result, expected) t.Errorf("CleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
} }
}) })
} }
@@ -77,34 +77,34 @@ func TestCleanAIResponse(t *testing.T) {
func TestCleanAIResponseThinkRegex(t *testing.T) { func TestCleanAIResponseThinkRegex(t *testing.T) {
input2 := "<Think>some reasoning</Think>actual response" input2 := "<Think>some reasoning</Think>actual response"
result2 := cleanAIResponse(input2) result2 := CleanAIResponse(input2)
if result2 != "actual response" { if result2 != "actual response" {
t.Errorf("Valid Think tags should be removed: %q", result2) t.Errorf("Valid Think tags should be removed: %q", result2)
} }
input3 := "<think\nmultiline\nreasoning</think visible" input3 := "<think\nmultiline\nreasoning</think visible"
result3 := cleanAIResponse(input3) result3 := CleanAIResponse(input3)
// No closing > on opening tag, so won't match regex // No closing > on opening tag, so won't match regex
if result3 != "<think\nmultiline\nreasoning</think visible" { if result3 != "<think\nmultiline\nreasoning</think visible" {
t.Errorf("Malformed think should not be removed: %q", result3) t.Errorf("Malformed think should not be removed: %q", result3)
} }
input4 := "<think type=re>reasoning</think visible" input4 := "<think type=re>reasoning</think visible"
result4 := cleanAIResponse(input4) result4 := CleanAIResponse(input4)
// </think followed by space, not >, so won't match // </think followed by space, not >, so won't match
if result4 != "<think type=re>reasoning</think visible" { if result4 != "<think type=re>reasoning</think visible" {
t.Errorf("Malformed closing should not be removed: %q", result4) t.Errorf("Malformed closing should not be removed: %q", result4)
} }
input_real := "prefix<think reasoning here</think suffix" input_real := "prefix<think reasoning here</think suffix"
result_real := cleanAIResponse(input_real) result_real := CleanAIResponse(input_real)
// The closing </think has no > after it, so won't match // The closing </think has no > after it, so won't match
if result_real != "prefix<think reasoning here</think suffix" { if result_real != "prefix<think reasoning here</think suffix" {
t.Errorf("Malformed tags should pass through: %q", result_real) t.Errorf("Malformed tags should pass through: %q", result_real)
} }
input_valid := "<Think>reasoning</Think>result" input_valid := "<Think>reasoning</Think>result"
result_valid := cleanAIResponse(input_valid) result_valid := CleanAIResponse(input_valid)
if result_valid != "result" { if result_valid != "result" {
t.Errorf("Valid tags should be removed: %q", result_valid) t.Errorf("Valid tags should be removed: %q", result_valid)
} }

View File

@@ -17,3 +17,62 @@ func fileContains(path, substr string) bool {
func execLookPath(name string) (string, error) { func execLookPath(name string) (string, error) {
return exec.LookPath(name) return exec.LookPath(name)
} }
func readOSReleaseName() string {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
var pretty, name, version string
for _, line := range strings.Split(string(data), "\n") {
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
val = strings.Trim(val, `"'`)
switch key {
case "PRETTY_NAME":
pretty = val
case "NAME":
name = val
case "VERSION_ID":
version = val
}
}
if pretty != "" {
return pretty
}
if name != "" && version != "" {
return name + " " + version
}
return name
}
func readMacOSVersion() string {
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func readWindowsVersion() string {
if v := os.Getenv("OS"); v != "" && strings.Contains(strings.ToLower(v), "windows") {
// Try to detect Windows 11 vs 10 via build number
if build := os.Getenv("MUYUE_WIN_BUILD"); build != "" {
return "Windows " + build
}
}
out, err := exec.Command("cmd", "/c", "ver").Output()
if err != nil {
return ""
}
s := strings.TrimSpace(string(out))
if strings.Contains(s, "10.0.22") || strings.Contains(s, "10.0.23") {
return "Windows 11"
}
if strings.Contains(s, "10.0.") {
return "Windows 10"
}
return s
}

View File

@@ -25,6 +25,7 @@ const (
type SystemInfo struct { type SystemInfo struct {
OS OS `json:"os"` OS OS `json:"os"`
OSName string `json:"os_name"`
Arch Arch `json:"arch"` Arch Arch `json:"arch"`
IsWSL bool `json:"is_wsl"` IsWSL bool `json:"is_wsl"`
Shell string `json:"shell"` Shell string `json:"shell"`
@@ -39,6 +40,7 @@ func Detect() SystemInfo {
} }
info.IsWSL = detectWSL() info.IsWSL = detectWSL()
info.OSName = detectOSName(info.OS, info.IsWSL)
info.Shell = detectShell() info.Shell = detectShell()
info.Terminal = detectTerminal() info.Terminal = detectTerminal()
info.PackageManager = detectPackageManager(info.OS) info.PackageManager = detectPackageManager(info.OS)
@@ -46,6 +48,33 @@ func Detect() SystemInfo {
return info return info
} }
func detectOSName(os OS, isWSL bool) string {
switch os {
case Linux:
if name := readOSReleaseName(); name != "" {
if isWSL {
return name + " (WSL)"
}
return name
}
if isWSL {
return "Linux (WSL)"
}
return "Linux"
case MacOS:
if v := readMacOSVersion(); v != "" {
return "macOS " + v
}
return "macOS"
case Windows:
if v := readWindowsVersion(); v != "" {
return v
}
return "Windows"
}
return string(os)
}
func detectWSL() bool { func detectWSL() bool {
return fileContains("/proc/version", "microsoft") || return fileContains("/proc/version", "microsoft") ||
fileContains("/proc/version", "WSL") fileContains("/proc/version", "WSL")
@@ -95,8 +124,11 @@ func detectPackageManager(os OS) string {
func (s SystemInfo) String() string { func (s SystemInfo) String() string {
parts := []string{ parts := []string{
"OS: " + string(s.OS), "OS: " + string(s.OS),
"Arch: " + string(s.Arch),
} }
if s.OSName != "" {
parts = append(parts, "Name: "+s.OSName)
}
parts = append(parts, "Arch: "+string(s.Arch))
if s.IsWSL { if s.IsWSL {
parts = append(parts, "WSL: yes") parts = append(parts, "WSL: yes")
} }

View File

@@ -33,6 +33,7 @@ type Skill struct {
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"` Category string `yaml:"category,omitempty" json:"category,omitempty"`
Deployed bool `yaml:"-" json:"deployed,omitempty"`
} }
type ValidationError struct { type ValidationError struct {
@@ -155,6 +156,27 @@ func Delete(name string) error {
return nil return nil
} }
func IsDeployed(name string) bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
_, crushErr := os.Stat(crushPath)
_, claudeErr := os.Stat(claudePath)
return crushErr == nil || claudeErr == nil
}
func Undeploy(name string) error {
skill, err := Get(name)
if err != nil {
return err
}
undeployFromTargets(skill.Name)
return nil
}
func Update(skill *Skill) error { func Update(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 { if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs) return fmt.Errorf("validation failed: %v", errs)

View File

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

View File

@@ -225,17 +225,21 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
stepStatuses[step.ID] = StatusPending stepStatuses[step.ID] = StatusPending
} }
resolveDeps := func(stepID string) bool { resolveDeps := func(stepID string) (ready bool, blocked bool) {
step := wf.findStep(stepID) step := wf.findStep(stepID)
if step == nil { if step == nil {
return false return false, true
} }
for _, dep := range step.DependsOn { for _, dep := range step.DependsOn {
if stepStatuses[dep] != StatusDone { depStatus := stepStatuses[dep]
return false if depStatus == StatusFailed || depStatus == StatusSkipped {
return false, true
}
if depStatus != StatusDone {
return false, false
} }
} }
return true return true, false
} }
executeStep := func(step *Step) error { executeStep := func(step *Step) error {
@@ -296,6 +300,7 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
s.Error = stepErr.Error() s.Error = stepErr.Error()
s.EndedAt = &endTime s.EndedAt = &endTime
}) })
stepStatuses[step.ID] = StatusFailed
if onStep != nil { if onStep != nil {
onStep(step, "failed") onStep(step, "failed")
} }
@@ -321,8 +326,27 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
continue continue
} }
for !resolveDeps(step.ID) { ready, blocked := resolveDeps(step.ID)
time.Sleep(100 * time.Millisecond) if blocked {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusSkipped
})
stepStatuses[step.ID] = StatusSkipped
if onStep != nil {
onStep(&step, "skipped")
}
continue
}
if !ready {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusSkipped
s.Error = "dependency not satisfied at execution time"
})
stepStatuses[step.ID] = StatusSkipped
if onStep != nil {
onStep(&step, "skipped")
}
continue
} }
if err := executeStep(&step); err != nil { if err := executeStep(&step); err != nil {

View File

@@ -27,7 +27,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error)
prompt := buildPlanPrompt(goal) prompt := buildPlanPrompt(goal)
messages := []orchestrator.Message{ messages := []orchestrator.Message{
{Role: "user", Content: prompt}, {Role: "user", Content: orchestrator.TextContent(prompt)},
} }
resp, err := p.orchestrator.SendWithTools(messages) resp, err := p.orchestrator.SendWithTools(messages)
@@ -159,14 +159,18 @@ func parsePlanResponse(content string) ([]Step, error) {
return steps, nil return steps, nil
} }
const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu génères des plans d'exécution sous forme de JSON. Chaque plan est une séquence d'étapes (steps) représentant des appels d'outils. const plannerSystemPrompt = `Tu es un planificateur de workflows pour Muyue. Tu génères des plans d'exécution sous forme de tableaux JSON.
Pour générer un plan: RÈGLES :
1. Comprends l'objectif de l'utilisateur 1. Analyse l'objectif → identifie les outils → décompose en étapes
2. Identifie les outils nécessaires 2. Chaque étape : {"name": string, "tool": string, "args": object}
3. Décompose en étapes logiques 3. Max 10 étapes par plan
4. Spécifie les paramètres de chaque outil 4. Ordonne par dépendances (les lectures avant les écritures)
5. Préfère les commandes non-interactives
6. Utilise crush_run pour les tâches complexes multi-fichiers
Réponds toujours en JSON valide, sans texte additionnel.` Outils : terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch
var _ = plannerSystemPrompt Réponds UNIQUEMENT en JSON valide, sans texte avant/après.`
const _ = plannerSystemPrompt

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

1301
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,14 @@
}, },
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.10.0-beta.203",
"@xterm/addon-search": "^0.17.0-beta.203",
"@xterm/addon-unicode11": "^0.10.0-beta.203",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.1.0-beta.203",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"mermaid": "^11.14.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },

View File

@@ -36,11 +36,17 @@ const api = {
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }), testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }), exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }), importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }),
undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }),
getDashboardStatus: () => request('/dashboard/status'), getDashboardStatus: () => request('/dashboard/status'),
getProvidersQuota: () => request('/providers/quota'), getProvidersQuota: () => request('/providers/quota'),
getProvidersConsumption: () => request('/providers/consumption'),
getRecentCommands: () => request('/recent-commands'), getRecentCommands: () => request('/recent-commands'),
getRunningProcesses: () => request('/running-processes'), getRunningProcesses: () => request('/running-processes'),
getSystemMetrics: () => request('/system/metrics'), getSystemMetrics: () => request('/system/metrics'),
getTestSnippet: () => request('/test/snippet'),
getTestSessions: () => request('/test/sessions'),
getTestConsole: (sessionId) => request(`/test/console/${encodeURIComponent(sessionId || '')}`),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
@@ -48,6 +54,7 @@ const api = {
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }), applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }), validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }), runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
getTerminalSessions: () => request('/terminal/sessions'), getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }), addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
@@ -57,15 +64,19 @@ const api = {
getChatHistory: () => request('/chat/history'), getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }), clearChat: () => request('/chat/clear', { method: 'POST' }),
summarizeChat: () => request('/chat/summarize', { method: 'POST' }), summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
sendChat: (message, stream = true, onChunk, signal) => { getShellChatHistory: () => request('/shell/chat/history'),
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
getShellAnalysis: () => request('/shell/analysis'),
sendChat: (message, stream = true, onChunk, signal, images = [], advancedReflection = false) => {
if (!stream) { if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false, images, advanced_reflection: advancedReflection }) })
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fetch(`${API_BASE}/chat`, { fetch(`${API_BASE}/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }), body: JSON.stringify({ message, stream: true, images, advanced_reflection: advancedReflection }),
signal, signal,
}).then(async (res) => { }).then(async (res) => {
if (!res.ok) { if (!res.ok) {
@@ -101,11 +112,9 @@ const api = {
}).catch(reject) }).catch(reject)
}) })
}, },
sendShellChat: (message, context = {}, stream = true, onChunk) => { sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
const payload = { const payload = {
message, message,
context: context.context || '',
history: context.history || [],
cwd: context.cwd || '', cwd: context.cwd || '',
platform: context.platform || '', platform: context.platform || '',
stream, stream,
@@ -118,6 +127,7 @@ const api = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal,
}).then(async (res) => { }).then(async (res) => {
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })) const err = await res.json().catch(() => ({ error: res.statusText }))
@@ -127,7 +137,6 @@ const api = {
const reader = res.body.getReader() const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let full = '' let full = ''
let toolCalls = []
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
@@ -137,27 +146,19 @@ const api = {
try { try {
const data = JSON.parse(line.slice(6)) const data = JSON.parse(line.slice(6))
if (data.error) { reject(new Error(data.error)); return } if (data.error) { reject(new Error(data.error)); return }
if (data.done) { if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
resolve({ content: full, tool_calls: toolCalls })
return
}
if (data.content) { if (data.content) {
full += data.content full += data.content
if (onChunk) onChunk(full, data) if (onChunk) onChunk(full, data)
} else if (data.tool_call) { } else if (data.tool_call || data.tool_result) {
toolCalls.push(data.tool_call) if (onChunk) onChunk(full, data)
if (onChunk) onChunk(full, data, toolCalls) } else if (data.thinking !== undefined || data.thinking_end) {
} else if (data.tool_result) { if (onChunk) onChunk(full, data)
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 {} } catch {}
} }
} }
resolve({ content: full, tool_calls: toolCalls }) resolve({ content: full })
}).catch(reject) }).catch(reject)
}) })
}, },

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react' import { LayoutDashboard, Sparkles, Terminal, Settings, TestTube2 } from 'lucide-react'
import api from '../api/client' import api from '../api/client'
import { getTheme, applyTheme } from '../themes' import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
@@ -7,6 +7,7 @@ import Dashboard from './Dashboard'
import Studio from './Studio' import Studio from './Studio'
import Shell from './Shell' import Shell from './Shell'
import Config from './Config' import Config from './Config'
import Tests from './Tests'
import OnboardingWizard from './OnboardingWizard' import OnboardingWizard from './OnboardingWizard'
export default function App() { export default function App() {
@@ -16,8 +17,6 @@ export default function App() {
const [isSudo, setIsSudo] = useState(false) const [isSudo, setIsSudo] = useState(false)
const [dashRefreshKey, setDashRefreshKey] = useState(0) const [dashRefreshKey, setDashRefreshKey] = useState(0)
const dashRefreshRef = useRef(null) const dashRefreshRef = useRef(null)
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null) const [config, setConfig] = useState(null)
const [showOnboarding, setShowOnboarding] = useState(false) const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n() const { t, layout } = useI18n()
@@ -26,13 +25,12 @@ export default function App() {
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> }, { id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> }, { id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> }, { id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'tests', label: 'Tests', icon: <TestTube2 size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> }, { id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t]) ], [t])
useEffect(() => { useEffect(() => {
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {}) api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getConfig().then(d => { api.getConfig().then(d => {
setConfig(d) setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red' const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
@@ -58,7 +56,8 @@ export default function App() {
Digit1: 'dash', Digit1: 'dash',
Digit2: 'studio', Digit2: 'studio',
Digit3: 'shell', Digit3: 'shell',
Digit4: 'config', Digit4: 'tests',
Digit5: 'config',
} }
if (map[e.code]) { if (map[e.code]) {
e.preventDefault() e.preventDefault()
@@ -76,8 +75,11 @@ export default function App() {
const switchTab = useCallback((tabId) => setActiveTab(tabId), []) const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
const hasUpdates = updates.some(u => u.needsUpdate) useEffect(() => {
const installed = tools.filter(tool => tool.installed).length const handler = () => setActiveTab('shell')
window.addEventListener('navigate-to-shell', handler)
return () => window.removeEventListener('navigate-to-shell', handler)
}, [])
const WINDOW_SHORTCUTS = useMemo(() => ({ const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [], dash: [],
@@ -86,22 +88,17 @@ export default function App() {
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') }, { keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
], ],
shell: [ shell: [
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+F`, desc: t('statusbar.search') },
{ keys: `${layout.keys.ctrl}++/${layout.keys.ctrl}+`, desc: t('statusbar.zoom') },
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') }, { keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
], ],
tests: [],
config: [], config: [],
}), [layout, t]) }), [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 ( return (
<div className="app-layout"> <div className="app-layout">
<header className="header"> <header className="header">
@@ -127,29 +124,22 @@ export default function App() {
<div className="header-spacer" /> <div className="header-spacer" />
<div className="header-indicators">
<span
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
title={t('header.toolsInstalled', { count: installed })}
/>
<span
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
/>
</div>
<span className="header-clock"> <span className="header-clock">
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })} {clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</header> </header>
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}> <main className="content">
{renderContent()} <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} isSudo={isSudo} /></div>
<div className={activeTab === 'tests' ? '' : 'tab-hidden'}><Tests api={api} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main> </main>
<footer className="statusbar"> <footer className="statusbar">
<div className="statusbar-left"> <div className="statusbar-left">
{isSudo && <span className="statusbar-sudo"> ROOT</span>} {isSudo && <span className="statusbar-sudo"> SUDO</span>}
{activeTab === 'dash' && ( {activeTab === 'dash' && (
<span className="statusbar-shortcut"> <span className="statusbar-shortcut">
<kbd>{layout.keys.ctrl}+R</kbd> refresh <kbd>{layout.keys.ctrl}+R</kbd> refresh

View File

@@ -1,13 +1,10 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react' import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n' import { useI18n } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const PANELS = [ const PANELS = [
{ id: 'profile', icon: User }, { id: 'profile', icon: User },
{ id: 'providers', icon: Brain }, { id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench }, { id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor }, { id: 'system', icon: Monitor },
] ]
@@ -18,10 +15,7 @@ export default function Config({ api }) {
const [config, setConfig] = useState(null) const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([]) const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([]) const [skillList, setSkillList] = useState([])
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(null)
const [editProfile, setEditProfile] = useState(false) const [editProfile, setEditProfile] = useState(false)
const [editProvider, setEditProvider] = useState(null) const [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({}) const [profileForm, setProfileForm] = useState({})
@@ -29,24 +23,13 @@ export default function Config({ api }) {
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const layouts = getLayoutList()
const loadData = useCallback(() => { const loadData = useCallback(() => {
api.getConfig().then(d => { api.getConfig().then(d => {
setConfig(d) setConfig(d)
setProfileForm({ setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
}).catch(() => {}) }).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {}) api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {}) api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
}, [api]) }, [api])
@@ -57,44 +40,6 @@ export default function Config({ api }) {
setTimeout(() => setToast(null), 2500) setTimeout(() => setToast(null), 2500)
} }
const handleCheckUpdates = async () => {
setChecking(true)
try {
await api.runScan()
const d = await api.getUpdates()
setUpdates(d.updates || [])
const td = await api.getTools()
setTools(td.tools || [])
showToast(t('config.upToDate'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setChecking(false)
}
const handleUpdateTool = async (tool) => {
setUpdating(tool)
try {
await api.runUpdate(tool)
await handleCheckUpdates()
showToast(`${tool}`)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
setUpdating('__all__')
try {
await api.runUpdate('')
await handleCheckUpdates()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
try { try {
@@ -125,17 +70,15 @@ export default function Config({ api }) {
...prev, ...prev,
[p.name]: { [p.name]: {
name: p.name, name: p.name,
api_key: p.apiKey || '', api_key: p.api_key || '',
model: p.model || '', model: p.model || '',
base_url: p.baseURL || '', base_url: p.base_url || '',
}, },
})) }))
setEditProvider(p.name) setEditProvider(p.name)
} }
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
const installedCount = tools.filter(tool => tool.installed).length
const missingCount = tools.filter(tool => !tool.installed).length
return ( return (
<div className="config-window"> <div className="config-window">
@@ -176,27 +119,8 @@ export default function Config({ api }) {
t={t} t={t}
/> />
)} )}
{activePanel === 'updates' && (
<PanelUpdates
updates={updates} tools={tools}
checking={checking} updating={updating}
needsUpdateCount={needsUpdateCount}
installedCount={installedCount} missingCount={missingCount}
handleCheckUpdates={handleCheckUpdates}
handleUpdateTool={handleUpdateTool}
handleUpdateAll={handleUpdateAll}
t={t}
/>
)}
{activePanel === 'locale' && (
<PanelLocale
language={keyboard} layouts={layouts}
setLanguage={setLanguage} setKeyboard={setKeyboard}
t={t}
/>
)}
{activePanel === 'skills' && ( {activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} /> <PanelSkills skillList={skillList} api={api} loadData={loadData} t={t} />
)} )}
{activePanel === 'system' && ( {activePanel === 'system' && (
<PanelSystem api={api} t={t} /> <PanelSystem api={api} t={t} />
@@ -209,93 +133,188 @@ export default function Config({ api }) {
} }
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) { 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 ( return (
<div className="config-card"> <div className="config-profile-center">
{config?.profile && !editProfile ? ( <div className="config-card">
<> <div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
<div className="config-card-row"> <RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
<span className="config-card-label">{t('config.name')}</span> </div>
<span className="config-card-value">{config.profile.name || '—'}</span> <div className="config-card">
</div> <div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
<div className="config-card-row"> {preferences ? (
<span className="config-card-label">{t('config.pseudo')}</span> <RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
<span className="config-card-value">{config.profile.pseudo || '—'}</span> ) : (
</div> <div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}></span></div>
<div className="config-card-row"> )}
<span className="config-card-label">{t('config.email')}</span> </div>
<span className="config-card-value">{config.profile.email || '—'}</span> <div className="config-card">
</div> <div className="config-card-actions" style={{ justifyContent: 'center' }}>
<div className="config-card-row"> {editProfile ? (
<span className="config-card-label">{t('config.editor')}</span> <>
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span> <button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
</div> <button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
<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> <button className="primary sm" onClick={() => {
</div> setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
<div className="config-card-row"> setEditProfile(true)
<span className="config-card-label">{t('config.languages')}</span> }}>{t('config.editProfile')}</button>
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span> )}
</div> </div>
<div className="config-card-actions"> </div>
<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> </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 }) { function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null) const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null) const [keyStatus, setKeyStatus] = useState({})
const handleValidate = async (name, apiKey, model, baseUrl) => { const validateKey = async (p) => {
setValidating(name) setValidating(p.name)
setValidationStatus(null)
try { try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl }) await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
setValidationStatus({ provider: name, valid: true }) setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
} catch (err) { } catch (err) {
const msg = err.message || '' setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
if (msg.includes('invalid_api_key')) {
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
} else {
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
}
} }
setValidating(null) setValidating(null)
} }
useEffect(() => {
providers.forEach(p => {
if (p.api_key && !keyStatus[p.name]) {
validateKey(p)
} else if (!p.api_key) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
}
})
}, [providers])
const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name)
try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
} catch (err) {
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
}
setValidating(null)
}
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo')
return ( return (
<div className="config-providers-list"> <div className="config-providers-list">
<div className="provider-setup-hint">{t('config.setupDescription')}</div> {displayed.map((p, i) => {
{providers.map((p, i) => {
const isEditing = editProvider === p.name const isEditing = editProvider === p.name
const isValidationTarget = validationStatus?.provider === p.name const currentModel = providerForm[p.name]?.model || p.model
const status = keyStatus[p.name]
return ( return (
<div key={i} className="config-card provider-card-v2"> <div key={i} className="config-card provider-card-v2">
<div className="provider-card-top"> <div className="provider-card-top">
<div className="provider-card-identity"> <div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span> <span className="provider-card-name">{p.name.toUpperCase()}</span>
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>} {p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>} {status?.checked && status?.valid && <span className="badge ok"> {t('config.keyValid')}</span>}
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>} {status?.checked && !status?.valid && <span className="badge error"> {status.error || t('config.keyInvalid')}</span>}
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
</div> </div>
</div> </div>
@@ -306,7 +325,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<input <input
className="config-form-input" className="config-form-input"
type="password" type="password"
placeholder={t('config.tokenPlaceholder')} placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''} value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => { onChange={e => {
if (!isEditing) openProviderEdit(p) if (!isEditing) openProviderEdit(p)
@@ -321,17 +340,18 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<button <button
className="sm primary" className="sm primary"
disabled={validating === p.name || !providerForm[p.name]?.api_key} disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)} onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
> >
{validating === p.name ? t('config.validating') : t('config.validateKey')} {validating === p.name ? t('config.validating') : t('config.validateKey')}
</button> </button>
{isValidationTarget && validationStatus?.valid && ( {isEditing && (
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button> <button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)} )}
</div> </div>
</div> </div>
<div className="provider-card-meta" style={{ marginTop: 8 }}> <div className="provider-card-model">
<span className="mono">{p.model || '—'}</span> <span className="provider-card-model-label">{t('config.model')}</span>
<span className="provider-card-model-value">{p.model || '—'}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -341,130 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
) )
} }
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) { function PanelSkills({ skillList, api, loadData, t }) {
return ( const [deploying, setDeploying] = useState(null)
<>
<div className="config-card">
<div className="config-update-controls">
<div className="config-update-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
<div className="config-update-buttons">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
</button>
{needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
</button>
)}
</div>
</div>
</div>
{updates.length === 0 ? ( const handleDeploy = async (name) => {
<div className="config-card"> setDeploying(name + '-deploy')
<div className="empty-state">{t('config.noUpdates')}</div> try {
</div> await api.deploySkill(name)
) : ( loadData()
<div className="config-update-list"> } catch (err) {
{updates.map((u, i) => ( console.error('deploy skill:', err)
<div key={i} className="config-update-row"> }
<div className="config-update-info"> setDeploying(null)
<span className="config-update-name">{u.tool}</span> }
<span className="config-update-versions">
{u.needsUpdate ? ( const handleUndeploy = async (name) => {
<>{u.current} <span style={{ color: 'var(--warning)' }}>{u.latest}</span></> setDeploying(name + '-undeploy')
) : ( try {
<span style={{ color: 'var(--success)' }}>{u.current}</span> await api.undeploySkill(name)
)} loadData()
</span> } catch (err) {
</div> console.error('undeploy skill:', err)
{u.needsUpdate && ( }
<button setDeploying(null)
className="sm" }
onClick={() => handleUpdateTool(u.tool)}
disabled={updating === u.tool} if (skillList.length === 0) {
> return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
{updating === u.tool ? t('config.updating') : t('config.updateTool')} }
</button>
return (
<div className="skills-list">
{skillList.map((s, i) => (
<div key={i} className="config-update-row" style={{ alignItems: 'center' }}>
<div className="skill-list-info">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="config-update-name">{s.name}</span>
{s.deployed ? (
<span className="badge ok">{t('config.installed')}</span>
) : (
<span className="badge neutral">{t('config.notInstalled')}</span>
)} )}
</div> </div>
))} <div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>{s.description}</div>
</div>
)}
</>
)
}
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
return (
<div className="config-card">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.language')}</span>
<div className="chip-row">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${language === lang.id ? 'active' : ''}`}
onClick={() => setLanguage(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 ${keyboard === l.id ? 'active' : ''}`}
onClick={() => setKeyboard(l.id)}
>
{l.name}
</div>
))}
</div>
</div>
</div>
)
}
function PanelSkills({ skillList, t }) {
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> </div>
)) <div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
)} <button
className="sm primary"
disabled={s.deployed || deploying === s.name + '-deploy'}
onClick={() => handleDeploy(s.name)}
>
{deploying === s.name + '-deploy' ? '...' : t('config.apply')}
</button>
<button
className="sm ghost"
disabled={!s.deployed || deploying === s.name + '-undeploy'}
onClick={() => handleUndeploy(s.name)}
>
{deploying === s.name + '-undeploy' ? '...' : t('config.remove')}
</button>
</div>
</div>
))}
</div> </div>
) )
} }
function PanelSystem({ api, t }) { function PanelSystem({ api, t }) {
const [resetConfirm, setResetConfirm] = useState(false) const [showResetModal, setShowResetModal] = useState(false)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const [isSudo, setIsSudo] = useState(false)
useEffect(() => {
api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {})
}, [api])
const showToast = (msg) => { const showToast = (msg) => {
setToast(msg) setToast(msg)
@@ -474,7 +444,7 @@ function PanelSystem({ api, t }) {
const handleReset = async () => { const handleReset = async () => {
try { try {
await api.resetConfig() await api.resetConfig()
setResetConfirm(false) setShowResetModal(false)
showToast(t('config.resetDone')) showToast(t('config.resetDone'))
setTimeout(() => window.location.reload(), 1500) setTimeout(() => window.location.reload(), 1500)
} catch (err) { } catch (err) {
@@ -482,49 +452,163 @@ function PanelSystem({ api, t }) {
} }
} }
const handleApplyStarship = async () => { const handleSystemUpdate = () => {
try { window.dispatchEvent(new CustomEvent('navigate-to-shell'))
await api.applyStarshipTheme('charm') if (isSudo) {
showToast(t('config.starshipApplied')) window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } }))
} catch (err) { } else {
showToast(`${t('config.error')}: ${err.message}`) window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } }))
} }
} }
const configureTool = (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } }))
}
const AI_TOOLS = [
{
id: 'crush',
name: 'Crush',
icon: 'Zap',
description: t('config.toolCrushDesc'),
prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`,
},
{
id: 'claude',
name: 'Claude Code',
icon: 'Bot',
description: t('config.toolClaudeDesc'),
prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`,
},
{
id: 'gh',
name: 'GitHub CLI',
icon: 'GitBranch',
description: t('config.toolGhDesc'),
prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`,
},
{
id: 'docker',
name: 'Docker',
icon: 'Container',
description: t('config.toolDockerDesc'),
prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`,
},
{
id: 'go',
name: 'Go',
icon: 'Circle',
description: t('config.toolGoDesc'),
prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`,
},
{
id: 'node',
name: 'Node.js',
icon: 'Hexagon',
description: t('config.toolNodeDesc'),
prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`,
},
{
id: 'python',
name: 'Python',
icon: 'Code',
description: t('config.toolPythonDesc'),
prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`,
},
{
id: 'starship',
name: 'Starship',
icon: 'Rocket',
description: t('config.toolStarshipDesc'),
prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`,
},
]
const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket }
return ( return (
<> <>
{toast && <div className="config-toast">{toast}</div>} {toast && <div className="config-toast">{toast}</div>}
<div className="config-card">
<div className="config-card-row" style={{ marginBottom: 16 }}> <div className="section-title" style={{ marginBottom: 8 }}>{t('config.systemConfig')}</div>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
</div> <div className="section-title" style={{ marginTop: 4, marginBottom: 8, fontSize: 12, color: 'var(--text-tertiary)', textTransform: 'none', letterSpacing: 0 }}>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}> <Bot size={13} style={{ verticalAlign: 'middle', marginRight: 6 }} />
{t('config.starshipApplied')} {t('config.aiToolsConfig')}
</div>
<button className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')}
</button>
</div> </div>
<div className="config-card" style={{ marginTop: 12 }}> <div className="config-ai-tools-grid">
<div className="config-card-row" style={{ marginBottom: 16 }}> {AI_TOOLS.map(tool => {
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span> const Icon = ICON_MAP[tool.icon] || Bot
</div> return (
{resetConfirm ? ( <div key={tool.id} className="config-ai-tool-card">
<div> <div className="config-ai-tool-header">
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}> <span className="config-ai-tool-icon"><Icon size={16} /></span>
{t('config.resetConfirm')} <span className="config-ai-tool-name">{tool.name}</span>
</div>
<div className="config-ai-tool-desc">{tool.description}</div>
<button className="sm primary" onClick={() => configureTool(tool)} style={{ marginTop: 'auto' }}>
<Sparkles size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
{t('config.configureViaAI')}
</button>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> )
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button> })}
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button> </div>
<div className="config-card" style={{ marginTop: 12, marginBottom: 4 }}>
<div className="config-card-row" style={{ alignItems: 'center' }}>
<div>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.systemUpdate')}</span>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
{isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')}
</div> </div>
</div> </div>
) : ( <button className="sm primary" onClick={handleSystemUpdate}>
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}> <Download size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
{t('config.resetConfig')} {t('config.updateBtn')}
</button> </button>
)} </div>
</div> </div>
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
Zone Rouge
</div>
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
Cette action supprimera toute votre configuration et relancera l'application.
</div>
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
{t('config.resetConfig')}
</button>
</div>
{showResetModal && (
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
{t('config.resetConfig')}
</div>
<div className="shell-modal-body">
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
{t('config.resetConfirm')}
</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>
Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée.
</p>
</div>
<div className="shell-modal-footer">
<button className="ghost" onClick={() => setShowResetModal(false)}>{t('config.cancel')}</button>
<button className="danger" onClick={handleReset}>{t('config.resetConfig')}</button>
</div>
</div>
</div>
)}
</> </>
) )
} }

View File

@@ -3,6 +3,15 @@ import { useI18n } from '../i18n'
const MAX_POINTS = 30 const MAX_POINTS = 30
const POLL_INTERVAL = 5000
const MAX_IDLE_POLLS = 3
function formatTokens(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
function MiniGraph({ data, max, color, label, unit }) { function MiniGraph({ data, max, color, label, unit }) {
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div> if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
const m = max || Math.max(...data, 1) const m = max || Math.max(...data, 1)
@@ -34,12 +43,32 @@ function MiniGraph({ data, max, color, label, unit }) {
) )
} }
function BarChart({ data, max, color }) {
if (!data || data.length === 0) return null
const barW = 100 / 7
const m = max || Math.max(...data.map(d => d.tokens), 1)
return (
<svg viewBox="0 0 100 40" className="dash-graph-svg" preserveAspectRatio="none">
{data.map((d, i) => {
const h = Math.max(1, (d.tokens / m) * 36)
const x = i * barW + barW * 0.15
const w = barW * 0.7
return (
<rect key={i} x={x} y={40 - h} width={w} height={h} rx="1.5" fill={color} opacity={0.85} />
)
})}
</svg>
)
}
export default function Dashboard({ api, refreshRef }) { export default function Dashboard({ api, refreshRef }) {
const { t } = useI18n() const { t } = useI18n()
const [quota, setQuota] = useState(null) const [quota, setQuota] = useState(null)
const [consumption, setConsumption] = useState(null)
const [recentCmds, setRecentCmds] = useState([]) const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([]) const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null) const [metrics, setMetrics] = useState(null)
const [copiedSet, setCopiedSet] = useState(new Set())
const cpuRef = useRef([]) const cpuRef = useRef([])
const memRef = useRef([]) const memRef = useRef([])
const netRxRef = useRef([]) const netRxRef = useRef([])
@@ -47,13 +76,15 @@ export default function Dashboard({ api, refreshRef }) {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const [quotaData, cmdData, procData, metricsData] = await Promise.all([ const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
api.getProvidersQuota().catch(() => null), api.getProvidersQuota().catch(() => null),
api.getProvidersConsumption().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })), api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })), api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getSystemMetrics().catch(() => null), api.getSystemMetrics().catch(() => null),
]) ])
setQuota(quotaData?.providers || []) setQuota(quotaData?.providers || [])
setConsumption(consumData?.providers || {})
setRecentCmds(cmdData.commands || []) setRecentCmds(cmdData.commands || [])
setProcesses(procData.processes || []) setProcesses(procData.processes || [])
if (metricsData) { if (metricsData) {
@@ -71,12 +102,70 @@ export default function Dashboard({ api, refreshRef }) {
useEffect(() => { useEffect(() => {
loadData() loadData()
if (refreshRef) refreshRef.current = loadData if (refreshRef) refreshRef.current = loadData
const iv = setInterval(loadData, 5000) let active = true
return () => clearInterval(iv) 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]) }, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax') const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
const topCmds = (() => {
const counts = {}
for (const c of recentCmds) {
const base = c.cmd.split(/\s+/)[0]
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
if (!/^[a-zA-Z@.\/]/.test(base)) continue
counts[base] = (counts[base] || 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([cmd, count]) => ({ cmd, count }))
})()
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
const copyCmd = (cmd, key) => {
navigator.clipboard.writeText(cmd)
setCopiedSet(prev => new Set(prev).add(key))
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
}
const relativeTime = (ts) => {
if (!ts) return ''
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
if (diff < 60) return `${diff}s`
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
const recentUnique = (() => {
const seen = new Set()
return recentCmds.filter(c => {
if (seen.has(c.cmd)) return false
seen.add(c.cmd)
return true
})
})()
const providerEntries = consumption ? Object.entries(consumption) : []
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
const maxDaily = providerEntries.length > 0
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
: 1
return ( return (
<div className="dash-grid"> <div className="dash-grid">
@@ -108,34 +197,36 @@ export default function Dashboard({ api, refreshRef }) {
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" /> <MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div> </div>
{/* API Quota */} {/* Consommation */}
<div className="dash-card"> <div className="dash-card">
<div className="dash-card-head"> <div className="dash-card-head">
<span className="dash-label">API Quota</span> <span className="dash-label">Consommation</span>
<span className="dash-count">7j</span>
</div> </div>
<div className="dash-quota-list"> <div className="dash-consumption-list">
{minimax && minimax.data?.models?.map((m, i) => ( {providerEntries.length === 0 && (
<div key={i} className="dash-quota-row"> <span className="dash-empty">Aucune donnée</span>
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span> )}
<div className="dash-bar"> {providerEntries.map(([name, p], pi) => (
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} /> <div key={name} className="dash-consumption-provider">
<div className="dash-consumption-head">
<span className="dash-consumption-name" style={{ color: colors[pi % colors.length] }}>
{name.toUpperCase()}
</span>
<span className="dash-consumption-total">
{formatTokens(p.total_tokens)} tokens · {p.total_requests} req
</span>
</div>
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
<div className="dash-consumption-days">
{(p.daily || []).map((d, i) => (
<span key={i} className="dash-consumption-day">
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
</span>
))}
</div> </div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div> </div>
))} ))}
{minimax && minimax.data?.models?.length === 0 && (
<div className="dash-quota-row">
<span className="dash-quota-name">MiniMax</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div>
)}
{zai && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
</div>
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div> </div>
</div> </div>
@@ -157,16 +248,34 @@ export default function Dashboard({ api, refreshRef }) {
</div> </div>
{/* Recent Commands */} {/* Recent Commands */}
<div className="dash-card"> <div className="dash-card dash-cmd-card">
<div className="dash-card-head"> <div className="dash-card-head">
<span className="dash-label">Recent Commands</span> <span className="dash-label">Recent Commands</span>
<span className="dash-count">{recentUnique.length}</span>
</div> </div>
{topCmds.length > 0 && (
<div className="dash-cmd-freq">
<span className="dash-cmd-freq-title">Most used</span>
{topCmds.map((c, i) => (
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
<div className="dash-cmd-freq-bar-wrap">
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
</div>
<span className="dash-cmd-freq-count">{c.count}×</span>
</div>
))}
</div>
)}
<div className="dash-cmd-list"> <div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>} {recentUnique.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => ( {recentUnique.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}> <div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
<span className="dash-cmd-shell">{c.shell}</span> <div className="dash-cmd-left">
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span> <span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
</div>
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
</div> </div>
))} ))}
</div> </div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,238 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { TestTube2, Copy, RefreshCw, CheckCircle2, AlertTriangle, Globe, Terminal as TerminalIcon } from 'lucide-react'
export default function Tests({ api }) {
const [snippet, setSnippet] = useState(null)
const [snippetError, setSnippetError] = useState('')
const [sessions, setSessions] = useState([])
const [console_, setConsole_] = useState([])
const [activeSessionId, setActiveSessionId] = useState('')
const [copied, setCopied] = useState(false)
const pollRef = useRef(null)
const refreshSnippet = useCallback(async () => {
try {
const data = await api.getTestSnippet()
setSnippet(data)
setSnippetError('')
} catch (err) {
setSnippetError(err.message || 'Failed to load snippet')
}
}, [api])
const refreshSessions = useCallback(async () => {
try {
const data = await api.getTestSessions()
const next = data.sessions || []
setSessions(next)
if (!activeSessionId && next.length > 0) {
setActiveSessionId(next[0].id)
} else if (activeSessionId && !next.find(s => s.id === activeSessionId)) {
setActiveSessionId(next.length > 0 ? next[0].id : '')
}
} catch {}
}, [api, activeSessionId])
const refreshConsole = useCallback(async () => {
if (!activeSessionId) {
setConsole_([])
return
}
try {
const data = await api.getTestConsole(activeSessionId)
setConsole_(data.console || [])
} catch {
setConsole_([])
}
}, [api, activeSessionId])
useEffect(() => {
refreshSnippet()
}, [refreshSnippet])
useEffect(() => {
refreshSessions()
refreshConsole()
pollRef.current = setInterval(() => {
refreshSessions()
refreshConsole()
}, 2000)
return () => clearInterval(pollRef.current)
}, [refreshSessions, refreshConsole])
const copySnippet = useCallback(async () => {
if (!snippet) return
try {
await navigator.clipboard.writeText(snippet.snippet)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
}, [snippet])
const activeSession = sessions.find(s => s.id === activeSessionId) || null
return (
<div className="tests-layout" style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', height: '100%', overflow: 'auto' }}>
<section className="tests-pane">
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<TestTube2 size={18} />
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Tests pilotés par l'IA</h2>
</header>
<p style={{ marginTop: 0, opacity: 0.85, lineHeight: 1.5 }}>
Donnez à l'IA Studio le contrôle d'un onglet de votre navigateur pour tester chaque bouton et détecter les erreurs console.
</p>
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 12 }}>
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>1. Connexion</h3>
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
<li>Ouvrez la page à tester dans n'importe quel navigateur (Chrome, Firefox, Edge).</li>
<li>Ouvrez la console développeur (<kbd>F12</kbd>).</li>
<li>Collez ce snippet et appuyez sur <kbd>Entrée</kbd> :</li>
</ol>
{snippetError && (
<div style={{ background: 'rgba(220,80,80,0.1)', border: '1px solid rgba(220,80,80,0.3)', padding: 8, borderRadius: 4, marginBottom: 8 }}>
{snippetError}
</div>
)}
<div style={{ position: 'relative', marginBottom: 12 }}>
<pre style={{
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
padding: '10px 12px',
borderRadius: 4,
fontSize: '0.75em',
maxHeight: 180,
overflow: 'auto',
border: '1px solid var(--border, rgba(128,128,128,0.3))',
margin: 0,
}}>
{snippet?.snippet || 'Chargement…'}
</pre>
<button
onClick={copySnippet}
disabled={!snippet}
title="Copier"
style={{
position: 'absolute', top: 6, right: 6,
background: 'var(--bg-tertiary, rgba(255,255,255,0.08))',
border: '1px solid var(--border, rgba(128,128,128,0.3))',
color: 'inherit', padding: '4px 8px', borderRadius: 3,
cursor: 'pointer', fontSize: '0.75em',
display: 'flex', alignItems: 'center', gap: 4,
}}
>
<Copy size={11} /> {copied ? 'Copié !' : 'Copier'}
</button>
</div>
<button onClick={refreshSnippet} style={{ background: 'transparent', border: '1px solid var(--border, rgba(128,128,128,0.3))', color: 'inherit', padding: '4px 10px', borderRadius: 3, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
<RefreshCw size={12} /> Régénérer le token
</button>
<small style={{ display: 'block', opacity: 0.6, marginTop: 4 }}>
Le token expire après {snippet?.expires_in ? Math.round(snippet.expires_in / 60) : 5} minutes ou dès la première connexion.
</small>
</div>
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12, marginTop: 16 }}>
<h3 style={{ fontSize: '0.95em', margin: '0 0 8px' }}>2. Pilotage par l'IA</h3>
<p style={{ margin: '0 0 8px', lineHeight: 1.5 }}>
Une fois la session connectée, allez dans l'onglet <strong>Studio</strong> et demandez par exemple :
</p>
<pre style={{ background: 'var(--bg-secondary, rgba(0,0,0,0.3))', padding: 8, borderRadius: 4, fontSize: '0.85em', margin: 0 }}>
{`Teste tous les boutons de cette page,
clique sur chacun, et dis-moi
lesquels déclenchent une erreur console.`}
</pre>
<p style={{ margin: '8px 0 0', opacity: 0.75, fontSize: '0.85em' }}>
L'IA dispose de l'outil <code>browser_test</code> avec les actions <code>list_clickables</code>, <code>click</code>, <code>console</code>, <code>eval</code>, <code>type</code>, <code>current_url</code>, <code>wait</code>, <code>summary</code>.
</p>
</div>
</section>
<section className="tests-pane">
<header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Globe size={16} />
<h2 style={{ margin: 0, fontSize: '1.1em' }}>Sessions connectées</h2>
</div>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.85em' }}>
{sessions.length > 0 ? <CheckCircle2 size={14} color="#3aaa61" /> : <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: '#888' }} />}
{sessions.length} session{sessions.length > 1 ? 's' : ''}
</span>
</header>
{sessions.length === 0 ? (
<div style={{ padding: 16, textAlign: 'center', opacity: 0.7, border: '1px dashed var(--border, rgba(128,128,128,0.3))', borderRadius: 4 }}>
<AlertTriangle size={20} style={{ opacity: 0.4 }} />
<div style={{ marginTop: 6 }}>Aucune session active.</div>
<small>Collez le snippet dans une page pour démarrer.</small>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
{sessions.map(s => (
<button
key={s.id}
onClick={() => setActiveSessionId(s.id)}
style={{
textAlign: 'left',
background: s.id === activeSessionId ? 'var(--accent-bg, rgba(108,92,231,0.15))' : 'transparent',
border: '1px solid ' + (s.id === activeSessionId ? 'var(--accent, #6c5ce7)' : 'var(--border, rgba(128,128,128,0.3))'),
color: 'inherit',
padding: 8, borderRadius: 4, cursor: 'pointer',
}}
>
<div style={{ fontWeight: 500, fontSize: '0.9em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.title || s.url || s.id}
</div>
<div style={{ fontSize: '0.75em', opacity: 0.65, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.url} · session {s.id.slice(0, 8)}
</div>
</button>
))}
</div>
)}
{activeSession && (
<div style={{ borderTop: '1px solid var(--border, rgba(128,128,128,0.3))', paddingTop: 12 }}>
<header style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<TerminalIcon size={14} />
<h3 style={{ margin: 0, fontSize: '0.95em' }}>Console (live, dernières {console_.length})</h3>
</header>
<div style={{
background: 'var(--bg-secondary, rgba(0,0,0,0.3))',
padding: 8,
borderRadius: 4,
maxHeight: 380,
overflow: 'auto',
fontSize: '0.8em',
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
border: '1px solid var(--border, rgba(128,128,128,0.3))',
}}>
{console_.length === 0 ? (
<div style={{ opacity: 0.5 }}>(aucun message console)</div>
) : (
console_.map((c, i) => (
<div key={i} style={{ color: levelColor(c.level), padding: '2px 0', borderBottom: '1px dashed rgba(128,128,128,0.15)' }}>
<span style={{ opacity: 0.55, fontSize: '0.85em' }}>[{c.time?.slice(11, 19)} {c.level}]</span> {c.message}
</div>
))
)}
</div>
</div>
)}
</section>
</div>
)
}
function levelColor(lvl) {
switch (lvl) {
case 'error': return '#ff6b6b'
case 'warn': return '#f5a623'
case 'info': return '#4dabf7'
case 'debug': return '#888'
default: return 'inherit'
}
}

View File

@@ -16,6 +16,12 @@ const en = {
switchWindow: 'Switch window', switchWindow: 'Switch window',
sendMessage: 'Send message', sendMessage: 'Send message',
newLine: 'New line', newLine: 'New line',
copy: 'Copy',
paste: 'Paste',
search: 'Search',
zoom: 'Zoom +/',
switchTab: 'Switch tab',
nextTab: 'Next tab',
runCommand: 'Run command', runCommand: 'Run command',
commandHistory: 'Command history', commandHistory: 'Command history',
}, },
@@ -114,6 +120,8 @@ const en = {
port: 'Port', port: 'Port',
user: 'User', user: 'User',
keyPath: 'SSH key path', keyPath: 'SSH key path',
password: 'Password',
passwordHint: 'requires sshpass installed',
connect: 'Connect', connect: 'Connect',
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',
@@ -182,6 +190,8 @@ const en = {
installed: 'Installed', installed: 'Installed',
missing: 'Missing', missing: 'Missing',
editProfile: 'Edit', editProfile: 'Edit',
profileInfo: 'Personal Info',
profilePrefs: 'Preferences',
cancel: 'Cancel', cancel: 'Cancel',
editProvider: 'Configure', editProvider: 'Configure',
validateKey: 'Validate', validateKey: 'Validate',
@@ -201,8 +211,27 @@ const en = {
resetConfirm: 'Are you sure? All preferences will be erased.', resetConfirm: 'Are you sure? All preferences will be erased.',
resetDone: 'Settings reset.', resetDone: 'Settings reset.',
applyStarship: 'Apply starship', applyStarship: 'Apply starship',
apply: 'Apply',
remove: 'Remove',
starshipApplied: 'Starship theme applied! Restart your shell to see the result.', starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
starshipError: 'Failed to apply starship theme.', starshipError: 'Failed to apply starship theme.',
systemConfig: 'System Configuration',
aiToolsConfig: 'Tools & Environments',
configureViaAI: 'Configure',
toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.',
toolClaudeDesc: 'AI coding assistant by Anthropic.',
toolGhDesc: 'Command-line interface for GitHub.',
toolDockerDesc: 'Application containerization platform.',
toolGoDesc: 'Programming language and runtime environment.',
toolNodeDesc: 'JavaScript runtime and package manager.',
toolPythonDesc: 'Programming language, pip and uv manager.',
toolStarshipDesc: 'Modern and customizable shell prompt.',
systemUpdate: 'System Update',
systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).',
systemUpdateDescNoSudo: 'Shows update commands to run manually.',
updateBtn: 'Update',
notInstalled: 'Not installed',
install: 'Install',
}, },
} }

View File

@@ -16,6 +16,12 @@ const fr = {
switchWindow: 'Changer de fen\u00eatre', switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message', sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne', newLine: 'Nouvelle ligne',
copy: 'Copier',
paste: 'Coller',
search: 'Rechercher',
zoom: 'Zoom +/\u2212',
switchTab: 'Changer d\u2019onglet',
nextTab: 'Onglet suivant',
runCommand: 'Ex\u00e9cuter', runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique', commandHistory: 'Historique',
}, },
@@ -114,6 +120,8 @@ const fr = {
port: 'Port', port: 'Port',
user: 'Utilisateur', user: 'Utilisateur',
keyPath: 'Chemin cl\u00e9 SSH', keyPath: 'Chemin cl\u00e9 SSH',
password: 'Mot de passe',
passwordHint: 'n\u00e9cessite sshpass install\u00e9',
connect: 'Se connecter', connect: 'Se connecter',
save: 'Enregistrer', save: 'Enregistrer',
cancel: 'Annuler', cancel: 'Annuler',
@@ -136,7 +144,7 @@ const fr = {
terminal: 'Terminal', terminal: 'Terminal',
updates: 'Mises \u00e0 jour', updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier', locale: 'Langue & Clavier',
skills: 'Comp\u00e9ENCES', skills: 'Compétences',
system: 'Syst\u00e8me', system: 'Syst\u00e8me',
}, },
profile: 'Profil', profile: 'Profil',
@@ -160,7 +168,7 @@ const fr = {
save: 'Enregistrer', save: 'Enregistrer',
saved: 'Enregistr\u00e9 !', saved: 'Enregistr\u00e9 !',
error: 'Erreur', error: 'Erreur',
skills: 'Comp\u00e9ENCES', skills: 'Compétences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.', noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init', runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue', language: 'Langue',
@@ -182,6 +190,8 @@ const fr = {
installed: 'Install\u00e9', installed: 'Install\u00e9',
missing: 'Manquant', missing: 'Manquant',
editProfile: 'Modifier', editProfile: 'Modifier',
profileInfo: 'Informations personnelles',
profilePrefs: 'Préférences',
editProvider: 'Configurer', editProvider: 'Configurer',
validateKey: 'Valider', validateKey: 'Valider',
validating: 'V\u00e9rification...', validating: 'V\u00e9rification...',
@@ -201,8 +211,27 @@ const fr = {
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.', resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.', resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
applyStarship: 'Appliquer starship', applyStarship: 'Appliquer starship',
apply: 'Appliquer',
remove: 'Retirer',
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.', starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.', starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
systemConfig: 'Configuration Syst\u00e8me',
aiToolsConfig: 'Outils & Environnements',
configureViaAI: 'Configurer',
toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.',
toolClaudeDesc: 'Assistant de codage IA par Anthropic.',
toolGhDesc: 'Interface en ligne de commande pour GitHub.',
toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.',
toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.',
toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.',
toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.',
toolStarshipDesc: 'Prompt shell moderne et personnalisable.',
systemUpdate: 'Mise à jour système',
systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).',
systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.',
updateBtn: 'Mettre à jour',
notInstalled: 'Non installé',
install: 'Installer',
}, },
} }

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; } .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 { position: absolute; inset: 0; overflow: hidden; }
.tab-hidden { display: none; }
.statusbar { .statusbar {
height: 28px; height: 28px;
@@ -274,8 +276,8 @@ input::placeholder { color: var(--text-disabled); }
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); } .sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; } .sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
.shell-layout { display: flex; height: 100%; } .shell-layout { display: flex; height: 100%; overflow: hidden; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } .shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.shell-tabs-bar { .shell-tabs-bar {
display: flex; align-items: center; background: var(--bg-surface); display: flex; align-items: center; background: var(--bg-surface);
@@ -327,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.shell-zoom-badge {
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
color: var(--accent); background: var(--accent-bg);
padding: 2px 6px; border-radius: 3px;
border: 1px solid var(--accent-dim);
white-space: nowrap;
}
.shell-new-tab-wrapper { position: relative; } .shell-new-tab-wrapper { position: relative; }
.shell-new-tab-btn { .shell-new-tab-btn {
display: flex; align-items: center; gap: 2px; display: flex; align-items: center; gap: 2px;
@@ -369,40 +379,177 @@ input::placeholder { color: var(--text-disabled); }
.shell-menu-item-row { display: flex; align-items: center; } .shell-menu-item-row { display: flex; align-items: center; }
.shell-menu-item-icon { .shell-menu-item-icon {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius); width: 26px; height: 26px; border-radius: var(--radius);
background: transparent; border: none; color: var(--text-disabled); background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary);
cursor: pointer; transition: all 0.1s; flex-shrink: 0; cursor: pointer; transition: all 0.1s; flex-shrink: 0;
} }
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); } .shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); }
.shell-menu-empty { .shell-menu-empty {
font-size: 12px; color: var(--text-disabled); padding: 8px 10px; font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
font-style: italic; font-style: italic;
} }
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } .shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance {
position: absolute; inset: 0; padding: 4px; .shell-search-bar {
display: block !important; position: absolute; top: 8px; right: 12px; z-index: 20;
display: flex; align-items: center; gap: 4px;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius); padding: 4px 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
} }
.shell-xterm-instance .xterm { height: 100%; padding: 4px; } .shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
.shell-search-input {
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
font-family: var(--font-mono); outline: none;
}
.shell-search-input:focus { border-color: var(--accent); }
.shell-search-nav {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 4px;
background: transparent; border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
padding: 0; transition: all 0.1s;
}
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
.shell-search-close {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 4px;
background: transparent; border: none;
color: var(--text-disabled); cursor: pointer; padding: 0;
}
.shell-search-close:hover { color: var(--accent); }
.shell-xterm-instance {
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
}
.shell-xterm-instance.active {
visibility: visible;
pointer-events: auto;
}
.shell-xterm-instance .xterm { height: 100%; }
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } .connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
.connection-dot.off { background: var(--error); } .connection-dot.off { background: var(--error); }
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; } .shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); } .shell-tab.ai-tab { border-bottom-color: var(--accent); }
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
.sudo-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.sudo-indicator.sudo-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); }
.sudo-indicator.sudo-blocked { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); }
.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; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; } .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(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); } .ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
.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.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 { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); } .ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; } .ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); } .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; } .ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
.ai-panel-input .ai-clear-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 16px; border-radius: var(--radius); border: 1px solid var(--accent);
background: var(--accent-bg); color: var(--accent); font-size: 13px; font-weight: 700;
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
}
.ai-panel-input .ai-clear-btn:hover { background: var(--accent); color: #fff; }
.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-code-actions button.copied { background: var(--accent-bg); color: var(--accent); animation: copy-flash 0.3s ease; }
.shell-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
.shell-mermaid-container svg { max-width: 100%; height: auto; }
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.ai-message thead, .ai-message tbody { display: table-row-group; }
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
.ai-message tr { display: table-row; }
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
@keyframes copy-flash {
0% { transform: scale(1); }
50% { transform: scale(1.05); background: color-mix(in srgb, var(--accent) 20%, transparent); }
100% { transform: scale(1); }
}
.shell-analysis-modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh;
display: flex; flex-direction: column; overflow: hidden;
}
.shell-analysis-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border);
font-weight: 700; font-size: 15px; color: var(--accent);
}
.shell-analysis-modal-body {
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
color: var(--text-primary); word-break: break-word;
}
.shell-analysis-modal-body table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
.shell-analysis-modal-body th { background: var(--bg-surface); padding: 4px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
.shell-analysis-modal-body td { padding: 3px 10px; border: 1px solid var(--border); color: var(--text-primary); }
.shell-analysis-modal-body tr:nth-child(even) td { background: var(--bg-surface); }
.shell-analysis-modal-body .msg-h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin: 16px 0 6px; display: block; }
.shell-analysis-modal-body .msg-h4 { font-size: 15px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 4px; display: block; }
.shell-analysis-modal-body .msg-h2 { font-size: 20px; font-weight: 700; color: var(--accent); margin: 20px 0 8px; display: block; }
.shell-analysis-modal-body .msg-bullet { display: block; padding-left: 4px; margin: 2px 0; color: var(--text-primary); }
.shell-analysis-modal-body .msg-step { display: flex; gap: 8px; align-items: baseline; margin: 2px 0; }
.shell-analysis-modal-body .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); flex-shrink: 0; }
.shell-analysis-modal-body strong { color: var(--accent-light); }
.shell-analysis-modal-body .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.shell-modal-overlay { .shell-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); position: fixed; inset: 0; background: rgba(0,0,0,0.6);
@@ -429,12 +576,16 @@ input::placeholder { color: var(--text-disabled); }
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.config-tabs-bar { .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; border-bottom: 1px solid var(--border); flex-shrink: 0;
} }
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .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-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 { .config-card {
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
@@ -476,6 +627,9 @@ input::placeholder { color: var(--text-disabled); }
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; } .provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; } .provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; } .provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); }
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } .provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.provider-setup-hint { .provider-setup-hint {
@@ -500,10 +654,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-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); } .config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; } .skill-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.config-skill-row:last-child { border-bottom: none; } .skill-tile { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; cursor: pointer; transition: border-color 0.15s; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; } .skill-tile:hover { border-color: var(--accent-dim); }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; } .chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast { .config-toast {
@@ -535,6 +703,7 @@ input::placeholder { color: var(--text-disabled); }
.dash-grid { .dash-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
height: 100%; height: 100%;
@@ -544,7 +713,7 @@ input::placeholder { color: var(--text-disabled); }
position: relative; position: relative;
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px 16px; border-radius: var(--radius-lg); padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; justify-content: center; gap: 8px;
overflow: hidden; overflow: hidden;
} }
@@ -594,6 +763,25 @@ input::placeholder { color: var(--text-disabled); }
white-space: nowrap; white-space: nowrap;
} }
/* Consumption */
.dash-consumption-list { display: flex; flex-direction: column; gap: 10px; max-height: 270px; overflow-y: auto; }
.dash-consumption-provider { display: flex; flex-direction: column; gap: 4px; }
.dash-consumption-head { display: flex; align-items: center; justify-content: space-between; }
.dash-consumption-name {
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
}
.dash-consumption-total {
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
}
.dash-consumption-days {
display: flex; gap: 4px; flex-wrap: wrap;
}
.dash-consumption-day {
font-size: 9px; font-family: var(--font-mono); color: var(--text-tertiary);
background: var(--bg-input); padding: 1px 5px; border-radius: 4px;
}
.dash-consumption-day strong { color: var(--text-secondary); }
/* Processes */ /* Processes */
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; } .dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
.dash-proc-row { .dash-proc-row {
@@ -602,27 +790,45 @@ input::placeholder { color: var(--text-disabled); }
} }
.dash-proc-name { .dash-proc-name {
font-size: 11px; font-weight: 600; color: var(--text-primary); 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 { .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 */ /* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; } .dash-cmd-card .dash-cmd-list { max-height: 220px; }
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
.dash-cmd-row { .dash-cmd-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 3px 0; overflow: hidden; padding: 5px 8px; border-radius: var(--radius-sm);
} background: var(--bg-surface); cursor: pointer;
.dash-cmd-shell { transition: background 0.12s;
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
text-transform: uppercase; flex-shrink: 0;
} }
.dash-cmd-row:hover { background: var(--accent-bg); }
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.dash-cmd-text { .dash-cmd-text {
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary); font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
.dash-cmd-freq-row {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 3px 4px; border-radius: var(--radius-sm);
transition: background 0.12s;
}
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
/* Services */ /* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; } .dash-services { display: flex; flex-direction: column; gap: 6px; }
@@ -703,7 +909,17 @@ input::placeholder { color: var(--text-disabled); }
/* ── Studio Feed ── */ /* ── Studio Feed ── */
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; } .studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
.studio-scroll-btn {
width: 32px; height: 32px; border-radius: 50%; padding: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg-card); border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
opacity: 0.7;
}
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; } .feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; } .feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
.feed-item:hover { background: var(--bg-card); } .feed-item:hover { background: var(--bg-card); }
@@ -725,9 +941,21 @@ input::placeholder { color: var(--text-disabled); }
} }
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; } .feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); } .feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; } .feed-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; } .feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; } .feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
.feed-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 { .feed-thinking-block {
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim); background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
@@ -767,17 +995,41 @@ input::placeholder { color: var(--text-disabled); }
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; margin: 8px 0; overflow: hidden; margin: 8px 0;
} }
.studio-code-header {
display: flex; align-items: center; justify-content: flex-end;
background: var(--bg-surface); border-bottom: 1px solid var(--border);
}
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; } .studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
.studio-code-lang { .studio-code-lang {
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary); padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; background: var(--bg-surface); text-transform: uppercase; letter-spacing: 0.5px;
} }
.studio-copy-btn {
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
background: transparent; border: none; border-left: 1px solid var(--border);
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
white-space: nowrap;
}
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
.studio-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
.studio-mermaid-container svg { max-width: 100%; height: auto; }
.studio-mermaid-loading { padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
.studio-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); } .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; } .msg-h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; display: block; }
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; } .msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; } .msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); } .msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; } .msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; } .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; } .studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }
@@ -791,7 +1043,18 @@ input::placeholder { color: var(--text-disabled); }
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; } .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 { 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.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 { 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 { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea { .studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px; flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
@@ -814,8 +1077,80 @@ input::placeholder { color: var(--text-disabled); }
cursor: pointer; transition: all 0.15s; flex-shrink: 0; cursor: pointer; transition: all 0.15s; flex-shrink: 0;
} }
.studio-stop-btn:hover { opacity: 0.8; } .studio-stop-btn:hover { opacity: 0.8; }
/* ── Image Attachments ── */
.studio-attach-btn {
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); background: var(--bg-card); color: var(--text-tertiary);
border: 1px solid var(--border); cursor: pointer; transition: all 0.15s; flex-shrink: 0;
}
.studio-attach-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.studio-attach-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.studio-image-previews {
display: flex; gap: 10px; padding: 10px 8px; flex-wrap: wrap; justify-content: center;
}
.studio-image-preview {
position: relative; width: 110px; height: 110px; border-radius: var(--radius-lg);
overflow: hidden; border: 2px solid var(--border); background: var(--bg-surface);
transition: border-color 0.2s;
}
.studio-image-preview:hover { border-color: var(--accent-dim); }
.studio-image-preview img {
width: 100%; height: 100%; object-fit: cover;
}
.studio-image-remove {
position: absolute; top: 4px; right: 4px; width: 24px; height: 24px;
border-radius: 50%; background: rgba(0,0,0,0.75); color: #fff; border: none;
font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center;
justify-content: center; line-height: 1; transition: background 0.15s;
backdrop-filter: blur(4px);
}
.studio-image-remove:hover { background: var(--error); }
/* ── Feed Images (in chat messages) ── */
.feed-images {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 6px;
}
.feed-image {
max-width: 240px; max-height: 180px; border-radius: var(--radius);
border: 1px solid var(--border); object-fit: cover; cursor: pointer;
transition: transform 0.15s, border-color 0.15s;
}
.feed-image:hover { transform: scale(1.03); border-color: var(--accent-dim); }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; } .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; }
.feed-summary-block { margin: 4px 0; }
.feed-summary-header {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-summary-header:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
.feed-summary-header svg { color: var(--accent); flex-shrink: 0; }
.feed-summary-text { font-size: 11px; color: var(--text-tertiary); flex: 1; font-weight: 600; }
.feed-summary-toggle { font-size: 10px; color: var(--accent); font-family: var(--font-mono); }
.skill-list-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.skills-list { display: flex; flex-direction: column; gap: 2px; }
/* ── Studio Tool Blocks ── */ /* ── Studio Tool Blocks ── */
.studio-tool-block { .studio-tool-block {
background: var(--bg-surface); background: var(--bg-surface);
@@ -904,3 +1239,124 @@ input::placeholder { color: var(--text-disabled); }
word-break: break-word; word-break: break-word;
background: var(--bg); background: var(--bg);
} }
/* === XTerm Custom Styling === */
/* Styles for xterm.js integrated with Muyue theme */
.shell-xterm-instance .xterm {
padding: 4px 8px;
}
.shell-xterm-instance .xterm-viewport {
background-color: var(--bg-base) !important;
}
.shell-xterm-instance .xterm-screen {
background-color: var(--bg-base);
}
/* Scrollbar styling for xterm */
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
background: var(--bg-surface);
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
background: var(--accent-dim);
border-radius: 4px;
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: var(--accent-dark);
}
/* Selection styling */
.shell-xterm-instance .xterm-selection {
background: var(--accent-dim) !important;
}
/* Focus ring styling */
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
box-shadow: none;
}
/* Ensure consistent font rendering */
.shell-xterm-instance .xterm .xterm-char-measure-element {
font-family: var(--font-mono) !important;
}
/* Bell animation styling */
.shell-xterm-instance .xterm-bell {
animation: xterm-bell-flash 0.3s ease-out;
}
@keyframes xterm-bell-flash {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 0; }
}
/* Cursor styling */
.shell-xterm-instance .xterm-cursor {
outline: none !important;
}
/* Link styling for web links addon */
.shell-xterm-instance .xterm-link {
color: var(--accent-light) !important;
text-decoration: underline;
}
.shell-xterm-instance .xterm-link:hover {
color: var(--accent-muted) !important;
}
.config-ai-tools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.config-ai-tool-card {
display: flex;
flex-direction: column;
padding: 14px;
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
transition: border-color 0.15s;
min-height: 120px;
}
.config-ai-tool-card:hover {
border-color: var(--accent-dim);
}
.config-ai-tool-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.config-ai-tool-icon {
font-size: 18px;
line-height: 1;
}
.config-ai-tool-name {
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
}
.config-ai-tool-desc {
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.4;
margin-bottom: 10px;
flex: 1;
}