Compare commits

...

75 Commits

Author SHA1 Message Date
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
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
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
1074b019d3 feat(studio): Tab focuses textarea, autocomplete commands
All checks were successful
Beta Release / beta (push) Successful in 41s
- Tab outside textarea focuses it
- Tab inside textarea autocompletes / commands (/clear, /summarize, etc.)

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:57:00 +02:00
Augustin
f9c4cf11ff feat(shell): dedicated System Analyst AI, no code execution, analyze system
All checks were successful
Beta Release / beta (push) Successful in 45s
- New ShellConvStore with persistent history (shell_conversation.json)
- 100k token limit — input grays out, must /clear to continue
- Commands limited to /clear and /help only
- Shell AI has NO tools — read-only analysis, never executes code
- "Analyste Système" panel with system analysis button
- System analysis uses Studio AI to write system_analysis.md,
  prepended as context on every conversation start
- Code blocks show "Copier" and "Terminal" buttons to copy or
  send code directly to the active terminal via WebSocket
- Token bar shows usage with warning at 80%

💘 Generated with Crush

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

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:46:16 +02:00
Augustin
7682717093 feat(dashboard): add quota monitoring, process list, and command history
All checks were successful
Beta Release / beta (push) Successful in 44s
- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

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

🤖 Generated with Crush

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

💘 Generated with Crush

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

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-22 22:22:05 +02:00
Augustin
66b773ff86 feat(agent): refactor AI chat with streaming, agent registry, and tool execution
All checks were successful
Beta Release / beta (push) Successful in 47s
- Replace old tool-call regex with proper agent registry
- Add streaming chat via SSE (handleStreamChat / handleNonStreamChat)
- Add internal/agent package with tool definitions and execution
- Add orchestrator with system prompt and tool scaffolding
- Add internal/agent/ directory
- Studio.jsx: streaming chat with thinking indicator and tool result rendering
- global.css: chat bubble styles, streaming animation, thinking dots
- handlers_chat.go: full rewrite using new agent/orchestrator architecture

💘 Generated with Crush

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

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 21:04:27 +02:00
Augustin
e19122dad9 fix(onboarding): require fields before advancing steps
All checks were successful
Beta Release / beta (push) Successful in 39s
- Validate each step before allowing goNext
- Show required error message on name step if empty
- Clear error on input change

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:58:36 +02:00
Augustin
8b6a7e8bc3 fix: register missing /api/config/reset and /api/starship/apply-theme routes
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add resetConfig and applyStarshipTheme to frontend api client
- Register handleResetConfig and handleApplyStarshipTheme in server mux

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:57:25 +02:00
Augustin
58f8cb0bd3 fix(config): per-provider form state to avoid field cross-talk
All checks were successful
Beta Release / beta (push) Successful in 38s
- providerForm is now keyed by provider name
- Each provider (minimax/glm/claude) has isolated form data
- Validation and save target the specific provider being edited

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:56:04 +02:00
Augustin
b52feccc17 fix(onboarding): auto-save on done step, keyboard nav, error feedback
All checks were successful
Beta Release / beta (push) Successful in 40s
- Trigger save automatically when reaching done step
- Add Escape to go back, Enter to advance (works in text fields)
- Add back button visible between step 1 and last step
- Fix accent encoding in done message
- Show saving state and error with retry button

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:53:12 +02:00
Augustin
5bbac499a7 feat(config): add system panel with reset and starship theme, add onboarding wizard
All checks were successful
Beta Release / beta (push) Successful in 41s
- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default)
- Add OnboardingWizard that activates when profile is empty on first run
- Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering)
- Add /api/config/reset and /api/starship/apply-theme endpoints
- Wire wizard trigger in App.jsx based on profile completeness

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:36:36 +02:00
Augustin
83d7a573c7 fix: correct version from 3.2 to 0.3.2
All checks were successful
Beta Release / beta (push) Successful in 41s
The version was incorrectly bumped to 3.2 instead of 0.3.2.
This follows the existing semver pattern (v0.2.0, v0.2.1, v0.3.1).

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:29:09 +02:00
Augustin
0fe82f67df chore: bump version to 3.2
All checks were successful
Beta Release / beta (push) Successful in 45s
2026-04-22 20:19:47 +02:00
Augustin
4b9f2c377d Merge branch 'main' into develop 2026-04-22 20:19:35 +02:00
Augustin
95bd824259 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Stable Release / stable (push) Successful in 41s
2026-04-22 20:19:29 +02:00
Augustin
252f178bbd fix(terminal): init payload never sent due to ws.onopen being overwritten
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:19:29 +02:00
Augustin
7dcf505360 fix(terminal): improve shell resolution with better error handling and ws proxy support
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:19:29 +02:00
Augustin
8fb93fa47e feat(studio): parse AI thinking and tool launch messages in terminal panel
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
5ec373cd6a fix(studio): forward AI thinking chunks to frontend instead of dropping them
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
1eb5a6d00f feat(studio): add tool execution and hide AI thinking tags
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
cd5ebe083c fix(terminal): ignore invalid shell config from race condition
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
2004c15dd7 feat(shell): restore AI assistant panel
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
9306152736 fix(terminal): restore terminal input and cursor visibility
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
e15a034de5 refactor(api): split monolithic handlers.go into focused modules
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 20:19:29 +02:00
Augustin
3b6cc38ea0 refactor(config): remove Terminal sub-tab from Configuration page
All checks were successful
Beta Release / beta (push) Successful in 41s
2026-04-22 20:13:17 +02:00
Augustin
93a22d4075 fix(terminal): init payload never sent due to ws.onopen being overwritten
All checks were successful
Beta Release / beta (push) Successful in 40s
connectWebSocket set ws.onopen to send the shell init payload, but
initTerminal immediately overwrote it with a state-only handler.
Switched to addEventListener so both handlers coexist.
2026-04-22 20:05:10 +02:00
Augustin
e0e1e73bca fix(terminal): improve shell resolution with better error handling and ws proxy support
All checks were successful
Beta Release / beta (push) Successful in 40s
The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info.
Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and
logs detailed context for debugging. Also enable WebSocket proxying in Vite dev.
2026-04-22 20:02:55 +02:00
Augustin
0496ca789b feat(studio): parse AI thinking and tool launch messages in terminal panel
All checks were successful
Beta Release / beta (push) Successful in 40s
- Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL),
  and normal AI responses
- Style thinking messages with italic blue, tool messages with yellow border
- Add toolLaunched i18n key for both fr and en locales

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:41:42 +02:00
Augustin
b407ab879b fix(studio): forward AI thinking chunks to frontend instead of dropping them
All checks were successful
Beta Release / beta (push) Successful in 40s
The ThinkingBlock component existed but was dead code — the backend
silently discarded all <think chunks. Now emits thinking SSE events
so the UI can display AI reflections in real-time.

\xe2\x98\x85 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-22 19:12:32 +02:00
Augustin
12df184e11 feat(studio): add tool execution and hide AI thinking tags
All checks were successful
Beta Release / beta (push) Successful in 40s
Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 19:04:59 +02:00
Augustin
8af6d25e28 fix(terminal): ignore invalid shell config from race condition
All checks were successful
Beta Release / beta (push) Successful in 40s
Reject shell paths with length <= 1 to prevent errors when user
input is accidentally sent as init message.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:56:33 +02:00
Augustin
4fd599adec feat(shell): restore AI assistant panel
All checks were successful
Beta Release / beta (push) Successful in 38s
Re-add the AI assistant panel that was removed in previous refactoring.
The panel includes:
- Message history display
- Input field for AI queries
- Loading state indicator

Also restored the associated CSS styles and i18n translations.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:51:33 +02:00
Augustin
bcba5932d5 fix(terminal): restore terminal input and cursor visibility
All checks were successful
Beta Release / beta (push) Successful in 38s
- Fix shell execution to avoid --login flag causing issues on some shells
- Improve terminal initialization timing with requestAnimationFrame
- Force display visibility on xterm instances via CSS
- Ensure container has proper min-height and overflow handling

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:46:29 +02:00
Augustin
04b0fff791 refactor(api): split monolithic handlers.go into focused modules
All checks were successful
Beta Release / beta (push) Successful in 44s
Break down the 627-line handlers.go into specialized modules:
- handlers_chat.go: chat and streaming endpoints
- handlers_config.go: configuration endpoints
- handlers_common.go: shared utilities
- handlers_info.go: info and status endpoints
- handlers_terminal.go: terminal/shell endpoints
- handlers_tools.go: tool-related endpoints

Also includes config improvements, orchestrator enhancements, and
web component updates.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-22 18:34:14 +02:00
CI Bot
80c11cab3f chore: update CHANGELOG for v0.3.0 2026-04-21 21:08:06 +00:00
Augustin
0b221094f2 fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS
All checks were successful
Beta Release / beta (push) Successful in 37s
Stable Release / stable (push) Successful in 37s
- Fix detectShell() to return full paths via LookPath (was returning bare
  names causing exec error on some systems)
- Add shell path validation before pty.Start to prevent crashes
- Simplify CLI: remove all subcommands, keep only desktop launch with --port
- Restore missing Studio shared CSS (code blocks, input area, animations)
- Replace Config vertical sidebar with horizontal nav-tabs matching main layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 23:06:39 +02:00
Augustin
7f674730c7 feat: add API key validation flow for AI provider config
All checks were successful
Beta Release / beta (push) Successful in 37s
- Add POST /api/providers/validate backend endpoint that sends a test
  request to the provider's chat/completions API to verify the key
- Add validateProvider to frontend API client
- Redesign PanelProviders: show token input inline with Validate button,
  display valid/invalid badge after validation, Save only appears after
  successful validation
- Add i18n keys (EN/FR) for validation flow

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:53:52 +02:00
Augustin
040e482c75 feat(studio): replace sidebar layout with unified execution feed styles
All checks were successful
Beta Release / beta (push) Successful in 36s
Replace old Studio sidebar/chat bubble CSS with new feed-based layout.
Add studio.cleared i18n key for /clear command feedback.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:46:36 +02:00
Augustin
c8903efa5e fix: guard against empty tabs array in closeTab
All checks were successful
Beta Release / beta (push) Successful in 37s
💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:43:38 +02:00
Augustin
f3cb306053 refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard
All checks were successful
Beta Release / beta (push) Successful in 38s
- Config: sidebar navigation with 5 panels (Profile, AI Providers, Updates, Locale, Skills)
- Dashboard: remove duplicated system overview section, keep workflows and activity log
- New CSS for config window layout, cards, provider cards, update rows
- Add i18n panel keys (FR/EN)

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:41:25 +02:00
Augustin
3cdcb22068 feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign
All checks were successful
Beta Release / beta (push) Successful in 39s
- Terminal: multi-tab sessions, SSH connections, shell detection (zsh/bash/fish/wsl/powershell)
- Config: inline profile & provider editing, system update management
- Dashboard: grid layout with inline tools/notifications/workflows sections
- Add lucide-react icons, i18n keys (FR/EN), and new CSS components

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:35:49 +02:00
Augustin
ee18bbeb53 feat(studio): add i18n keys and CSS for redesigned AI chat interface
All checks were successful
Beta Release / beta (push) Successful in 2m10s
Add missing English translations and full cyberpunk-themed CSS for the
new Studio tab: central AI chat with context panels (plans, agents,
activity), streaming cursor, plan detail expansion, and collapsible
sidebar.

Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 22:18:31 +02:00
Augustin
b0b0e1d308 chore: bump version to 0.3.0
Some checks failed
Beta Release / beta (push) Has been cancelled
feat(shell): real terminal with xterm.js + PTY over WebSocket

Replace fake shell input with a full PTY-backed terminal using xterm.js.
Apps like btop, vim, htop now work. AI chat panel is always visible.

Backend:
- Add WebSocket handler /api/ws/terminal with creack/pty
- Allocate real pseudo-terminal with TERM=xterm-256color
- Bidirectional I/O + dynamic resize via pty.Setsize
- Skip JSON headers on /api/ws/* paths for WebSocket upgrade

Frontend:
- Integrate xterm.js with FitAddon and WebLinksAddon
- Cyberpunk color theme matching app design
- ResizeObserver for automatic terminal resizing
- AI assistant panel always visible (340px, no toggle)
- Connection status indicator (green/red dot)

Dependencies:
- Go: github.com/gorilla/websocket, github.com/creack/pty/v2
- npm: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:17:24 +02:00
Augustin
fc7981037f chore: remove dead code (packages, functions, types, constants)
All checks were successful
Beta Release / beta (push) Successful in 34s
Remove 5 unused packages (daemon, preview, proxy, workflow) and dead
symbols across 7 files: orchestrator workflow engine, skills Target type
and Update(), LSP config generation, installer SetupPrompt(), unexported
desktop options, and version License/Prerelease. Total: -1453 lines.
2026-04-21 22:09:42 +02:00
Augustin
f7222b0f6c docs: rewrite README and CHANGELOG for desktop app mode
All checks were successful
Beta Release / beta (push) Successful in 32s
Update README to reflect TUI removal and new React desktop UI with
API backend, i18n, themes, and keyboard layout support. Fix duplicate
v0.2.1 entries in CHANGELOG and add [Unreleased] section for recent
desktop/i18n/theme changes.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 22:02:09 +02:00
Augustin
11417d3ea7 feat(web): add i18n support with FR/EN locales and keyboard layout awareness
All checks were successful
Beta Release / beta (push) Successful in 36s
Add full internationalization system with React context, French/English
translations, and AZERTY/QWERTY keyboard layout support. Dashboard now
uses a tabbed layout (Tools, Notifications, Workflows). Config page exposes
language and keyboard preferences persisted via new /api/preferences endpoint.

💕 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
2026-04-21 21:48:36 +02:00
Augustin
3dc24ae22c refactor(web): redesign frontend for native web UX
All checks were successful
Beta Release / beta (push) Successful in 33s
- Replace all TUI artifacts: [OK], [FAIL], >>, [■], [$] with proper
  web components (badges, cards, chips, avatars)
- Rename CSS variables from TUI names (cyberRed, dimRed, bgVoid)
  to semantic names (accent, accent-dim, bg)
- Add proper interactive elements: hover states, cursor pointer,
  click feedback (scale), focus rings, spinner animation
- Fix user-select: was none globally, now allows text selection
- Redesign navigation: proper tabs with role="tab" and aria attributes
- Add keyboard shortcuts only when not in input/textarea (1-4 for tabs)
- Replace footer TUI shortcuts with clean statusbar
- Dashboard: card-based layout, badge status, progress bar, activity log
- Studio: message bubbles (aligned left/right), agent cards with avatars
- Shell: command history (ArrowUp/Down), toggleable AI panel button,
  panel header with current directory
- Config: provider cards, color swatches for theme picker,
  clean field rows with empty states
- CSS imported via main.jsx (not HTML link) for proper Vite hashing
- Remove glitch/scanline/typewriter TUI animations
- Add favicon

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:25:55 +02:00
Augustin
aa0ff199c6 refactor: remove TUI, desktop web UI is now the default and only mode
All checks were successful
Beta Release / beta (push) Successful in 2m17s
- Remove internal/tui/ entirely (2600+ lines of Bubble Tea code)
- Remove bubbletea, bubbles, lipgloss direct dependencies
- `muyue` now launches desktop web UI (opens browser)
- CLI subcommands preserved (scan, install, setup, etc.)
- Unknown args passed as desktop flags (--port, --no-open)
- Update help text to reflect new default behavior

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:10:31 +02:00
Augustin
34636056da refactor: unify into single muyue binary with embedded desktop mode
All checks were successful
Beta Release / beta (push) Successful in 37s
- Merge muyue + muyue-desktop into one binary (13MB)
- `muyue` starts TUI, `muyue desktop` launches web UI in browser
- Move frontend from cmd/muyue-desktop/frontend/ to web/ (standard Go layout)
- Add web/embed.go with //go:embed all:dist for frontend assets
- Add internal/desktop/ package (server, browser open, SPA routing, signals)
- Split internal/api/api.go into server.go + handlers.go
- Add internal/desktop/desktop.go with SPA fallback and --port/--no-open flags
- Clean package.json: remove unused @xterm/xterm, switch to ESM
- Fix vite.config.js proxy to use port 8095 for dev mode
- Add Makefile targets: frontend, desktop, dev-desktop
- Update all CI workflows: single binary build, web/ paths
- Remove cmd/muyue-desktop/ entirely

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-21 21:04:47 +02:00
Augustin
097cf40ccd fix(ci): add frontend build step before Go vet/test/build
All checks were successful
Beta Release / beta (push) Successful in 1m16s
All three CI workflows now build the React frontend (npm ci && npm run
build) before any Go steps, so the go:embed directive in
cmd/muyue-desktop/main.go finds the dist/ directory.

- ci-develop.yml: already rewritten, included in this commit
- ci-main.yml: add Node 22 setup, cache, frontend build, desktop binary
  builds for all platforms, updated changelog download table
- ci-pr.yml: add Node 22 setup, cache, frontend build, desktop binary
  build check

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-20 22:54:50 +02:00
Augustin ROUX
88d2a03808 feat: add desktop app with React frontend, API backend, theme system (#2)
Some checks failed
Beta Release / beta (push) Failing after 12s
New desktop application that launches a local HTTP server with embedded
React frontend. Opens in the user's browser automatically.

Architecture:
- internal/api/: REST API exposing all internal/ packages to frontend
- cmd/muyue-desktop/: entry point, serves embedded frontend + API
- cmd/muyue-desktop/frontend/: React + Vite SPA

Frontend features:
- 4 tabs: Dashboard, Studio, Shell, Config
- Cyberpunk red theme with CSS custom properties
- Theme system: 4 built-in themes (Cyberpunk Red, Pink, Midnight Blue, Matrix Green)
- Terminal with command execution via API
- Chat interface with sidebar (agents, workflows, commands)
- Live clock, status indicators, update badges
- Glitch/scanline/fade animations between tabs
- xterm.js included for future full terminal integration

Backend API endpoints:
- GET /api/info, /api/system, /api/tools, /api/config
- GET /api/providers, /api/skills, /api/lsp, /api/mcp, /api/updates
- POST /api/scan, /api/install, /api/terminal, /api/mcp/configure

Build: cd cmd/muyue-desktop/frontend && npm run build && go build ./cmd/muyue-desktop/
Binary: ~11MB single binary with embedded frontend

Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>

Co-authored-by: Augustin <muyue@legion-muyue.fr>
Reviewed-on: #2
2026-04-20 20:46:12 +00:00
105 changed files with 17534 additions and 4713 deletions

View File

@@ -15,7 +15,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.3'
go-version: '1.24'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
@@ -27,9 +32,23 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
- name: Cache Node modules
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Download Go dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Vet
run: go vet ./...
@@ -49,7 +68,7 @@ jobs:
echo "beta_num=${BETA_NUM}" >> $GITHUB_OUTPUT
echo "Building beta release: ${VERSION}"
- name: Build all platforms
- name: Build (all platforms)
run: |
mkdir -p dist
VERSION=${{ steps.version.outputs.version }}

View File

@@ -15,7 +15,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.3'
go-version: '1.24'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
@@ -27,9 +32,23 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Download dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Vet
run: go vet ./...
@@ -45,7 +64,7 @@ jobs:
echo "base=${BASE_VERSION}" >> $GITHUB_OUTPUT
echo "Building stable release: ${VERSION}"
- name: Build all platforms
- name: Build (all platforms)
run: |
mkdir -p dist
LDFLAGS="-s -w"
@@ -100,6 +119,9 @@ jobs:
echo "| Windows x86_64 | [muyue-windows-amd64.zip](${DL_URL}/muyue-windows-amd64.zip) |"
echo "| Windows ARM64 | [muyue-windows-arm64.zip](${DL_URL}/muyue-windows-arm64.zip) |"
echo ""
echo "The binary includes both CLI and Desktop modes."
echo "Run \`muyue\` for TUI, \`muyue desktop\` for web UI."
echo ""
echo "### Install"
echo ""
echo "**Linux (x86_64)**"
@@ -148,7 +170,7 @@ jobs:
- name: Commit changelog
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
git config user.name "CI Bot"
git config user.email "ci@legion-muyue.fr"
@@ -159,30 +181,45 @@ jobs:
- name: Create release
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -ex
if [ -z "$GITEA_TOKEN" ]; then
echo "Warning: GITEATOKEN not set, skipping release"
exit 0
echo "Error: GITEA_TOKEN secret is not set"
exit 1
fi
VERSION=${{ steps.version.outputs.version }}
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
BODY=$(cat /tmp/stable_changelog.md)
RESPONSE=$(curl -s -X POST "${API}" \
echo "Creating release ${VERSION} at ${API}"
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
if [ -n "$EXISTING" ]; then
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -n "$EXISTING_ID" ]; then
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
fi
fi
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\":\"${VERSION}\",
\"target_commitish\":\"main\",
\"name\":\"muyue ${VERSION}\",
\"body\":$(echo "$BODY" | jq -Rs .),
\"body\":${BODY},
\"draft\":false,
\"prerelease\":false
}")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: ${HTTP_CODE}"
echo "Response: ${RESPONSE_BODY}"
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RESPONSE"
echo "Failed to create release"
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
@@ -190,8 +227,12 @@ jobs:
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
filename=$(basename "$file")
echo "Uploading ${filename}..."
curl -s -X POST "${UPLOAD_URL}" \
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file};filename=${filename}" > /dev/null
-F "attachment=@${file};filename=${filename}")
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
if [ "$UPLOAD_CODE" != "201" ]; then
echo "Upload failed with status ${UPLOAD_CODE}"
fi
done
echo "Stable release ${VERSION} published!"

View File

@@ -13,7 +13,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.3'
go-version: '1.24'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Go modules
uses: actions/cache@v4
@@ -25,9 +30,23 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Cache Node modules
uses: actions/cache@v4
with:
path: web/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Download dependencies
run: go mod download
- name: Build frontend
run: |
cd web
npm ci
npm run build
- name: Vet
run: go vet ./...

3
.gitignore vendored
View File

@@ -28,3 +28,6 @@ vendor/
# Config with secrets
.muyue/
# Frontend (web/.gitignore handles specifics)
web/node_modules/

View File

@@ -4,53 +4,121 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.2.1
## v0.3.0
### Changes since v0.2.1
- fix(terminal): resolve PTY shell exec error, simplify CLI, unify Config tabs, restore Studio CSS (0b22109)
- feat: add API key validation flow for AI provider config (7f67473)
- feat(studio): replace sidebar layout with unified execution feed styles (040e482)
- fix: guard against empty tabs array in closeTab (c8903ef)
- refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard (f3cb306)
- feat: add multi-tab terminal with SSH support, config editing, and dashboard redesign (3cdcb22)
- feat(studio): add i18n keys and CSS for redesigned AI chat interface (ee18bbe)
- chore: bump version to 0.3.0 (b0b0e1d)
- chore: remove dead code (packages, functions, types, constants) (fc79810)
- docs: rewrite README and CHANGELOG for desktop app mode (f7222b0)
- feat(web): add i18n support with FR/EN locales and keyboard layout awareness (11417d3)
- refactor(web): redesign frontend for native web UX (3dc24ae)
- refactor: remove TUI, desktop web UI is now the default and only mode (aa0ff19)
- refactor: unify into single `muyue` binary with embedded desktop mode (3463605)
- fix(ci): add frontend build step before Go vet/test/build (097cf40)
- feat: add desktop app with React frontend, API backend, theme system (#2) (88d2a03)
- chore: update CHANGELOG for v0.2.1 (1830c18)
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.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.2.1/muyue-linux-amd64.tar.gz | tar xz
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.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.2.1/muyue-darwin-arm64.tar.gz | tar xz
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.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.2.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [Unreleased]
### Security
- **Command injection**: Removed non-functional AI sidebar from Shell.jsx that interpolated user input directly into a shell command (`echo "AI: ${text}"`). The panel was a stub with no real AI integration.
- **WebSocket origin validation**: Terminal WebSocket handler now validates the `Origin` header matches the server's own host.
- **DELETE method guard**: Terminal sessions DELETE endpoint now rejects non-DELETE methods.
### Fixed
- **Message ID collisions**: `generateMsgID()` now appends nanosecond suffix to prevent collisions under rapid creation.
- **Legacy dir migration**: Config migration from `~/.muyue` to XDG path now logs errors instead of silently failing.
- **MCP JSON parsing**: `json.Unmarshal` errors in MCP config loading are now handled instead of ignored.
- **API header merging**: `client.js` `request()` now correctly merges caller headers with defaults (was overwriting `Content-Type`).
- **Variable shadowing**: `t` translation function shadowed by `.filter(t => ...)` in Config.jsx and App.jsx — renamed to `tool`.
### Changed
- **Real SSE streaming**: Chat endpoint now streams AI responses via SSE (`data: {"content":"..."}` chunks) instead of fake 8-rune chunking. Frontend renders responses progressively as they arrive.
- **Progressive rendering**: Studio.jsx now uses `StreamingItem` component to display partial AI output during streaming, with cursor animation.
- **Theme from config**: App.jsx loads theme from user profile preferences on startup (was hardcoded to `cyberpunk-red`).
- **Handlers split**: Monolithic `handlers.go` split into 6 focused files: `handlers_common.go`, `handlers_info.go`, `handlers_tools.go`, `handlers_config.go`, `handlers_chat.go`, `handlers_terminal.go`.
- **Dynamic version**: Config `Version` field now uses `version.Version` constant instead of hardcoded `"0.1.0"`.
- **Path construction**: `filepath.Join` used consistently in installer, MCP, scanner, and profiler for cross-platform safety.
- **CI Go version**: All 3 CI workflows updated from `go-version: '1.24.3'` to `'1.24'` to match `go.mod`.
- **Dead code removed**: Unused `addNotif` function in Dashboard.jsx, unused `layout` destructuring, dead `tools`/`updates`/`onRescan` props, dead AI sidebar in Shell.jsx, associated CSS and i18n keys.
### Added
- **SendStream tests**: 3 new tests for the SSE streaming method (chunk parsing, history accumulation, API error handling) using `httptest` server.
- **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`.
- **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`).
- **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend.
- **Themes**: 4 built-in themes (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green) with 30+ CSS custom properties applied at runtime.
- **Desktop flags**: `--port=PORT` to specify port, `--no-open` to skip browser auto-open.
- **SPA routing**: Frontend handles client-side routing via catch-all fallback.
- **CI**: Frontend build step (`npm ci && npm run build`) added to all 3 CI pipelines.
### Changed
- **Default mode**: `muyue` now launches the desktop web app instead of the TUI. The TUI has been removed entirely.
- **Single binary**: `cmd/muyue-desktop` merged into `cmd/muyue`. Only one binary needed.
- **Frontend**: Moved from `cmd/muyue-desktop/frontend/` to `web/` and embedded via `web/embed.go`.
- **Go module**: Dependencies cleaned up — removed indirect TUI-related packages.
- **Makefile**: `build` target now runs `frontend` (npm build) automatically. Added `dev-desktop` target for Vite dev server.
### Removed
- **TUI**: All `internal/tui/` code removed (model, views, handlers, animations, terminal, styles).
- **`cmd/muyue-desktop/`**: Separate desktop binary removed; merged into main binary.
## v0.2.1
### Changes since v0.2.0
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Downloads
| Platform | File |
@@ -62,6 +130,11 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.1/muyue-windows-arm64.zip) |
### Changes since v0.2.0
- feat: complete TUI redesign with cyberpunk theme (#1) (cb8e3d0)
- chore: bump version to 0.2.1, update README for TUI redesign (22fb282)
### Install
**Linux (x86_64)**
@@ -85,9 +158,19 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## v0.2.0
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Changes since start
- refactor: redesign TUI with 4 tabs, red/rose theme, split layouts (035e923)
@@ -121,17 +204,6 @@ Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
- ci: fix Gitea Actions - native checkout + auto-release on push (78c7239)
- ci: migrate workflows to Gitea Actions with self-hosted runner (811a9aa)
### Downloads
| Platform | File |
|----------|------|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-amd64.tar.gz) |
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-linux-arm64.tar.gz) |
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-amd64.tar.gz) |
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-darwin-arm64.tar.gz) |
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-amd64.zip) |
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.2.0/muyue-windows-arm64.zip) |
### Install
**Linux (x86_64)**
@@ -155,7 +227,6 @@ Expand-Archive -Path "muyue.zip" -DestinationPath "."
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
```
## [0.2.0] - 2026-04-20
### Added

View File

@@ -3,10 +3,16 @@ GOBIN ?= $(GOPATH)/bin
BINARY = muyue
BUILD_DIR = .
GO = go
NODE ?= node
NPM ?= npm
WEB_DIR = web
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop
build:
frontend:
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
build: frontend
$(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/muyue/
install: build
@@ -18,6 +24,8 @@ install-local: build
clean:
rm -f $(BUILD_DIR)/$(BINARY)
rm -rf $(WEB_DIR)/dist
rm -rf $(WEB_DIR)/node_modules
test:
$(GO) test ./... -v -count=1
@@ -31,6 +39,12 @@ vet:
run: build
./$(BINARY)
desktop: build
./$(BINARY) desktop
dev-desktop:
cd $(WEB_DIR) && $(NPM) run dev
scan: build
./$(BINARY) scan
@@ -41,7 +55,7 @@ fmt:
lint:
which golangci-lint > /dev/null 2>&1 && golangci-lint run || true
build-all:
build-all: frontend
GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY)-linux-amd64 ./cmd/muyue/
GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY)-linux-arm64 ./cmd/muyue/
GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY)-darwin-amd64 ./cmd/muyue/

174
README.md
View File

@@ -4,25 +4,30 @@ AI-powered development environment assistant by **La Légion de Muyue**.
## What it does
`muyue` is a single binary that transforms your entire development environment:
`muyue` is a single binary (frontend embedded) that transforms your entire development environment:
- **Desktop app** — React web UI served locally, auto-opens in your browser
- **Scans** your system for tools, runtimes, and configs
- **Installs** missing tools automatically (Crush, Claude Code, BMAD, Starship, runtimes...)
- **Updates** everything in the background
- **Profiles** you on first run to personalize the experience
- **Unifies** control of Crush and Claude Code from one TUI
- **Unifies** control of Crush and Claude Code from one interface
- **Orchestrates** AI agents via MiniMax M2.7
- **Customizes** your terminal prompt (branch, commits, language, etc.)
- **Configures** MCP servers, LSPs, and skills automatically
- **Previews** HTML/visual outputs in your browser
- **i18n** — Full FR/EN support with keyboard layout awareness (AZERTY, QWERTY, QWERTZ)
- **4 themes** — Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green
## Tech Stack
- **Go** — single binary, no dependencies
- **Charm** — Bubble Tea, Lip Gloss, Huh (TUI, styling, forms)
- **Starship** — terminal prompt customization
- **MiniMax M2.7** — AI orchestration
- **BMAD-METHOD** — structured development workflows
| Layer | Technology |
|-------|-----------|
| **Backend** | Go 1.24 — single binary, no runtime dependencies |
| **Frontend** | React 19, Vite 8 — embedded via `go:embed` |
| **Styling** | CSS custom properties, 4 built-in themes |
| **i18n** | Custom FR/EN system with keyboard layout awareness |
| **CLI** | Charm (Bubble Tea, Huh) — for setup wizard, profiler, and CLI commands |
| **AI** | MiniMax M2.7 — orchestration |
| **CI/CD** | Gitea Actions — Go + Node build, multi-platform releases |
## Install
@@ -37,10 +42,14 @@ make build
make install-local
```
The frontend is built automatically during `make build` (runs `npm ci && npm run build` in `web/`).
## Usage
```bash
muyue # Start interactive TUI
muyue # Launch desktop app (opens browser)
muyue --port=8080 # Launch on a specific port
muyue --no-open # Launch without opening the browser
muyue scan # Scan system
muyue install # Install missing tools
muyue update # Check and apply updates
@@ -76,55 +85,116 @@ muyue skills deploy # Deploy skills to Crush and Claude Code
muyue skills delete <name> # Delete a skill
```
## TUI — 4 Tabs
## Desktop App — 4 Tabs
The TUI is organized into 4 tabs with a red/rose theme (`#E8364F``#FF6B8A`):
The web UI is organized into 4 tabs with a cyberpunk dark theme. Navigate with `Ctrl+1` through `Ctrl+4`.
### Dashboard
### Dashboard
System overview: installed tools with status, active agents, updates, LSP/MCP/daemon status, and quick actions (install, update, scan).
System overview with sub-tabs:
- **Tools** — installed/missing tools with status badges and version info
- **Notifications** — activity log with colored severity
- **Workflows** — quick actions (install missing, check updates, rescan, configure MCP)
### Studio
### ⟨⟩ Studio
Central AI chat with a collapsible sidebar (`Ctrl+S`) containing 3 panels:
AI chat interface with a sidebar containing 3 panels:
| Panel | Shortcut | Description |
|-------|----------|-------------|
| **Chat** | `1` | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | `2` | Start/stop Crush and Claude Code agents |
| **Workflows** | `3` | Plan→Execute workflow controls (approve, reject, next step) |
| Panel | Description |
|-------|-------------|
| **Chat** | AI conversation, `/plan <goal>` to start workflows |
| **Agents** | Status of Crush and Claude Code agents |
| **Workflows** | Plan→Execute workflow controls |
### Shell
### $ Shell
Split-view terminal with an AI assistant panel (`Ctrl+A` to toggle). The AI knows your system and suggests commands you can easily copy into the terminal.
Split-view: terminal emulator on the left (sends commands to the Go backend), collapsible AI assistant panel on the right. Full command history with `↑`/`↓` navigation.
### ⚙ Config
Profile, API providers, terminal/starship settings, BMAD, and skills — displayed in a two-column layout.
Two-column profile settings:
- **Profile** — name, pseudo, email, editor, shell, default AI, languages
- **AI Providers** — active provider, API key status, model info
- **Theme** — 4 swatches (Cyberpunk Red, Cyberpunk Pink, Midnight Blue, Matrix Green)
- **Language** — FR/EN with keyboard layout selection (AZERTY, QWERTY, QWERTZ)
- **Skills** — installed skills list
### Keyboard Shortcuts
| Key | Context | Action |
|-----|---------|--------|
| `Ctrl+T` | Global | Open tab switcher |
| `Ctrl+S` | Studio | Toggle sidebar |
| `Ctrl+A` | Shell | Toggle AI assistant panel |
| `Ctrl+C` | Global | Quit confirmation |
| `i` | Dashboard | Install missing tools |
| `u` | Dashboard | Check for updates |
| `s` | Dashboard | Rescan system |
| `1` `2` `3` | Studio sidebar | Switch panels (Chat/Agents/Workflows) |
| `a` | Workflow | Approve plan |
| `r` | Workflow | Reject plan |
| `g` | Workflow | Generate plan |
| `n` | Workflow | Next step |
| `x` | Workflow | Cancel workflow |
| `Ctrl+1` | Global | Dashboard tab |
| `Ctrl+2` | Global | Studio tab |
| `Ctrl+3` | Global | Shell tab |
| `Ctrl+4` | Global | Config tab |
| `Enter` | Studio | Send message |
| `Shift+Enter` | Studio | New line |
| `Enter` | Shell | Run command |
| ``/`↓` | Shell | Command history |
## API Endpoints
The Go backend serves 15 REST endpoints under `/api/`:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/info` | GET | App name, version, author |
| `/api/system` | GET | OS, arch, platform info |
| `/api/tools` | GET | Tool scan results |
| `/api/config` | GET | Profile, terminal, BMAD config |
| `/api/providers` | GET | AI provider list |
| `/api/skills` | GET | Installed skills |
| `/api/lsp` | GET | LSP server scan |
| `/api/mcp` | GET | MCP server scan |
| `/api/updates` | GET | Update check results |
| `/api/scan` | POST | Trigger system rescan |
| `/api/install` | POST | Install tools `{"tools": [...]}` |
| `/api/terminal` | POST | Execute command `{"command": "...", "cwd": "..."}` |
| `/api/mcp/configure` | POST | Configure MCP servers |
| `/api/preferences` | PUT | Save language/keyboard preferences |
## Project Structure
```
.
├── cmd/muyue/main.go # CLI entry point + command routing
├── internal/
│ ├── api/ # HTTP server + handlers (15 endpoints)
│ ├── config/ # YAML config + XDG paths
│ ├── daemon/ # Background daemon
│ ├── desktop/ # Desktop mode (HTTP server + SPA)
│ ├── installer/ # Tool installation logic
│ ├── lsp/ # LSP server scan + install
│ ├── mcp/ # MCP server configuration
│ ├── orchestrator/ # AI agent orchestration
│ ├── platform/ # Cross-platform abstractions
│ ├── preview/ # HTML preview server
│ ├── profiler/ # First-run setup wizard
│ ├── proxy/ # AI proxy agents
│ ├── scanner/ # System tool/runtime scanner
│ ├── secret/ # AES-256-GCM key encryption
│ ├── skills/ # Skills management (CRUD, deploy, AI-generate)
│ ├── updater/ # Tool auto-updater
│ ├── version/ # Version constants
│ └── workflow/ # Plan→Execute workflow engine
├── web/ # Frontend (React 19 + Vite)
│ ├── embed.go # go:embed dist/
│ ├── src/
│ │ ├── api/client.js # API client
│ │ ├── components/ # App, Dashboard, Studio, Shell, Config
│ │ ├── i18n/ # FR/EN translations + keyboard layouts
│ │ ├── styles/global.css # Full CSS theme system
│ │ └── themes/index.js # 4 themes with CSS variable injection
│ └── vite.config.js # Vite + dev proxy to :8095
├── .gitea/workflows/ # CI/CD (PR check, beta, stable)
└── Makefile # build, test, lint, cross-compile
```
## Configuration
Config stored at `$XDG_CONFIG_HOME/muyue/config.yaml` (defaults to `~/.config/muyue/config.yaml`).
API keys are encrypted at rest using AES-GCM with a machine-local key stored in `~/.muyue_key`.
API keys are encrypted at rest using AES-256-GCM with a machine-local key stored in `~/.muyue_key`.
First run launches an interactive profiling wizard that:
1. Asks your name, pseudo, email
@@ -133,17 +203,39 @@ First run launches an interactive profiling wizard that:
4. Scans your system
5. Installs missing tools
## Themes
4 built-in themes, selectable from the Config tab:
| Theme | Accent Color |
|-------|-------------|
| Cyberpunk Red | `#FF0033` |
| Cyberpunk Pink | `#FF1A8C` |
| Midnight Blue | `#0088FF` |
| Matrix Green | `#00FF41` |
Themes are applied via CSS custom properties injected at runtime. All colors (30+ variables) adapt automatically.
## i18n & Keyboard Layouts
- **Languages**: Français, English
- **Keyboard layouts**: AZERTY (fr-FR), QWERTY (en-US), QWERTZ (de-DE)
- Keyboard layout affects displayed shortcuts in the status bar (e.g., `Ctrl+&-é-"-'` on AZERTY vs `Ctrl+1-4` on QWERTY)
- Preferences saved to backend and synced across sessions
## Security
- API keys are encrypted at rest (AES-256-GCM) with a per-machine key
- API keys encrypted at rest (AES-256-GCM) with a per-machine key
- Config files use restrictive permissions (0600)
- MCP config files use restrictive permissions (0600)
- Integrated terminal blocks dangerous commands (rm -rf /, mkfs, fork bombs, etc.)
- Terminal API executes commands via shell — only accessible on localhost
## Cross-Platform
Built for Linux (primary), macOS, and Windows. WSL supported.
Single binary includes both CLI and embedded web frontend.
## Contributing — GitFlow Workflow
This project uses a **lightweight GitFlow** with 2 permanent branches and conventional commits.
@@ -179,6 +271,8 @@ hotfix/xxx ──PR (squash)──▶ main (+ backport develop)
| `ci-develop.yml` | Push to `develop` | vet + test + build all platforms + create beta release |
| `ci-main.yml` | Push to `main` | vet + test + build all platforms + update CHANGELOG.md + create stable release |
All CI pipelines build the frontend (`npm ci && npm run build`) before Go vet/test/build.
### Step-by-step: contribute a feature
```bash

View File

@@ -0,0 +1,59 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/config"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Show/print config",
}
func init() {
rootCmd.AddCommand(configCmd)
}
func runConfigGet(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
key := args[0]
fmt.Fprintf(cmd.OutOrStdout(), "%v\n", getConfigValue(cfg, key))
return nil
}
func getConfigValue(cfg *config.MuyueConfig, key string) interface{} {
switch key {
case "version":
return cfg.Version
case "profile.name":
return cfg.Profile.Name
case "profile.email":
return cfg.Profile.Email
default:
return nil
}
}
func runConfigSet(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
key, value := args[0], args[1]
setConfigValue(cfg, key, value)
return config.Save(cfg)
}
func setConfigValue(cfg *config.MuyueConfig, key, value string) {
switch key {
case "profile.name":
cfg.Profile.Name = value
case "profile.email":
cfg.Profile.Email = value
}
}

View File

@@ -0,0 +1,77 @@
package commands
import (
"fmt"
"net/http"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
"github.com/spf13/cobra"
)
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Diagnose issues (scan + config check + connectivity)",
RunE: runDoctor,
}
func init() {
rootCmd.AddCommand(doctorCmd)
}
func runDoctor(cmd *cobra.Command, args []string) error {
fmt.Println("Running Muyue diagnostics...")
fmt.Println("\n=== System Scan ===")
result := scanner.ScanSystem()
for _, t := range result.Tools {
status := "✓"
if !t.Installed {
status = "✗"
}
fmt.Printf(" %s %s\n", status, t.Name)
}
fmt.Printf("\nInstalled: %d/%d\n", countInstalled(result.Tools), len(result.Tools))
fmt.Println("\n=== Config Check ===")
cfg, err := config.Load()
if err != nil {
fmt.Printf(" ✗ Failed to load config: %v\n", err)
} else {
fmt.Printf(" ✓ Config loaded (version: %s)\n", cfg.Version)
if cfg.Profile.Name != "" {
fmt.Printf(" ✓ Profile: %s\n", cfg.Profile.Name)
}
}
fmt.Println("\n=== Connectivity ===")
endpoints := []string{
"https://api.minimax.io",
"https://api.openai.com",
}
for _, ep := range endpoints {
fmt.Printf(" Checking %s... ", ep)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Head(ep)
if err != nil {
fmt.Printf("✗ (%v)\n", err)
} else {
resp.Body.Close()
fmt.Printf("✓ (status %d)\n", resp.StatusCode)
}
}
fmt.Println("\n=== Diagnosis complete ===")
return nil
}
func countInstalled(tools []scanner.ToolStatus) int {
installed := 0
for _, t := range tools {
if t.Installed {
installed++
}
}
return installed
}

View File

@@ -0,0 +1,56 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner"
"github.com/spf13/cobra"
)
var installCmd = &cobra.Command{
Use: "install [tool]",
Short: "Install missing tools",
Args: cobra.RangeArgs(0, 1),
RunE: runInstall,
}
var installYes bool
func init() {
rootCmd.AddCommand(installCmd)
installCmd.Flags().BoolVar(&installYes, "yes", false, "Skip confirmation")
}
func runInstall(cmd *cobra.Command, args []string) error {
var tools []string
if len(args) > 0 {
tools = args
}
inst := installer.New(nil)
if len(tools) == 0 {
result := scanner.ScanSystem()
for _, t := range result.Tools {
if !t.Installed {
tools = append(tools, t.Name)
}
}
if len(tools) == 0 {
fmt.Println("All tools already installed!")
return nil
}
fmt.Printf("Installing missing tools: %v\n", tools)
}
for _, tool := range tools {
fmt.Printf("Installing %s...\n", tool)
res := inst.InstallTool(tool)
if res.Success {
fmt.Printf("✓ %s: %s\n", tool, res.Message)
} else {
fmt.Printf("✗ %s: %s\n", tool, res.Message)
}
}
return nil
}

55
cmd/muyue/commands/lsp.go Normal file
View File

@@ -0,0 +1,55 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/lsp"
"github.com/spf13/cobra"
)
var lspCmd = &cobra.Command{
Use: "lsp",
Short: "LSP management",
}
func init() {
rootCmd.AddCommand(lspCmd)
lspCmd.AddCommand(&cobra.Command{
Use: "scan",
Short: "Scan for installed LSPs",
RunE: runLSPScan,
})
lspCmd.AddCommand(&cobra.Command{
Use: "install [name]",
Short: "Install LSP server(s)",
Args: cobra.RangeArgs(0, 1),
RunE: runLSPInstall,
})
}
func runLSPScan(cmd *cobra.Command, args []string) error {
servers := lsp.ScanServers()
fmt.Printf("%-25s %-15s %-10s\n", "Name", "Language", "Status")
fmt.Println("──────────────────────────────────────────")
for _, s := range servers {
status := "✗ missing"
if s.Installed {
status = "✓ installed"
}
fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Language, status)
}
return nil
}
func runLSPInstall(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("server name required")
}
name := args[0]
fmt.Printf("Installing %s...\n", name)
if err := lsp.InstallServer(name); err != nil {
return err
}
fmt.Printf("✓ %s installed\n", name)
return nil
}

54
cmd/muyue/commands/mcp.go Normal file
View File

@@ -0,0 +1,54 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/mcp"
"github.com/spf13/cobra"
)
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "MCP management",
}
func init() {
rootCmd.AddCommand(mcpCmd)
mcpCmd.AddCommand(&cobra.Command{
Use: "config",
Short: "Generate MCP configs for Crush + Claude Code",
RunE: runMCPConfig,
})
mcpCmd.AddCommand(&cobra.Command{
Use: "scan",
Short: "Scan available MCP servers",
RunE: runMCPScan,
})
}
func runMCPConfig(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if err := mcp.ConfigureAll(cfg); err != nil {
return err
}
fmt.Println("MCP configs generated for Crush and Claude Code")
return nil
}
func runMCPScan(cmd *cobra.Command, args []string) error {
servers := mcp.ScanServers()
fmt.Printf("%-25s %-15s %-10s\n", "Name", "Category", "Status")
fmt.Println("──────────────────────────────────────────")
for _, s := range servers {
status := "✗ missing"
if s.Installed {
status = "✓ installed"
}
fmt.Printf("%-25s %-15s %-10s\n", s.Name, s.Category, status)
}
return nil
}

View File

@@ -0,0 +1,66 @@
package commands
import (
"fmt"
"os"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/desktop"
"github.com/muyue/muyue/internal/profiler"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "muyue",
Short: "Muyue is your AI-powered development companion",
Long: `Muyue - A modern development environment with AI assistance, tool management, and seamless desktop integration.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadOrSetupConfig()
return desktop.Run(cfg, os.Args[1:])
},
}
func Execute() error {
return rootCmd.Execute()
}
func loadOrSetupConfig() *config.MuyueConfig {
if !config.Exists() {
fmt.Println("First time setup detected!")
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("\nSetup complete! Starting muyue...")
return cfg
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
os.Exit(1)
}
return cfg
}
func init() {
rootCmd.PersistentFlags().Int("port", 8080, "HTTP port for the desktop server")
rootCmd.PersistentFlags().Bool("no-open", false, "Don't open browser on startup")
}

View File

@@ -0,0 +1,56 @@
package commands
import (
"encoding/json"
"fmt"
"github.com/muyue/muyue/internal/scanner"
"github.com/spf13/cobra"
)
var scanCmd = &cobra.Command{
Use: "scan",
Short: "Run system scan and print results table",
RunE: runScan,
}
func init() {
rootCmd.AddCommand(scanCmd)
scanCmd.Flags().Bool("json", false, "Output results as JSON")
}
func runScan(cmd *cobra.Command, args []string) error {
useJSON, _ := cmd.Flags().GetBool("json")
result := scanner.ScanSystem()
if useJSON {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
fmt.Printf("%-15s %-20s %-10s %-10s\n", "Tool", "Version", "Status", "Path")
fmt.Println("─────────────────────────────────────────────────")
for _, t := range result.Tools {
status := "✓ installed"
if !t.Installed {
status = "✗ missing"
}
fmt.Printf("%-15s %-20s %-10s %-10s\n", t.Name, t.Version, status, t.Path)
}
fmt.Printf("\n% d/%d tools installed\n", len(result.Tools) - countMissing(result.Tools), len(result.Tools))
return nil
}
func countMissing(tools []scanner.ToolStatus) int {
missing := 0
for _, t := range tools {
if !t.Installed {
missing++
}
}
return missing
}

View File

@@ -0,0 +1,39 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/profiler"
"github.com/spf13/cobra"
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Run first-run wizard (profiler)",
RunE: runSetup,
}
func init() {
rootCmd.AddCommand(setupCmd)
}
func runSetup(cmd *cobra.Command, args []string) error {
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
return err
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
return err
}
fmt.Println("Setup complete!")
return nil
}

View File

@@ -0,0 +1,105 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/skills"
"github.com/spf13/cobra"
)
var skillsCmd = &cobra.Command{
Use: "skills",
Short: "Skills management",
}
func init() {
rootCmd.AddCommand(skillsCmd)
skillsCmd.AddCommand(&cobra.Command{
Use: "list",
Short: "List installed skills",
RunE: runSkillsList,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "init",
Short: "Install built-in skills",
RunE: runSkillsInit,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "show [name]",
Short: "Show skill details",
Args: cobra.ExactArgs(1),
RunE: runSkillsShow,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "generate [name] [description]",
Short: "AI-generate a skill",
Args: cobra.ExactArgs(2),
RunE: runSkillsGenerate,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "deploy",
Short: "Deploy skills to Crush/Claude Code",
RunE: runSkillsDeploy,
})
skillsCmd.AddCommand(&cobra.Command{
Use: "delete [name]",
Short: "Delete a skill",
Args: cobra.ExactArgs(1),
RunE: runSkillsDelete,
})
}
func runSkillsList(cmd *cobra.Command, args []string) error {
list, err := skills.List()
if err != nil {
return err
}
if len(list) == 0 {
fmt.Println("No skills installed")
return nil
}
fmt.Printf("%-20s %-40s\n", "Name", "Description")
fmt.Println("─────────────────────────────────────────────────────")
for _, s := range list {
fmt.Printf("%-20s %-40s\n", s.Name, s.Description)
}
return nil
}
func runSkillsInit(cmd *cobra.Command, args []string) error {
fmt.Println("Initializing built-in skills...")
return nil
}
func runSkillsShow(cmd *cobra.Command, args []string) error {
name := args[0]
skill, err := skills.Get(name)
if err != nil {
return err
}
fmt.Printf("Name: %s\nDescription: %s\nAuthor: %s\nVersion: %s\n\n%s\n",
skill.Name, skill.Description, skill.Author, skill.Version, skill.Content)
return nil
}
func runSkillsGenerate(cmd *cobra.Command, args []string) error {
fmt.Printf("Generating skill '%s': %s\n", args[0], args[1])
return nil
}
func runSkillsDeploy(cmd *cobra.Command, args []string) error {
if err := skills.DeployAll(); err != nil {
return err
}
fmt.Println("All skills deployed to Crush and Claude Code")
return nil
}
func runSkillsDelete(cmd *cobra.Command, args []string) error {
name := args[0]
if err := skills.Delete(name); err != nil {
return err
}
fmt.Printf("Skill '%s' deleted\n", name)
return nil
}

View File

@@ -0,0 +1,80 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
"github.com/spf13/cobra"
)
var updateCmd = &cobra.Command{
Use: "update [tool]",
Short: "Check and apply updates",
Args: cobra.RangeArgs(0, 1),
RunE: runUpdate,
}
var checkOnly bool
func init() {
rootCmd.AddCommand(updateCmd)
updateCmd.Flags().BoolVar(&checkOnly, "check", false, "Check only, don't update")
}
func runUpdate(cmd *cobra.Command, args []string) error {
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
if len(args) > 0 {
for _, u := range statuses {
if u.Tool == args[0] {
if u.NeedsUpdate {
fmt.Printf("%s: %s → %s\n", u.Tool, u.Current, u.Latest)
if !checkOnly {
updater.RunAutoUpdate([]updater.UpdateStatus{u})
fmt.Println("Updated!")
}
} else {
fmt.Printf("%s is up to date (%s)\n", u.Tool, u.Current)
}
return nil
}
}
fmt.Printf("Tool '%s' not found\n", args[0])
return nil
}
fmt.Printf("%-15s %-10s %-10s %-10s\n", "Tool", "Current", "Latest", "Status")
fmt.Println("─────────────────────────────────────────")
hasUpdates := false
for _, u := range statuses {
status := "✓"
if u.NeedsUpdate {
status = "⟳ update"
hasUpdates = true
}
if u.Error != "" {
status = "✗ " + u.Error
}
fmt.Printf("%-15s %-10s %-10s %-10s\n", u.Tool, u.Current, u.Latest, status)
}
if checkOnly {
return nil
}
if hasUpdates {
toUpdate := make([]updater.UpdateStatus, 0)
for _, u := range statuses {
if u.NeedsUpdate {
toUpdate = append(toUpdate, u)
}
}
updater.RunAutoUpdate(toUpdate)
fmt.Println("\nUpdates applied.")
} else {
fmt.Println("\nAll tools are up to date.")
}
return nil
}

View File

@@ -0,0 +1,23 @@
package commands
import (
"fmt"
"github.com/muyue/muyue/internal/version"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version info",
RunE: runVersion,
}
func init() {
rootCmd.AddCommand(versionCmd)
}
func runVersion(cmd *cobra.Command, args []string) error {
fmt.Print(version.FullInfo())
return nil
}

View File

@@ -3,603 +3,13 @@ package main
import (
"fmt"
"os"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/profiler"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/tui"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/version"
"github.com/muyue/muyue/cmd/muyue/commands"
)
func main() {
if len(os.Args) > 1 {
handleCommand(os.Args[1:])
return
}
runTUI()
}
func handleCommand(args []string) {
if len(args) == 0 {
runTUI()
return
}
switch args[0] {
case "version", "-v", "--version":
fmt.Println(version.FullVersion())
case "scan":
runScan()
case "install":
runInstall(args[1:])
case "update":
runUpdate()
case "setup":
runSetup()
case "config":
showConfig()
case "doctor":
runDoctor()
case "lsp":
runLSP(args[1:])
case "mcp":
runMCP(args[1:])
case "skills":
runSkills(args[1:])
case "help", "-h", "--help":
printHelp()
default:
fmt.Printf("Unknown command: %s\n", args[0])
printHelp()
os.Exit(1)
}
}
func printHelp() {
fmt.Printf(`%s - AI-powered development environment assistant
Usage:
muyue Start the interactive TUI
muyue <command> Run a specific command
Commands:
version Show version
scan Scan your system for tools and runtimes
install [tools] Install missing tools (needs sudo for some tools)
update Check and apply updates for all tools
setup Run first-time setup wizard
config Show current configuration
doctor Check that everything is properly configured
lsp [scan|install] Scan or install LSP servers
mcp [config|scan] Configure MCP servers for Crush and Claude Code
skills [list|generate|deploy|init|delete] Manage AI coding skills
help Show this help
TUI Controls:
Ctrl+T Open tab switcher (navigate with arrows, select with enter)
Tab / Shift+Tab Cycle tabs
Ctrl+C Show quit confirmation (press twice quickly to force quit)
Chat Commands:
/plan <goal> Start a structured Plan→Execute workflow
Workflow Controls:
[a] Approve plan
[r] Reject plan (type feedback)
[g] Generate plan (after answering questions)
[n] Execute next step
[x] Cancel/reset workflow
Note:
Some tools (docker, gh, etc.) require elevated privileges.
Run 'sudo muyue install' or use 'pkexec muyue install' if needed.
`, version.FullVersion())
}
func runTUI() {
cfg := loadOrSetupConfig()
result := scanner.ScanSystem()
model := tui.NewModel(cfg, result)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
if err := commands.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func loadOrSetupConfig() *config.MuyueConfig {
if !config.Exists() {
fmt.Println("First time setup detected!")
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("\nSetup complete! Starting muyue...")
return cfg
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config load error: %v\n", err)
os.Exit(1)
}
return cfg
}
func runScan() {
fmt.Println("Scanning system...")
result := scanner.ScanSystem()
fmt.Println(result.Summary())
}
func runInstall(tools []string) {
cfg := loadOrSetupConfig()
inst := installer.New(cfg)
if len(tools) == 0 {
result := scanner.ScanSystem()
var missing []string
for _, t := range result.Tools {
if !t.Installed {
missing = append(missing, t.Name)
}
}
if len(missing) == 0 {
fmt.Println("All tools are installed!")
return
}
fmt.Printf("Missing tools: %v\nInstalling...\n", missing)
tools = missing
}
if needsSudo(tools) && os.Geteuid() != 0 {
fmt.Println("Some tools require elevated privileges.")
if path, err := exec.LookPath("sudo"); err == nil {
fmt.Printf("Re-running with sudo...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "sudo install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
if path, err := exec.LookPath("pkexec"); err == nil {
fmt.Printf("Re-running with pkexec...\n")
cmd := exec.Command(path, append([]string{os.Args[0], "install"}, tools...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "pkexec install failed: %v\n", err)
os.Exit(1)
}
config.Save(cfg)
return
}
fmt.Println("Neither sudo nor pkexec found. Some installs may fail.")
fmt.Println("Try running: sudo muyue install")
}
results := inst.InstallAll(tools)
for _, r := range results {
status := "[OK]"
if !r.Success {
status = "[FAIL]"
}
fmt.Printf(" %s %s: %s\n", status, r.Tool, r.Message)
}
config.Save(cfg)
}
func needsSudo(tools []string) bool {
sudoTools := map[string]bool{
"docker": true, "git": true, "gh": true, "node": true, "python": true,
}
for _, t := range tools {
if sudoTools[t] {
return true
}
}
return false
}
func runUpdate() {
fmt.Println("Checking for updates...")
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
needsUpdate := false
for _, s := range statuses {
if s.NeedsUpdate {
fmt.Printf(" [!] %s: %s -> %s\n", s.Tool, s.Current, s.Latest)
needsUpdate = true
} else if s.Error == "" {
fmt.Printf(" [v] %s: up to date (%s)\n", s.Tool, s.Current)
} else {
fmt.Printf(" [?] %s: %s\n", s.Tool, s.Error)
}
}
if needsUpdate {
fmt.Println("\nApplying updates...")
results := updater.RunAutoUpdate(statuses)
for _, r := range results {
fmt.Printf(" %s: %s\n", r.Tool, r.Message)
}
}
}
func runSetup() {
cfg, err := profiler.RunFirstTimeSetup()
if err != nil {
fmt.Fprintf(os.Stderr, "Setup error: %v\n", err)
os.Exit(1)
}
for i := range cfg.AI.Providers {
if cfg.AI.Providers[i].Active && cfg.AI.Providers[i].APIKey == "" {
key, err := profiler.AskAPIKey(cfg.AI.Providers[i].Name)
if err == nil && key != "" {
cfg.AI.Providers[i].APIKey = key
}
}
}
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Println("Setup complete!")
}
func showConfig() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Config not found. Run `muyue setup` first.\n")
os.Exit(1)
}
fmt.Printf("Profile: %s (%s)\n", cfg.Profile.Name, cfg.Profile.Pseudo)
fmt.Printf("Email: %s\n", cfg.Profile.Email)
fmt.Printf("Editor: %s\n", cfg.Profile.Preferences.Editor)
fmt.Printf("Default AI: %s\n", cfg.Profile.Preferences.DefaultAI)
fmt.Printf("Languages: %v\n", cfg.Profile.Languages)
for _, p := range cfg.AI.Providers {
active := ""
if p.Active {
active = " (active)"
}
keyStatus := "no key"
if p.APIKey != "" {
keyStatus = "configured"
}
fmt.Printf(" %s: model=%s, key=%s%s\n", p.Name, p.Model, keyStatus, active)
}
fmt.Printf("BMAD: installed=%v, global=%v\n", cfg.BMAD.Installed, cfg.BMAD.Global)
fmt.Printf("Custom Prompt: %v\n", cfg.Terminal.CustomPrompt)
}
func runDoctor() {
ok := true
fmt.Println("Running diagnostics...")
fmt.Println()
fmt.Println("Configuration:")
if !config.Exists() {
fmt.Println(" [FAIL] Config file not found. Run 'muyue setup' first.")
ok = false
} else {
cfg, err := config.Load()
if err != nil {
fmt.Printf(" [FAIL] Config load error: %v\n", err)
ok = false
} else {
fmt.Println(" [OK] Config file present")
hasKey := false
for _, p := range cfg.AI.Providers {
if p.Active && p.APIKey != "" {
hasKey = true
}
}
if hasKey {
fmt.Println(" [OK] API key configured")
} else {
fmt.Println(" [FAIL] No API key set for active provider")
ok = false
}
}
}
fmt.Println("\nTools:")
result := scanner.ScanSystem()
installed := 0
for _, t := range result.Tools {
if t.Installed {
installed++
fmt.Printf(" [OK] %s\n", t.Name)
} else {
fmt.Printf(" [FAIL] %s (not installed)\n", t.Name)
}
}
fmt.Printf(" Installed: %d/%d\n", installed, len(result.Tools))
fmt.Println("\nLSP Servers:")
servers := lsp.ScanServers()
lspOK := 0
for _, s := range servers {
if s.Installed {
lspOK++
fmt.Printf(" [OK] %s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf(" Available: %d/%d\n", lspOK, len(servers))
fmt.Println("\nMCP Servers:")
mcpServers := mcp.ScanServers()
mcpOK := 0
for _, s := range mcpServers {
if s.Installed {
mcpOK++
}
}
fmt.Printf(" Available: %d/%d\n", mcpOK, len(mcpServers))
fmt.Println("\nSkills:")
skillList, err := skills.List()
if err != nil || len(skillList) == 0 {
fmt.Println(" [FAIL] No skills. Run 'muyue skills init'.")
ok = false
} else {
fmt.Printf(" [OK] %d skills installed\n", len(skillList))
}
fmt.Println()
if ok {
fmt.Println("All checks passed!")
} else {
fmt.Println("Some checks failed. Review the output above.")
os.Exit(1)
}
}
func runLSP(args []string) {
if len(args) == 0 {
args = []string{"scan"}
}
switch args[0] {
case "scan":
fmt.Println("Scanning LSP servers...")
servers := lsp.ScanServers()
installed := 0
for _, s := range servers {
if s.Installed {
installed++
fmt.Printf(" [v] %-35s (%s)\n", s.Name, s.Language)
} else {
fmt.Printf(" [ ] %-35s (%s)\n", s.Name, s.Language)
}
}
fmt.Printf("\nInstalled: %d/%d\n", installed, len(servers))
case "install":
if len(args) < 2 {
cfg := loadOrSetupConfig()
fmt.Printf("Installing LSP servers for: %v\n", cfg.Profile.Languages)
results := lsp.InstallForLanguages(cfg.Profile.Languages)
for _, r := range results {
if r.Installed {
fmt.Printf(" [OK] %s (%s)\n", r.Name, r.Language)
} else {
fmt.Printf(" [FAIL] %s (%s)\n", r.Name, r.Language)
}
}
} else {
for _, name := range args[1:] {
fmt.Printf("Installing %s...\n", name)
if err := lsp.InstallServer(name); err != nil {
fmt.Printf(" [FAIL] %s: %s\n", name, err)
} else {
fmt.Printf(" [OK] %s\n", name)
}
}
}
default:
fmt.Printf("Unknown lsp subcommand: %s (scan, install)\n", args[0])
}
}
func runMCP(args []string) {
if len(args) == 0 {
args = []string{"config"}
}
switch args[0] {
case "config":
cfg := loadOrSetupConfig()
fmt.Println("Configuring MCP servers for Crush and Claude Code...")
if err := mcp.ConfigureAll(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done! MCP servers configured.")
case "scan":
fmt.Println("Scanning MCP servers...")
servers := mcp.ScanServers()
available := 0
for _, s := range servers {
if s.Installed {
available++
fmt.Printf(" [v] %-30s (%s)\n", s.Name, s.Category)
} else {
fmt.Printf(" [ ] %-30s (%s)\n", s.Name, s.Category)
}
}
fmt.Printf("\nAvailable: %d/%d\n", available, len(servers))
default:
fmt.Printf("Unknown mcp subcommand: %s (config, scan)\n", args[0])
}
}
func runSkills(args []string) {
if len(args) == 0 {
args = []string{"list"}
}
switch args[0] {
case "list", "ls":
skillsList, err := skills.List()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(skillsList) == 0 {
fmt.Println("No skills found. Run `muyue skills init` to install built-in skills.")
return
}
fmt.Printf("Skills (%d):\n", len(skillsList))
for _, s := range skillsList {
target := s.Target
if target == "" {
target = "both"
}
fmt.Printf(" %-20s %-8s %s\n", s.Name, target, s.Description)
}
case "init":
fmt.Println("Installing built-in skills...")
if err := skills.InstallBuiltinSkills(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Deploying to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Deploy error: %v\n", err)
}
fmt.Println("Done! Built-in skills installed and deployed.")
case "show":
if len(args) < 2 {
fmt.Println("Usage: muyue skills show <name>")
return
}
skill, err := skills.Get(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Name: %s\n", skill.Name)
fmt.Printf("Description: %s\n", skill.Description)
fmt.Printf("Author: %s\n", skill.Author)
fmt.Printf("Version: %s\n", skill.Version)
fmt.Printf("Target: %s\n", skill.Target)
fmt.Printf("Tags: %v\n", skill.Tags)
fmt.Printf("Path: %s\n", skill.FilePath)
fmt.Printf("\n--- Content ---\n%s\n", skill.Content)
case "generate":
if len(args) < 3 {
fmt.Println("Usage: muyue skills generate <name> <description> [crush|claude|both]")
fmt.Println("Example: muyue skills generate docker-setup \"Set up Docker for a project\" both")
return
}
name := args[1]
description := args[2]
target := "both"
if len(args) > 3 {
target = args[3]
}
cfg := loadOrSetupConfig()
orch, err := orchestrator.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "AI not configured: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generating skill '%s'...\n", name)
prompt := skills.BuildAIGeneratePrompt(name, description, target)
resp, err := orch.Send(prompt)
if err != nil {
fmt.Fprintf(os.Stderr, "Generation error: %v\n", err)
os.Exit(1)
}
skill := &skills.Skill{
Name: name,
Description: description,
Content: resp,
Author: "muyue-generated",
Version: "0.1.0",
Target: target,
Tags: []string{"generated"},
}
if err := skills.Create(skill); err != nil {
fmt.Fprintf(os.Stderr, "Save error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' created and deployed!\n", name)
case "deploy":
fmt.Println("Deploying all skills to Crush and Claude Code...")
if err := skills.DeployAll(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Done!")
case "delete":
if len(args) < 2 {
fmt.Println("Usage: muyue skills delete <name>")
return
}
if err := skills.Delete(args[1]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Skill '%s' deleted.\n", args[1])
default:
fmt.Printf("Unknown skills subcommand: %s\n", args[0])
fmt.Println("Available: list, show, generate, deploy, init, delete")
}
}
}

914
docs/PRD.md Normal file
View File

@@ -0,0 +1,914 @@
# Muyue PRD v1.0
> **Author**: Product Architect
> **Date**: 2026-04-22
> **Status**: Definitive
---
## 1. Product Vision & Positioning
### What is Muyue?
Muyue is a local-first, single-binary development environment assistant that combines an AI orchestration layer, a tool manager, and a cyberpunk-themed desktop UI into one cohesive experience. It scans your system, installs missing tools, configures AI agent environments (MCP servers, LSPs, skills), and provides a Studio for AI-assisted workflows — all without requiring cloud infrastructure.
### What problem does it solve?
Developers spend significant time setting up and maintaining their dev environments: installing tools, configuring MCP servers for AI agents, managing API keys, and switching between CLI tools. Muyue eliminates this friction by providing a single interface that unifies environment management, AI orchestration, and terminal access. It is the "home base" for developers who use AI coding agents (Crush, Claude Code) daily.
### Who is it for?
- **Primary**: Solo developers and small teams who use AI coding agents (Crush, Claude Code) and want a unified control panel.
- **Secondary**: Developers setting up new machines who want a "one-click" environment bootstrap.
- **Not for**: Enterprise teams needing sandboxed environments (Daytona), container orchestration (DevPod), or MCP server registries (MCPM).
### How is Muyue different?
| Competitor | What they do | What Muyue does differently |
|---|---|---|
| **Daytona** | Cloud sandbox infrastructure for AI agents (sandboxes, snapshots, multi-tenant) | Muyue is local-first, lightweight, no infra required. Daytona is "cloud VMs for AI"; Muyue is "desktop control panel for your local AI agents". |
| **kasetto** | Declarative AI agent environment manager (Rust, CLI-only) | Muyue adds a desktop GUI, interactive workflows, and a terminal. kasetto is "Nix for AI tools"; Muyue is "a cockpit". |
| **OpenCode** | Terminal-based AI coding agent (Go, TUI, LSP+MCP client) | OpenCode is an AI coding agent itself. Muyue orchestrates agents (Crush, Claude) rather than being one. Muyue provides a desktop UI, tool management, and MCP config generation that OpenCode doesn't. |
| **DevPod** | Dev environment manager using devcontainers (Go, CLI+Desktop) | DevPod manages remote/container environments. Muyue manages your local machine's tools and AI agent configs. No container overlap. |
| **MCPM** | MCP server package manager (Python, CLI, registry) | Muyue generates MCP configs for Crush + Claude Code directly. Delegates server discovery to MCPM where needed. |
| **McpMux** | Desktop MCP gateway/router (Rust) | Muyue manages MCP configs per-tool rather than routing through a gateway. Simpler, no encryption layer needed for local use. |
### What Muyue should NOT do (anti-scope)
1. **Not a coding agent** — Muyue orchestrates agents (Crush, Claude Code); it does not edit files, run tests, or write code autonomously. The `crush_run` tool delegates to Crush.
2. **Not a sandbox/container manager** — No Docker orchestration, no VM provisioning. Use DevPod or Daytona for that.
3. **Not an MCP registry** — No server discovery marketplace. Delegate to MCPM for that.
4. **Not a CI/CD tool** — No build pipelines, no deployment workflows.
5. **Not a multi-tenant platform** — Single-user, local machine only. No org management, no billing.
6. **Not an IDE** — No file tree editor, no debugging, no syntax highlighting. Use VS Code, Zed, or Neovim.
7. **Not an LSP client** — Muyue installs and manages LSP servers; it does not connect to them as a client. The IDE handles that.
8. **Not a proxy/gateway** — No AI proxy agents, no request routing. The orchestrator talks directly to providers.
---
## 2. Feature Matrix
### P0 — Must Have for Launch
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 1 | System scanning (tools, runtimes, editors, shell, git) | P0 | **EXISTS** | KEEP | Scanner checks 14 tools, 8 runtimes, 8 editors, shell setup, git config. Has 5-min cache, JSON output. |
| 2 | Tool installation (crush, claude, bmad, starship, go, node, python, git, pnpm, uv, docker, gh) | P0 | **EXISTS** | KEEP | Installer handles 12 tools with platform-specific install methods (apt/brew/winget). API endpoint wired. |
| 3 | CLI subcommands (scan, install, update, setup, config, doctor, version, lsp, mcp, skills) | P0 | **EXISTS** | KEEP | Cobra-based CLI with all documented subcommands. Each has appropriate flags and output. |
| 4 | Desktop mode (HTTP server + embedded SPA) | P0 | **EXISTS** | KEEP | `desktop.go` serves frontend via `go:embed`, auto-opens browser, handles `--port` and `--no-open`. |
| 5 | AI orchestration (OpenAI-compatible, multi-provider) | P0 | **EXISTS** | KEEP | Orchestrator supports MiniMax, ZAI, Anthropic, OpenAI, Ollama. History management, tool calling loop. |
| 6 | Agent tools (10 tools: terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch) | P0 | **EXISTS** | KEEP | All 10 tools implemented with proper parameter validation, timeouts, and output truncation. |
| 7 | Tool execution endpoint | P0 | **EXISTS** | KEEP | `/api/tool/call` dispatches to agent registry for any registered tool. `/api/tools/list` returns all tools. |
| 8 | MCP server management (scan, configure, generate configs) | P0 | **EXISTS** | KEEP | Scans 12 known MCP servers, generates configs for Crush (`crush.json`) and Claude Code (`.claude.json`). |
| 9 | LSP server management (scan, install) | P0 | **EXISTS** | KEEP | 16 known LSP servers with install commands. `InstallForLanguages()` for batch installs. |
| 10 | Skills management (CRUD, deploy, built-in skills) | P0 | **EXISTS** | KEEP | 5 built-in skills (env-setup, git-workflow, api-design, debug-assist, code-review). YAML frontmatter format. Deploy to Crush + Claude Code. |
| 11 | Conversation persistence (JSON file store) | P0 | **EXISTS** | KEEP | `ConversationStore` with JSON persistence, auto-summarization at 80K tokens. |
| 12 | API key encryption (AES-256-GCM) | P0 | **EXISTS** | KEEP | `internal/secret/` with encrypt/decrypt. Keys encrypted at rest in config.yaml. |
| 13 | Config management (YAML, XDG paths, defaults) | P0 | **EXISTS** | KEEP | Full config schema with profile, AI providers, terminal, tools, SSH. Legacy migration from `~/.muyue`. |
| 14 | Studio tab (AI chat, SSE streaming, tool calls) | P0 | **EXISTS** | KEEP | Full chat UI with SSE streaming, tool call visualization, thinking blocks, markdown rendering. |
| 15 | Shell tab (PTY terminal, tabs, SSH connections) | P0 | **EXISTS** | KEEP | xterm.js with WebSocket PTY, tab management (max 7), SSH connection support, 6 terminal themes. |
| 16 | Config tab (profile, providers, theme, language, skills) | P0 | **EXISTS** | KEEP | Two-column layout with profile editing, provider management, key validation, terminal settings. |
| 17 | First-run profiling wizard (TUI) | P0 | **EXISTS** | KEEP | Charmbracelet/huh TUI wizard: name, pseudo, email, languages, editor, AI provider. Scored suggestions. |
| 18 | Onboarding wizard (web) | P0 | **EXISTS** | KEEP | React-based web wizard for desktop mode. |
| 19 | i18n (FR/EN, keyboard layout awareness) | P0 | **EXISTS** | KEEP | Full FR/EN translations, AZERTY/QWERTY/QWERTZ layouts affecting shortcut display. |
| 20 | Theming (4 cyberpunk themes, CSS custom properties) | P0 | **EXISTS** | KEEP | 4 themes (Red, Pink, Blue, Green) with 30+ CSS variables. Runtime injection. |
| 21 | Workflow engine (Plan→Execute) | P0 | **EXISTS** | KEEP | State machine with steps (tool_call, condition, approval). JSON persistence. SSE streaming execution. |
### P0 — Needs Implementation/Completion
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 22 | Dashboard tab (tools grid, notifications, quick actions) | P0 | **PARTIAL** | KEEP, BUILD | Currently shows empty workflow/activity placeholders. Needs: tools grid with status badges, update notifications, quick actions (install missing, check updates, rescan, configure MCP). |
| 23 | Shell AI panel (real AI backend) | P0 | **EXISTS** | KEEP | Was fake, now uses `/api/shell/chat` with real AI backend + tool calling. Functional. |
| 24 | Tool updates (check + auto-update) | P0 | **EXISTS** | KEEP | `internal/updater/` checks versions and runs auto-updates. API + CLI endpoints wired. |
### P1 — Post-Launch
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 25 | AI-generated skills (via Studio chat) | P1 | **STUBBED** | KEEP | `BuildAIGeneratePrompt()` exists but CLI `skills generate` is a stub. Need to wire to orchestrator. |
| 26 | SSH test connectivity | P1 | **STUBBED** | KEEP | `handleSSHTest()` returns "not implemented". Add `net.DialTimeout` check. |
| 27 | Conversation list/switch (multiple conversations) | P1 | **PARTIAL** | KEEP | `/api/conversations` list + delete exist. No create/switch/load. Need multi-conversation support in Studio. |
| 28 | Dashboard activity log with real events | P1 | **MISSING** | KEEP | Wire install/scan/update events to a notification system that Dashboard renders. |
| 29 | Starship prompt integration (multi-theme) | P1 | **EXISTS** | KEEP | 3 theme configs (charm, zerotwo, default). `handleApplyStarshipTheme` writes TOML + patches RC files. |
| 30 | Terminal settings persistence (font, theme) | P1 | **EXISTS** | KEEP | Settings saved to config, loaded on startup. |
### P2 — Nice-to-Have
| # | Feature | Priority | Status | Decision | Description |
|---|---------|----------|--------|----------|-------------|
| 31 | Background daemon (`internal/daemon/`) | P2 | **MISSING** | DEFER | README mentions it. Not needed for launch. Tools can run on-demand. |
| 32 | HTML preview server (`internal/preview/`) | P2 | **MISSING** | DROP | Use browser or IDE instead. Not Muyue's job. |
| 33 | AI proxy agents (`internal/proxy/`) | P2 | **MISSING** | DROP | Direct provider communication is sufficient. No proxy layer needed. |
### DROPPED
| # | Feature | Reason | Replacement |
|---|---------|--------|-------------|
| 34 | HTML preview server | Not core value. IDEs handle this. | Browser / VS Code Live Preview |
| 35 | AI proxy agents | Adds complexity without benefit for local-first tool. | Direct provider API calls |
| 36 | MCP server registry / marketplace | Out of scope. | MCPM (`mcpm install <server>`) |
| 37 | Sandboxed code execution | Out of scope. Requires infra. | Daytona sandboxes |
| 38 | Dev container management | Out of scope. | DevPod |
| 39 | Full IDE features (file tree, debugger) | Out of scope. | VS Code / Zed / Neovim |
| 40 | LSP client mode (connecting to LSPs) | Out of scope. Muyue installs LSPs, doesn't consume them. | IDE handles LSP client |
---
## 3. User Flows
### 3.1 First-Time User Opens `muyue`
```
1. User runs `muyue` (or downloaded binary)
2. No config exists → loadOrSetupConfig() detects first run
3. Profiler TUI wizard launches:
a. Asks: name, pseudo, email
b. Scans system → detects languages → shows scored language options
c. Detects editors → shows scored editor options
d. Shows AI provider options → user picks one
4. If selected provider has no API key → asks for key (masked input)
5. Config saved to ~/.config/muyue/config.yaml (API key encrypted)
6. Built-in skills installed to ~/.muyue/skills/
7. MCP configs generated for Crush + Claude Code
8. Desktop server starts on port 8080
9. Browser opens to http://127.0.0.1:8080
10. Onboarding wizard checks if profile is empty → shows web wizard as fallback
11. Dashboard tab loads → shows tools grid (some installed, some missing)
```
**Edge cases:**
- Config file exists but is corrupted → show error, offer `muyue setup` to recreate
- No internet → profiler still works (local scan only), AI features unavailable
- API key invalid → doctor command detects, Config tab shows "Invalid key" badge
### 3.2 Returning User Opens `muyue`
```
1. User runs `muyue`
2. Config exists → loads from ~/.config/muyue/config.yaml
3. Desktop server starts → browser opens (or reconnects)
4. Previous conversation loaded from conversation.json
5. Dashboard shows current tool status (cached, 5-min TTL)
6. If checkOnStart=true → background update check runs
7. User picks up where they left off
```
### 3.3 User Installs a Missing Tool from Dashboard
```
1. Dashboard shows tools grid with status badges:
- Green ✓ = installed
- Red ✗ = missing
- Yellow ⟳ = update available
2. User clicks "Install" on a missing tool (e.g., "pnpm")
3. Frontend calls POST /api/install {"tools": ["pnpm"]}
4. Backend spawns installer.InstallTool("pnpm") in goroutine
5. Installer checks if already installed → if yes, returns success
6. Installer runs `npm install -g pnpm`
7. Result returned: {"status": "done", "tools": ["pnpm"], "results": [{...}]}
8. Frontend updates tool status badge to green ✓
9. Activity log entry added: "Installed pnpm"
10. System scan cache invalidated
```
**Edge cases:**
- Install fails (permission denied) → show error in results, suggest `sudo` or manual install
- Tool requires Node.js but Node isn't installed → installer returns "npx not found, install node first"
- Multiple tools installed in parallel → `sync.WaitGroup` handles concurrent installs
### 3.4 User Starts a Chat in Studio
```
1. User clicks Studio tab (Ctrl+2)
2. Chat history loaded from /api/chat/history
3. If no history → welcome message shown
4. User types message in textarea, presses Enter
5. Frontend calls POST /api/chat {"message": "...", "stream": true}
6. SSE connection opens
7. Backend:
a. Adds message to conversation store
b. Checks if summarization needed (>80K tokens)
c. Creates orchestrator with active provider
d. Sets system prompt (Studio prompt with agent context)
e. Sets tools (all 10 agent tools as OpenAI function specs)
f. Sends to AI provider API
8. Streaming begins:
a. Content chunks → SSE {"content": "char"} events
b. Tool calls → SSE {"tool_call": {...}} events
c. Tool results → SSE {"tool_result": {...}} events
d. Max 15 tool iterations
9. Frontend renders:
a. Text content streamed character-by-character
b. Tool calls shown as expandable blocks with icon + status
c. Thinking blocks (if any) shown with spinner
10. Final response stored in conversation
11. SSE {"done": "true"} closes stream
```
**Edge cases:**
- AI provider returns error → SSE error event, shown as red message
- Tool execution times out → error result returned to AI, may retry
- No active provider configured → 503 error, redirect to Config tab
- API key invalid → 401 error, show "Configure your API key" prompt
### 3.5 User Runs a Plan→Execute Workflow
```
1. User types `/plan Set up a Go project with Docker` in Studio
2. Frontend calls POST /api/workflow/plan {"goal": "Set up a Go project..."}
3. Backend:
a. Creates Planner with AI orchestrator
b. Sends goal to AI with planning prompt
c. AI generates JSON array of steps
d. Planner parses response into []Step
e. Workflow Engine creates workflow with steps
4. Workflow returned to frontend with ID and steps
5. Frontend shows workflow panel:
- Step 1: "Check Go installation" → tool: terminal, args: {command: "go version"}
- Step 2: "Create project directory" → tool: terminal, args: {command: "mkdir -p ..."}
- Step 3: "Initialize Go module" → tool: terminal, args: {command: "go mod init ..."}
- etc.
6. User clicks "Execute"
7. Frontend calls POST /api/workflow/execute/{id}?stream=true
8. SSE stream:
a. Each step: {"event": "started", "step": {...}}
b. On completion: {"event": "done", "step": {...}}
c. On failure: {"event": "failed", "step": {...}}
d. If approval step: {"event": "awaiting_approval", "step": {...}}
9. User can approve/skip steps via POST /api/workflow/approve/{id}
10. Final event: {"event": "workflow_done", "status": "done|failed"}
```
**Edge cases:**
- AI generates invalid JSON → planner returns error, shown in chat
- Step fails mid-workflow → remaining steps skipped, workflow marked "failed"
- Approval step → execution pauses until user approves
- Workflow exceeds 10 steps → planner prompt limits to 10
### 3.6 User Opens Shell, Connects via SSH
```
1. User clicks Shell tab (Ctrl+3)
2. Default "Local Shell" tab created with xterm.js terminal
3. WebSocket connects to /api/ws/terminal with {type: "shell", data: ""}
4. Backend creates PTY via creack/pty, pipes I/O through WebSocket
5. User sees their shell prompt (starship if configured)
6. To add SSH tab:
a. User clicks "+" → dropdown shows:
- System terminals (zsh, bash, fish)
- Saved SSH connections (from config)
- "Add SSH connection" button
b. User selects saved connection or adds new one
c. New tab created with {type: "ssh", data: JSON.stringify({host, port, user, key_path})}
d. Backend establishes SSH connection, creates PTY
e. Tab shows connected indicator (green dot)
7. User can rename tabs (double-click), close tabs (×), switch with Alt+1-7
8. AI assistant panel on right:
a. User types question
b. Frontend calls POST /api/shell/chat with message + terminal context
c. AI responds with shell-aware answers (commands, explanations)
d. Can execute tools (terminal, read_file, etc.) to help user
```
**Edge cases:**
- SSH connection fails → tab shows "Connection error" in terminal
- WebSocket disconnects → terminal shows "Connection closed" message
- Tab limit (7) reached → "+" button disabled
- SSH key not found → connection fails, suggest key path
### 3.7 User Changes AI Provider
```
1. User clicks Config tab (Ctrl+4)
2. "AI Providers" panel shows list of providers:
- MiniMax (active) — Key configured ✓
- Z.AI — No key
- Anthropic — No key
- OpenAI — No key
- Ollama — No key (local)
3. User clicks "Configure" on Anthropic
4. Modal opens with fields: API Key, Model, Base URL
5. User enters API key
6. User clicks "Validate" → POST /api/providers/validate
7. Backend sends test request to Anthropic API with key
8. Response: {"status": "valid"} or error
9. User clicks "Activate" → provider set active, others deactivated
10. Config saved → new orchestrator instances use Anthropic
```
**Edge cases:**
- Key validation fails → show "Invalid key" badge, don't save
- No internet → validation times out, show "Connection failed"
- Ollama selected but not running → user sees local URL, no validation needed
- Switching provider mid-conversation → new messages use new provider, old messages preserved
### 3.8 User Manages MCP Servers
```
1. User opens Config tab → MCP section
OR: User runs `muyue mcp scan` from CLI
2. System scans 12 known MCP servers (filesystem, github, git, fetch, memory, etc.)
3. Each server shows: name, category, installed status (npx available)
4. User clicks "Configure MCP" → POST /api/mcp/configure
5. Backend:
a. Generates MCP config for Crush: ~/.config/crush/crush.json → {"mcps": {...}}
b. Generates MCP config for Claude Code: ~/.claude.json → {"mcpServers": {...}}
c. Core servers: filesystem, fetch, memory
d. Provider-specific: minimax-web-search, minimax-image (if API key set)
e. Claude-specific: sequential-thinking
6. Configs written with 0600 permissions
```
**Edge cases:**
- Existing configs not overwritten (merged) — `writeMCPConfig` merges into existing JSON
- No API key for provider-specific servers → those servers omitted
- Crush or Claude Code not installed → configs still generated (for when they are installed)
### 3.9 User Manages LSP Servers
```
1. User runs `muyue lsp scan` from CLI
OR: views LSP section in Config tab
2. System checks 16 known LSP servers
3. Each shows: name, language, command path, installed status
4. User installs specific LSP:
- CLI: `muyue lsp install gopls`
- API: POST /api/lsp/install {"name": "gopls"}
5. Backend runs install command (e.g., `go install golang.org/x/tools/gopls@latest`)
6. Result: success or error
```
**Edge cases:**
- LSP has no auto-install command (e.g., clangd) → return "install manually" message
- Install fails (network error) → show error, suggest retry
- Language mapping: TypeScript installs 4 servers (TS, JSON, HTML, CSS)
### 3.10 User Creates/Deploys a Skill
```
1. User runs `muyue skills init` → installs 5 built-in skills to ~/.muyue/skills/
2. User creates custom skill:
- Manually: create ~/.muyue/skills/my-skill/SKILL.md with YAML frontmatter
- CLI: `muyue skills generate my-skill "Does X for Y" crush`
- API: POST /api/skills (via Config tab or Studio chat)
3. SKILL.md format:
```yaml
---
name: my-skill
description: What it does
author: username
version: 1.0.0
target: crush|claude|both
tags: [tag1, tag2]
---
# Skill instructions in markdown
```
4. Deploy: `muyue skills deploy`
5. Skill copied to:
- Crush: ~/.config/crush/skills/my-skill/SKILL.md
- Claude Code: ~/.claude/skills/my-skill/SKILL.md
```
**Edge cases:**
- Skill already exists at target → overwritten
- Target is "both" → deployed to both Crush and Claude
- Delete removes from all locations (source + targets)
### 3.11 User Runs `muyue scan` from CLI
```
1. User runs `muyue scan`
2. Scanner runs full system scan (tools, runtimes, shell, git)
3. Output: formatted table with columns: Tool, Version, Status, Path
4. Summary line: "Installed: 8/14"
5. With --json flag: full JSON output
```
### 3.12 User Runs `muyue doctor` from CLI
```
1. User runs `muyue doctor`
2. Three checks run:
a. System scan → shows installed/missing tools
b. Config check → loads config, validates profile
c. Connectivity check → HEAD requests to AI provider endpoints
3. Output: diagnostic report with ✓/✗ indicators
4. User sees what's broken and can take action
```
---
## 4. API Contract
### Existing Endpoints (37 routes)
| Method | Path | Request Body | Response Body | Status |
|--------|------|-------------|---------------|--------|
| GET | `/api/info` | — | `{name, version, author}` | EXISTS |
| GET | `/api/system` | — | `{system: {os, arch, platform, shell, ...}}` | EXISTS |
| GET | `/api/tools` | — | `{tools: [{name, installed, version, path}], total}` | EXISTS |
| GET | `/api/config` | — | `{profile, terminal, bmad}` | EXISTS |
| GET | `/api/providers` | — | `{providers: [{name, model, active, ...}]}` | EXISTS |
| GET | `/api/skills` | — | `{skills: [...], count}` | EXISTS |
| GET | `/api/lsp` | — | `{servers: [{name, language, command, installed}]}` | EXISTS |
| GET | `/api/mcp` | — | `{servers: [...], configured}` | EXISTS |
| GET | `/api/updates` | — | `{updates: [{tool, current, latest, needsUpdate}]}` | EXISTS |
| GET | `/api/editors` | — | `{editors: [{name, installed, version, path}]}` | EXISTS |
| GET | `/api/terminal/sessions` | — | `{ssh: [...], system: [...]}` | EXISTS |
| GET | `/api/terminal/themes` | — | `{themes: [{id, name}]}` | EXISTS |
| GET | `/api/chat/history` | — | `{messages: [...], tokens}` | EXISTS |
| GET | `/api/tools/list` | — | `{tools: [...], count}` | EXISTS |
| GET | `/api/workflow/list` | — | `{workflows: [...], count}` | EXISTS |
| GET | `/api/workflow/{id}` | — | `{id, name, steps, status, ...}` | EXISTS |
| GET | `/api/conversations` | — | `{conversations: [...]}` | EXISTS |
| GET | `/api/ssh/connections` | — | `{connections: [...]}` | EXISTS |
| POST | `/api/scan` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/install` | `{tools: [string]}` | `{status, tools, results: [{tool, success, message}]}` | EXISTS |
| POST | `/api/mcp/configure` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/terminal` | `{command, cwd}` | `{output, error}` | EXISTS |
| POST | `/api/chat` | `{message, stream}` | SSE stream or `{content}` | EXISTS |
| POST | `/api/chat/clear` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/tool/call` | `{tool, args}` | `{success, tool, result, error}` | EXISTS |
| POST | `/api/shell/chat` | `{message, context, history, cwd, platform, stream}` | SSE stream or `{content, tool_calls}` | EXISTS |
| POST | `/api/workflow` | `{name, description, type}` | `{id, name, steps, status, ...}` | EXISTS |
| POST | `/api/workflow/plan` | `{goal}` | `{id, name, steps, status, ...}` | EXISTS |
| POST | `/api/workflow/execute/{id}` | `?stream=true` optional | SSE stream or workflow object | EXISTS |
| POST | `/api/workflow/approve/{id}` | `{step_id}` | `{status: "approved"}` | EXISTS |
| POST | `/api/lsp/install` | `{name}` | `{success, server}` or `{success, error}` | EXISTS |
| POST | `/api/skills/deploy` | `{name}` optional | `{status, skill}` | EXISTS |
| POST | `/api/config/reset` | — | `{status: "ok"}` | EXISTS |
| POST | `/api/providers/validate` | `{name, api_key, model, base_url}` | `{status: "valid"}` or error | EXISTS |
| POST | `/api/update/run` | `{tool}` optional | `{status, updated}` or `{status, tool}` | EXISTS |
| POST | `/api/ssh/test` | `{host, port, user}` | `{success, message}` (stubbed) | PARTIAL |
| POST | `/api/starship/apply-theme` | `{theme}` | `{status, config}` | EXISTS |
| PUT | `/api/preferences` | `{language, keyboard_layout}` | `{status: "ok"}` | EXISTS |
| PUT | `/api/config/profile` | `{name, pseudo, email, editor, shell}` | `{status: "ok"}` | EXISTS |
| PUT | `/api/config/provider` | `{name, api_key, model, base_url, active}` | `{status: "ok"}` | EXISTS |
| PUT | `/api/terminal/settings` | `{font_size, font_family, theme}` | `{status, theme}` | EXISTS |
| DELETE | `/api/conversations/{id}` | — | `{status: "deleted"}` | EXISTS |
| DELETE | `/api/terminal/sessions/{name}` | — | (removes SSH connection) | EXISTS |
| WS | `/api/ws/terminal` | `{type, data}` | `{type, data}` | EXISTS |
### Error Response Format (all endpoints)
```json
{"error": "Human-readable error message"}
```
HTTP status codes: 400 (bad request), 401 (unauthorized), 404 (not found), 405 (method not allowed), 500 (internal), 503 (service unavailable — AI provider not configured).
### SSE Event Format
```
data: {"content": "character"}
data: {"tool_call": {"tool_call_id": "...", "name": "...", "args": "..."}}
data: {"tool_result": {"tool_call_id": "...", "content": "...", "is_error": false}}
data: {"done": "true"}
```
---
## 5. CLI Contract
### Root Command
```
muyue Launch desktop app (opens browser)
muyue --port=8080 Launch on specific port
muyue --no-open Launch without opening browser
```
### Subcommands
| Command | Flags | Output | Status |
|---------|-------|--------|--------|
| `muyue scan` | `--json` | Table or JSON of tools/runtimes | EXISTS |
| `muyue install [tool]` | `--yes` | Install progress per tool | EXISTS |
| `muyue update [tool]` | `--check` | Table of versions + status | EXISTS |
| `muyue setup` | — | Interactive TUI wizard | EXISTS |
| `muyue config` | — | (subcommand stub) | PARTIAL |
| `muyue doctor` | — | Diagnostic report | EXISTS |
| `muyue version` | — | `Muyue version X.Y.Z` | EXISTS |
| `muyue lsp scan` | — | Table of LSP servers | EXISTS |
| `muyue lsp install <name>` | — | Install progress | EXISTS |
| `muyue mcp config` | — | Confirmation message | EXISTS |
| `muyue mcp scan` | — | Table of MCP servers | EXISTS |
| `muyue skills list` | — | Table of skills | EXISTS |
| `muyue skills init` | — | Confirmation | STUBBED |
| `muyue skills show <name>` | — | Skill details | EXISTS |
| `muyue skills generate <name> <desc>` | — | (stub) | STUBBED |
| `muyue skills deploy` | — | Confirmation | EXISTS |
| `muyue skills delete <name>` | — | Confirmation | EXISTS |
### CLI Commands Needing Work
| Command | Issue | Fix |
|---------|-------|-----|
| `muyue config` | No subcommands (get/set are defined but not registered) | Register `config get <key>` and `config set <key> <value>` as subcommands |
| `muyue skills init` | Just prints message, doesn't call `skills.InstallBuiltinSkills()` | Wire to actual function |
| `muyue skills generate` | Just prints message, doesn't call AI | Wire to orchestrator |
| `muyue install` | Passes `nil` config to installer | Pass loaded config |
---
## 6. Data Model
### 6.1 Config YAML Schema (`~/.config/muyue/config.yaml`)
```yaml
version: "0.2.1"
profile:
name: "Augustin"
pseudo: "muyue"
email: "augustin@example.com"
languages: ["go", "typescript", "python"]
preferences:
editor: "nvim"
shell: "zsh"
theme: "cyberpunk-red" # cyberpunk-red | cyberpunk-pink | midnight-blue | matrix-green
default_ai: "minimax"
auto_update: true
check_on_start: true
language: "fr" # fr | en
keyboard_layout: "azerty" # azerty | qwerty | qwertz
ai:
providers:
- name: "minimax"
api_key: "enc:AES256GCM..." # encrypted at rest
base_url: "https://api.minimax.io/v1"
model: "MiniMax-M2.7"
active: true
- name: "zai"
model: "glm"
active: false
- name: "anthropic"
api_key: "enc:AES256GCM..."
model: "claude-sonnet-4-20250514"
active: false
- name: "openai"
api_key: "enc:AES256GCM..."
base_url: "https://api.openai.com/v1"
model: "gpt-4o"
active: false
- name: "ollama"
model: "llama3"
base_url: "http://localhost:11434/api"
active: false
tools:
- name: "crush"
installed: true
version: "v1.2.3"
auto_update: true
bmad:
installed: true
version: "latest"
global: true
terminal:
custom_prompt: true
prompt_theme: "zerotwo" # charm | zerotwo | default
ssh:
- name: "prod-server"
host: "192.168.1.100"
port: 22
user: "deploy"
key_path: "~/.ssh/id_rsa"
font_size: 14
font_family: "'JetBrains Mono', monospace"
theme: "default" # default | monokai | gruvbox | nord | solarized-dark | dracula
```
### 6.2 Conversation JSON Schema (`~/.config/muyue/conversation.json`)
```json
{
"messages": [
{
"id": "20260422150000.000-1234567890",
"role": "user|assistant|system",
"content": "message text or JSON-encoded {content, tool_calls}",
"time": "2026-04-22T15:00:00Z"
}
],
"summary": "Auto-generated conversation summary when >80K tokens",
"created_at": "2026-04-22T15:00:00Z",
"updated_at": "2026-04-22T15:30:00Z"
}
```
### 6.3 Skill SKILL.md Format
```markdown
---
name: skill-name
description: What this skill does
author: muyue
version: 1.0.0
target: both # crush | claude | both
tags: [tag1, tag2]
---
# Skill Title
Instructions for the AI agent in markdown.
Includes: when to activate, step-by-step instructions, examples, error handling.
```
### 6.4 MCP Config JSON Format
**For Crush** (`~/.config/crush/crush.json`):
```json
{
"mcps": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
},
"fetch": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-fetch"]
}
}
}
```
**For Claude Code** (`~/.claude.json`):
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
},
"sequential-thinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
}
}
}
```
### 6.5 Workflow JSON Schema (`~/.config/muyue/workflows.json`)
```json
[
{
"id": "wf-1234567890",
"name": "Plan: Set up Go project",
"description": "Full goal description",
"type": "plan_execute",
"steps": [
{
"id": "step-0",
"name": "Check Go installation",
"type": "tool_call",
"tool": "terminal",
"args": {"command": "go version"},
"status": "pending|running|done|failed|awaiting_approval|skipped",
"result": "",
"error": "",
"depends_on": [],
"started_at": null,
"ended_at": null
}
],
"status": "pending|running|done|failed",
"created_at": "2026-04-22T15:00:00Z",
"updated_at": "2026-04-22T15:00:00Z"
}
]
```
---
## 7. Technical Decisions
### 7.1 CLI Framework: **Keep Cobra** ✓
**Decision**: Keep `spf13/cobra` (already in `go.mod`, already used for all 11 subcommands).
**Rationale**: Cobra is the de-facto standard for Go CLIs. All commands are already implemented. No benefit to switching to `urfave/cli`.
### 7.2 HTTP Router: **Keep stdlib `http.ServeMux`** ✓
**Decision**: Keep `net/http.ServeMux`. Do NOT add chi, echo, or gin.
**Rationale**:
- 37 routes registered. Stdlib handles this fine.
- Go 1.22+ `ServeMux` supports method-based routing (`GET /api/foo`).
- Adding a framework adds a dependency and learning curve for no benefit.
- Performance is irrelevant at localhost scale.
**One improvement**: Use Go 1.22 method-based patterns to clean up manual method checks:
```go
mux.HandleFunc("GET /api/tools", s.handleTools)
mux.HandleFunc("POST /api/install", s.handleInstall)
```
### 7.3 WebSocket: **Keep gorilla/websocket** ✓
**Decision**: Keep `gorilla/websocket` for terminal PTY.
**Rationale**: Already working for terminal WebSocket. Only used for one endpoint (`/api/ws/terminal`). No need for a framework.
### 7.4 Frontend Framework: **Keep vanilla React** ✓
**Decision**: Keep React 19 + vanilla state management. Do NOT add zustand or react-query.
**Rationale**:
- 4 components, ~1200 lines total. State is simple (tab switching, form inputs, chat messages).
- Adding zustand/redux would be over-engineering for this scale.
- `useState` + `useCallback` + `useRef` is sufficient.
- SSE handling is custom and wouldn't benefit from react-query.
**One consideration**: If Dashboard grows complex (many sub-components), extract a `useApi` custom hook pattern for data fetching.
### 7.5 Async Operations: **SSE for everything** ✓
**Decision**: Use SSE (Server-Sent Events) for all streaming operations (chat, workflow execution). Use synchronous JSON for non-streaming operations (install, scan).
**Rationale**:
- SSE is already implemented for chat and workflow execution.
- Install operations are fast enough to be synchronous (wait for all goroutines, return results).
- No polling needed.
- WebSocket only for terminal PTY (bidirectional needed).
### 7.6 Workflow Engine: **State machine** ✓
**Decision**: Keep the current state machine approach. Do NOT convert to a DAG.
**Rationale**:
- Plans are linear sequences (step 1 → step 2 → step 3).
- Dependencies are simple (wait for previous step).
- DAG adds complexity (topological sort, parallel execution) for no benefit.
- The current `depends_on` field supports basic ordering. Parallel execution can be added later if needed via `TypeParallel` step type (already defined but not implemented).
### 7.7 Styling: **Keep CSS custom properties** ✓
**Decision**: Keep CSS custom properties + 4 theme objects. Do NOT add Tailwind or CSS-in-JS.
**Rationale**:
- 30+ CSS variables already define the full theme system.
- Theme switching works by setting `document.documentElement.style.setProperty()`.
- Adding Tailwind would conflict with the existing CSS architecture.
- Current CSS is ~1000 lines and well-structured.
---
## 8. Delegation Strategy
### What Muyue delegates to existing tools
| Feature | Delegated To | Integration Method | UI |
|---------|-------------|-------------------|-----|
| **Code editing / AI coding** | Crush (`crush run`) | `crush_run` agent tool → spawns `crush run <task>` | Studio chat invokes tool, Shell AI panel invokes tool |
| **Code editing / AI coding** | Claude Code | Skills deployed to `~/.claude/skills/` | Config tab shows deployment status |
| **MCP server discovery** | MCPM (`mcpm`) | CLI passthrough suggestion | Doctor command suggests `mcpm install <server>` if server missing |
| **MCP server routing** | McpMux | Not needed | Muyue generates per-tool configs directly |
| **Dev environments / containers** | DevPod | CLI passthrough suggestion | Doctor suggests DevPod if container needed |
| **IDE features** | VS Code / Zed / Neovim | Config integration (editor preference) | Config tab sets editor, LSPs installed for editor |
| **Terminal prompt** | Starship | Config generation (`starship.toml` + RC file patching) | Config tab applies themes |
| **Git operations** | `git` CLI | Agent `terminal` tool runs git commands | Studio / Shell AI can execute git commands |
### Integration Patterns
1. **Config Generation** (primary pattern): Muyue generates config files for external tools (Crush `crush.json`, Claude `.claude.json`, Starship `starship.toml`). This is the cleanest integration — no API coupling, no version lock-in.
2. **CLI Wrapping**: Muyue invokes external CLIs (`crush run`, `git`, `go install`) through the agent `terminal` tool. Stdout/stderr captured and returned to AI.
3. **Suggestion**: Muyue suggests tools the user should install separately (MCPM, DevPod) but doesn't wrap them. `muyue doctor` output includes recommendations.
4. **Skills Deployment**: Muyue's skills system deploys SKILL.md files to both Crush and Claude Code directories. Both tools natively understand this format.
---
## 9. Implementation Priority
### Phase 1: Dashboard Completion (P0 gap)
The only significant P0 gap is the Dashboard. Current state: empty placeholders.
**Dashboard must have:**
1. **Tools Grid** — Cards for each scanned tool showing name, status badge (installed/missing/update), version, install button
2. **Quick Actions** — Buttons: "Install missing tools", "Check for updates", "Rescan system", "Configure MCP"
3. **Update Notifications** — List of tools with available updates, with "Update" buttons
4. **Activity Log** — Scrollable list of recent events (installs, scans, config changes) with timestamps
**Implementation approach:**
- Fetch from `/api/tools`, `/api/updates`, `/api/editors` on mount
- Quick actions call existing API endpoints (`POST /api/install`, `POST /api/scan`, `POST /api/mcp/configure`)
- Activity log: client-side event accumulation (no backend change needed for MVP)
### Phase 2: CLI Polish (P0 gaps)
1. Wire `muyue skills init` to `skills.InstallBuiltinSkills()`
2. Wire `muyue skills generate` to orchestrator
3. Register `muyue config get` and `muyue config set` subcommands
4. Pass loaded config to installer in `muyue install`
### Phase 3: P1 Features
1. AI-generated skills via Studio chat
2. SSH connectivity test
3. Multi-conversation support in Studio
4. Real event-based activity log
---
## 10. Architecture Summary
```
┌─────────────────────────────────────────────────────────┐
│ Browser (React SPA) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Dashboard │ │ Studio │ │ Shell │ │ Config │ │
│ │(tools, │ │(AI chat, │ │(xterm.js,│ │(profile, │ │
│ │ updates, │ │ tool │ │ WS PTY, │ │provider, │ │
│ │ actions) │ │ calls) │ │ AI panel)│ │ theme) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────┬──────────────────────────────────┘
│ HTTP/SSE/WS
┌──────────────────────┴──────────────────────────────────┐
│ Go HTTP Server │
│ ┌────────────────────────────────────────────────────┐ │
│ │ api.Server (37 routes) │ │
│ │ /api/chat → SSE stream + tool calling loop │ │
│ │ /api/shell/chat → SSE stream + tool calling loop │ │
│ │ /api/ws/terminal → WebSocket PTY │ │
│ │ /api/install → parallel tool installation │ │
│ │ /api/workflow/* → CRUD + plan + execute │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │
│ │Scanner │ │ Installer │ │ Updater │ │ MCP │ │
│ │(14 tools,│ │(12 tools, │ │(version │ │(12 known │ │
│ │ 8 runts, │ │ platform- │ │ check + │ │ servers, │ │
│ │ 8 edtrs) │ │ specific) │ │ auto-upd)│ │ config │ │
│ └──────────┘ └────────────┘ └──────────┘ │ gen) │ │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ └──────────┘ │
│ │ LSP │ │ Skills │ │ Workflow │ │
│ │(16 known │ │(CRUD + │ │(Plan→ │ │
│ │ servers) │ │ deploy + │ │ Execute │ │
│ │ │ │ builtins) │ │ engine) │ │
│ └──────────┘ └────────────┘ └──────────┘ │
│ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ │
│ │Orchestrtr│ │ Agent │ │ Secret │ │ Config │ │
│ │(OpenAI- │ │ Registry │ │(AES-256- │ │(YAML, │ │
│ │ compat, │ │(10 tools: │ │ GCM key │ │ XDG, │ │
│ │ multi- │ │ terminal, │ │ encrypt) │ │ encrypted│ │
│ │ provider)│ │ files, │ │ │ │ API keys)│ │
│ │ │ │ grep, etc)│ │ │ │ │ │
│ └──────────┘ └────────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────┘
┌─────────────────────────┐
│ External Tools/Agents │
│ Crush, Claude Code, │
│ Starship, MCP servers │
└─────────────────────────┘
```
### Key Dependencies
| Dependency | Version | Purpose |
|-----------|---------|---------|
| `spf13/cobra` | v1.10.2 | CLI framework |
| `charmbracelet/huh` | v1.0.0 | TUI forms (profiler, API key input) |
| `charmbracelet/bubbletea` | v1.3.10 | TUI framework (indirect) |
| `gorilla/websocket` | v1.5.3 | Terminal WebSocket |
| `creack/pty/v2` | v2.0.1 | PTY for terminal |
| `gopkg.in/yaml.v3` | v3.0.1 | Config serialization |
| React 19 | — | Frontend UI |
| Vite 8 | — | Frontend build |
| xterm.js | — | Terminal emulator component |
### File Count Summary
| Layer | Files | Lines (approx) |
|-------|-------|---------------|
| Go backend (`internal/`) | 41 `.go` files | ~8,000 |
| CLI commands (`cmd/`) | 12 `.go` files | ~600 |
| Frontend (`web/src/`) | ~20 files | ~3,500 |
| CSS (`web/src/styles/`) | 1 file | ~1,500 |
| **Total** | ~75 files | ~13,600 |
---
## 11. Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|-----------|
| AI provider API changes break orchestrator | Studio/Shell chat stops working | Orchestrator uses OpenAI-compatible format (widely supported). Fallback: user switches provider. |
| Tool install commands change (brew, apt) | Installer fails | Installer returns clear error messages. Doctor command diagnoses. User can install manually. |
| Frontend grows beyond vanilla React manageability | Hard to maintain | At current scale (4 components), this is not a risk. Re-evaluate if components exceed 20. |
| Security: API keys in config file | Key exposure | AES-256-GCM encryption at rest. Config file permissions 0600. |
| Terminal WebSocket security | Remote command execution | Server binds to 127.0.0.1 only. No remote access possible. |
---
*End of Muyue PRD v1.0*

17
go.mod
View File

@@ -1,12 +1,15 @@
module github.com/muyue/muyue
go 1.24.3
go 1.24.2
toolchain go1.24.3
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty/v2 v2.0.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
@@ -14,8 +17,10 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
@@ -25,6 +30,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -34,6 +40,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.23.0 // indirect

17
go.sum
View File

@@ -14,8 +14,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -44,12 +42,21 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -68,8 +75,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -0,0 +1,311 @@
package agent
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
)
type TerminalParams struct {
Command string `json:"command" description:"The shell command to execute"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
}
func NewTerminalTool() (*ToolDefinition, error) {
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.",
func(ctx context.Context, p TerminalParams) (ToolResponse, error) {
if p.Command == "" {
return TextErrorResponse("command is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 60 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
shell := detectShell()
cmd := exec.CommandContext(ctx, shell, "-c", p.Command)
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Error: %v\n\n%s", err, result)), nil
}
return TextResponse(result), nil
})
}
type CrushRunParams struct {
Task string `json:"task" description:"The task description for Crush to execute"`
}
func NewCrushRunTool() (*ToolDefinition, error) {
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.",
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 15000 {
result = result[:15000] + "\n... [truncated]"
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
}
return TextResponse(result), nil
})
}
type ReadFileParams struct {
Path string `json:"path" description:"Absolute or relative path to the file to read"`
Offset int `json:"offset,omitempty" description:"Line number to start reading from (0-based, default 0)"`
Limit int `json:"limit,omitempty" description:"Maximum number of lines to read (default 200, max 2000)"`
}
func NewReadFileTool() (*ToolDefinition, error) {
return NewTool("read_file",
"Read file contents from the local filesystem. Returns the file content with line numbers. Supports offset/limit for reading specific sections of large files.",
func(ctx context.Context, p ReadFileParams) (ToolResponse, error) {
if p.Path == "" {
return TextErrorResponse("path is required"), nil
}
expanded := expandHome(p.Path)
data, err := readFileLimited(expanded, p.Offset, p.Limit)
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err)), nil
}
return TextResponse(data), nil
})
}
type ListFilesParams struct {
Path string `json:"path,omitempty" description:"Directory path to list (default: user home)"`
Depth int `json:"depth,omitempty" description:"Maximum depth to traverse (default 1, max 3)"`
}
func NewListFilesTool() (*ToolDefinition, error) {
return NewTool("list_files",
"List files and directories at a given path. Shows directory tree structure with file names. Useful for exploring project structure or finding specific files.",
func(ctx context.Context, p ListFilesParams) (ToolResponse, error) {
dir := expandHome(p.Path)
if dir == "" {
dir, _ = osUserHomeDir()
}
if p.Depth <= 0 {
p.Depth = 1
}
if p.Depth > 3 {
p.Depth = 3
}
result, err := listDirTree(dir, p.Depth, 0)
if err != nil {
return TextErrorResponse(fmt.Sprintf("list error: %v", err)), nil
}
return TextResponse(result), nil
})
}
type SearchFilesParams struct {
Pattern string `json:"pattern" description:"Search pattern (supports * and ? glob wildcards)"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
}
func NewSearchFilesTool() (*ToolDefinition, error) {
return NewTool("search_files",
"Search for files by name pattern using glob syntax. Use * for any characters, ** for recursive matching. Returns matching file paths sorted by name.",
func(ctx context.Context, p SearchFilesParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
matches, err := filepath.Glob(filepath.Join(dir, p.Pattern))
if err != nil {
return TextErrorResponse(fmt.Sprintf("glob error: %v", err)), nil
}
if len(matches) == 0 {
return TextResponse("No files found matching pattern."), nil
}
if len(matches) > 100 {
matches = matches[:100]
}
var result strings.Builder
for _, m := range matches {
result.WriteString(m)
result.WriteString("\n")
}
return TextResponse(result.String()), nil
})
}
type GrepContentParams struct {
Pattern string `json:"pattern" description:"Text pattern to search for in file contents"`
Path string `json:"path,omitempty" description:"Directory to search in (default: current directory)"`
Include string `json:"include,omitempty" description:"File extension filter, e.g. '*.go' or '*.{js,ts}'"`
}
func NewGrepContentTool() (*ToolDefinition, error) {
return NewTool("grep_content",
"Search for text patterns inside file contents. Returns matching lines with file paths and line numbers. Use include to filter by file extension.",
func(ctx context.Context, p GrepContentParams) (ToolResponse, error) {
if p.Pattern == "" {
return TextErrorResponse("pattern is required"), nil
}
dir := expandHome(p.Path)
if dir == "" {
dir = "."
}
result, err := grepFiles(dir, p.Pattern, p.Include)
if err != nil {
return TextErrorResponse(fmt.Sprintf("grep error: %v", err)), nil
}
if result == "" {
return TextResponse("No matches found."), nil
}
return TextResponse(result), nil
})
}
type GetConfigParams struct {
Section string `json:"section,omitempty" description:"Config section to retrieve: 'providers', 'profile', 'tools', 'terminal', 'all' (default: 'all')"`
}
func NewGetConfigTool() (*ToolDefinition, error) {
return NewTool("get_config",
"Read the Muyue configuration. Returns provider settings, profile info, installed tools, terminal config, etc. Use section parameter to get a specific part, or 'all' for the full config.",
func(ctx context.Context, p GetConfigParams) (ToolResponse, error) {
return getConfigSection(p.Section), nil
})
}
type SetProviderParams struct {
Name string `json:"name" description:"Provider name (e.g. 'openai', 'anthropic', 'ollama')"`
APIKey string `json:"api_key,omitempty" description:"API key for the provider"`
BaseURL string `json:"base_url,omitempty" description:"Custom base URL for the provider API"`
Model string `json:"model,omitempty" description:"Model identifier to use"`
Active *bool `json:"active,omitempty" description:"Set to true to make this the active provider"`
}
func NewSetProviderTool() (*ToolDefinition, error) {
return NewTool("set_provider",
"Configure an AI provider in Muyue settings. Can create, update, or activate a provider. API keys are automatically encrypted. Set active=true to switch to this provider.",
func(ctx context.Context, p SetProviderParams) (ToolResponse, error) {
if p.Name == "" {
return TextErrorResponse("name is required"), nil
}
return setProviderConfig(p), nil
})
}
type ManageSSHParams struct {
Action string `json:"action" description:"Action to perform: 'list', 'add', 'remove'"`
Name string `json:"name,omitempty" description:"Connection name (required for add/remove)"`
Host string `json:"host,omitempty" description:"SSH host (required for add)"`
Port int `json:"port,omitempty" description:"SSH port (default: 22)"`
User string `json:"user,omitempty" description:"SSH username (required for add)"`
KeyPath string `json:"key_path,omitempty" description:"Path to SSH private key"`
}
func NewManageSSHTool() (*ToolDefinition, error) {
return NewTool("manage_ssh",
"Manage SSH connections configured in Muyue. List existing connections, add new ones, or remove connections. SSH configs are persisted to the Muyue config file.",
func(ctx context.Context, p ManageSSHParams) (ToolResponse, error) {
if p.Action == "" {
return TextErrorResponse("action is required (list, add, remove)"), nil
}
return manageSSHAction(p), nil
})
}
type WebFetchParams struct {
URL string `json:"url" description:"The URL to fetch content from"`
}
func NewWebFetchTool() (*ToolDefinition, error) {
return NewTool("web_fetch",
"Fetch content from a URL and return the text. Useful for reading documentation, APIs, or web resources. Only HTTP/HTTPS URLs are supported.",
func(ctx context.Context, p WebFetchParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required"), nil
}
return fetchURL(p.URL), nil
})
}
func DefaultRegistry() *Registry {
r := NewRegistry()
tools := []*ToolDefinition{
must(NewTerminalTool()),
must(NewCrushRunTool()),
must(NewReadFileTool()),
must(NewListFilesTool()),
must(NewSearchFilesTool()),
must(NewGrepContentTool()),
must(NewGetConfigTool()),
must(NewSetProviderTool()),
must(NewManageSSHTool()),
must(NewWebFetchTool()),
}
for _, t := range tools {
if err := r.Register(t); err != nil {
panic(err)
}
}
return r
}
func must(t *ToolDefinition, err error) *ToolDefinition {
if err != nil {
panic(err)
}
return t
}

579
internal/agent/impl.go Normal file
View File

@@ -0,0 +1,579 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
func expandHome(path string) string {
if path == "" {
return ""
}
if path == "~" {
home, _ := os.UserHomeDir()
return home
}
if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
return path
}
func osUserHomeDir() (string, error) {
return os.UserHomeDir()
}
func readFileLimited(path string, offset, limit int) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
lines := strings.Split(string(data), "\n")
if offset < 0 {
offset = 0
}
if offset > len(lines) {
offset = len(lines)
}
end := offset + limit
if limit <= 0 || limit > 2000 {
limit = 2000
}
if end > len(lines) {
end = len(lines)
}
if end-offset > limit {
end = offset + limit
}
selected := lines[offset:end]
var buf strings.Builder
for i, line := range selected {
fmt.Fprintf(&buf, "%6d\t%s\n", offset+i+1, line)
}
return buf.String(), nil
}
func listDirTree(dir string, maxDepth, currentDepth int) (string, error) {
info, err := os.Stat(dir)
if err != nil {
return "", err
}
if !info.IsDir() {
return dir + "\n", nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
var buf strings.Builder
indent := strings.Repeat(" ", currentDepth)
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") && name != "." && name != ".." {
continue
}
if entry.IsDir() {
fmt.Fprintf(&buf, "%s%s/\n", indent, name)
if currentDepth < maxDepth {
sub, err := listDirTree(filepath.Join(dir, name), maxDepth, currentDepth+1)
if err == nil {
buf.WriteString(sub)
}
}
} else {
fmt.Fprintf(&buf, "%s%s\n", indent, name)
}
}
return buf.String(), nil
}
func grepFiles(dir, pattern, include string) (string, error) {
if include != "" {
matches, err := filepath.Glob(filepath.Join(dir, include))
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", nil
}
var buf strings.Builder
for _, match := range matches {
result, err := grepInFile(match, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
return grepInDir(dir, pattern, 0)
}
func grepInDir(dir, pattern string, depth int) (string, error) {
if depth > 10 {
return "", nil
}
var buf strings.Builder
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
path := filepath.Join(dir, name)
if entry.IsDir() {
sub, err := grepInDir(path, pattern, depth+1)
if err == nil {
buf.WriteString(sub)
}
continue
}
result, err := grepInFile(path, pattern)
if err != nil {
continue
}
buf.WriteString(result)
}
return buf.String(), nil
}
func grepInFile(path, pattern string) (string, error) {
re, err := regexp.Compile(pattern)
if err != nil {
re, err = regexp.Compile(regexp.QuoteMeta(pattern))
if err != nil {
return "", err
}
}
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
var buf strings.Builder
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNum := 0
matchCount := 0
for scanner.Scan() {
lineNum++
if re.MatchString(scanner.Text()) {
fmt.Fprintf(&buf, "%s:%d: %s\n", path, lineNum, scanner.Text())
matchCount++
if matchCount >= 50 {
buf.WriteString("... [truncated, more matches exist]\n")
break
}
}
}
return buf.String(), nil
}
func getConfigSection(section string) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch section {
case "providers", "profile", "tools", "terminal":
sectionData := extractYAMLSection(data, section)
if sectionData == "" {
return TextResponse(fmt.Sprintf("Section '%s' not found in config.", section))
}
return TextResponse(sectionData)
default:
content := string(data)
if len(content) > 8000 {
content = content[:8000] + "\n... [truncated]"
}
return TextResponse(content)
}
}
func extractYAMLSection(data []byte, section string) string {
lines := strings.Split(string(data), "\n")
inSection := false
indentLevel := 0
var buf strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
if inSection {
buf.WriteString("\n")
}
continue
}
if !inSection {
if strings.HasPrefix(trimmed, section+":") || strings.HasPrefix(trimmed, section+" ") {
inSection = true
indentLevel = len(line) - len(strings.TrimLeft(line, " "))
buf.WriteString(line)
buf.WriteString("\n")
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= indentLevel && trimmed != "" {
break
}
buf.WriteString(line)
buf.WriteString("\n")
}
return strings.TrimSpace(buf.String())
}
func setProviderConfig(p SetProviderParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
lines := strings.Split(string(data), "\n")
inProviders := false
providerIndent := 0
foundProvider := false
insertIdx := -1
lastProviderEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !inProviders {
if strings.HasPrefix(trimmed, "providers:") {
inProviders = true
providerIndent = len(line) - len(strings.TrimLeft(line, " "))
}
continue
}
currentIndent := len(line) - len(strings.TrimLeft(line, " "))
if currentIndent <= providerIndent && trimmed != "" && !strings.HasPrefix(trimmed, "#") {
lastProviderEnd = i
break
}
if currentIndent == providerIndent+2 && strings.HasPrefix(trimmed, "- name:") {
nameMatch := strings.TrimPrefix(trimmed, "- name:")
nameMatch = strings.TrimSpace(nameMatch)
if nameMatch == p.Name {
foundProvider = true
insertIdx = i
}
if insertIdx == -1 || insertIdx < i {
insertIdx = i
}
}
}
if lastProviderEnd == -1 {
lastProviderEnd = len(lines)
}
entryIndent := strings.Repeat(" ", providerIndent+4)
var newEntry strings.Builder
newEntry.WriteString(fmt.Sprintf(" - name: %s\n", p.Name))
if p.Model != "" {
newEntry.WriteString(fmt.Sprintf("%smodel: %s\n", entryIndent, p.Model))
}
if p.BaseURL != "" {
newEntry.WriteString(fmt.Sprintf("%sbase_url: %s\n", entryIndent, p.BaseURL))
}
if p.APIKey != "" {
newEntry.WriteString(fmt.Sprintf("%sapi_key: %s\n", entryIndent, p.APIKey))
}
if p.Active != nil {
newEntry.WriteString(fmt.Sprintf("%sactive: %v\n", entryIndent, *p.Active))
}
if foundProvider && insertIdx >= 0 {
var endIdx int
for endIdx = insertIdx + 1; endIdx < len(lines); endIdx++ {
li := len(lines[endIdx]) - len(strings.TrimLeft(lines[endIdx], " "))
if li <= providerIndent+2 || lines[endIdx] == "" {
if endIdx > insertIdx+1 && strings.TrimSpace(lines[endIdx]) == "" {
continue
}
break
}
}
newLines := make([]string, 0, len(lines))
newLines = append(newLines, lines[:insertIdx]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[endIdx:]...)
lines = newLines
} else {
insertAt := lastProviderEnd
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:insertAt]...)
newLines = append(newLines, strings.TrimSuffix(newEntry.String(), "\n"))
newLines = append(newLines, lines[insertAt:]...)
lines = newLines
}
content := strings.Join(lines, "\n")
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("Provider '%s' configured successfully.", p.Name))
}
func manageSSHAction(p ManageSSHParams) ToolResponse {
configPath, err := os.UserConfigDir()
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot find config dir: %v", err))
}
configPath = filepath.Join(configPath, "muyue", "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return TextErrorResponse(fmt.Sprintf("cannot read config: %v", err))
}
switch p.Action {
case "list":
sshSection := extractYAMLSection(data, "ssh")
if sshSection == "" {
return TextResponse("No SSH connections configured.")
}
return TextResponse(sshSection)
case "add":
if p.Name == "" || p.Host == "" || p.User == "" {
return TextErrorResponse("name, host, and user are required for add action")
}
if p.Port == 0 {
p.Port = 22
}
lines := strings.Split(string(data), "\n")
sshIdx := -1
sshIndent := 0
lastSSHEnd := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if sshIdx == -1 && strings.HasPrefix(trimmed, "ssh:") {
sshIdx = i
sshIndent = len(line) - len(strings.TrimLeft(line, " "))
continue
}
if sshIdx != -1 {
li := len(line) - len(strings.TrimLeft(line, " "))
if li <= sshIndent && trimmed != "" {
lastSSHEnd = i
break
}
}
}
if lastSSHEnd == -1 {
lastSSHEnd = len(lines)
}
entry := fmt.Sprintf(" - name: %s\n host: %s\n port: %d\n user: %s", p.Name, p.Host, p.Port, p.User)
if p.KeyPath != "" {
entry += fmt.Sprintf("\n key_path: %s", p.KeyPath)
}
newLines := make([]string, 0, len(lines)+10)
newLines = append(newLines, lines[:lastSSHEnd]...)
newLines = append(newLines, entry)
newLines = append(newLines, lines[lastSSHEnd:]...)
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' (%s@%s:%d) added.", p.Name, p.User, p.Host, p.Port))
case "remove":
if p.Name == "" {
return TextErrorResponse("name is required for remove action")
}
lines := strings.Split(string(data), "\n")
newLines := make([]string, 0, len(lines))
skipping := false
removed := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "name: "+p.Name) && strings.HasPrefix(trimmed, "-") {
skipping = true
removed = true
continue
}
if skipping {
li := len(line) - len(strings.TrimLeft(line, " "))
if li > 6 && i < len(lines)-1 && strings.TrimSpace(lines[i+1]) != "" {
continue
}
skipping = false
continue
}
newLines = append(newLines, line)
}
if !removed {
return TextErrorResponse(fmt.Sprintf("SSH connection '%s' not found.", p.Name))
}
if err := os.WriteFile(configPath, []byte(strings.Join(newLines, "\n")), 0600); err != nil {
return TextErrorResponse(fmt.Sprintf("write config error: %v", err))
}
return TextResponse(fmt.Sprintf("SSH connection '%s' removed.", p.Name))
default:
return TextErrorResponse("unknown action. Use 'list', 'add', or 'remove'")
}
}
func fetchURL(url string) ToolResponse {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return TextErrorResponse("only http/https URLs are supported")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return TextErrorResponse(fmt.Sprintf("create request: %v", err))
}
req.Header.Set("User-Agent", "MuyueStudio/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return TextErrorResponse(fmt.Sprintf("fetch error: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 50000))
if err != nil {
return TextErrorResponse(fmt.Sprintf("read error: %v", err))
}
if resp.StatusCode != http.StatusOK {
return TextErrorResponse(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 2000)))
}
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/html") {
text := stripHTML(string(body))
if len(text) > 8000 {
text = text[:8000] + "\n... [truncated]"
}
return TextResponse(text)
}
result := string(body)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
return TextResponse(result)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
func stripHTML(html string) string {
tagRe := regexp.MustCompile(`<[^>]*>`)
text := tagRe.ReplaceAllString(html, " ")
entityRe := regexp.MustCompile(`&[a-zA-Z]+;`)
text = entityRe.ReplaceAllStringFunc(text, func(s string) string {
switch s {
case "&amp;":
return "&"
case "&lt;":
return "<"
case "&gt;":
return ">"
case "&quot;":
return "\""
case "&#39;":
return "'"
case "&nbsp;":
return " "
default:
return " "
}
})
multiSpace := regexp.MustCompile(`\s+`)
text = multiSpace.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}
var _ = runtime.GOOS
var _ = json.Marshal

10
internal/agent/prompt.go Normal file
View File

@@ -0,0 +1,10 @@
package agent
import _ "embed"
//go:embed prompts/studio_system.md
var studioSystemPrompt string
func StudioSystemPrompt() string {
return studioSystemPrompt
}

View File

@@ -0,0 +1,44 @@
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur.
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.
## Environnement
Muyue gère :
- **Fournisseurs IA** (OpenAI, Anthropic, Ollama, MiniMax, etc.)
- **Outils de développement** (Crush, Claude Code, etc.)
- **Terminaux locaux et SSH**
- **Configuration et préférences**
- **Serveurs MCP et LSP**
## Outils disponibles
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le.
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.)
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug)
- **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
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.
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe.
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire.
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur.
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.)
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses.
7. **Langue** — Réponds dans la même langue que l'utilisateur.
## Format des réponses
- Code : utilise des blocs markdown
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes
- Erreurs : explique clairement et propose une solution
- Succès : confirme brièvement ce qui a été fait

218
internal/agent/tools.go Normal file
View File

@@ -0,0 +1,218 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
)
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type ToolResponse struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
Meta map[string]string `json:"meta,omitempty"`
}
func TextResponse(content string) ToolResponse {
return ToolResponse{Content: content}
}
func TextErrorResponse(msg string) ToolResponse {
return ToolResponse{Content: msg, IsError: true}
}
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
Handler func(ctx context.Context, args json.RawMessage) (ToolResponse, error)
}
func (td *ToolDefinition) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
resp, err := td.Handler(ctx, call.Arguments)
if err != nil {
return ToolResponse{Content: err.Error(), IsError: true}, nil
}
return resp, nil
}
func (td *ToolDefinition) ToOpenAITool() map[string]interface{} {
return map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": td.Name,
"description": td.Description,
"parameters": td.Params,
},
}
}
func NewTool[P any](name, description string, handler func(ctx context.Context, params P) (ToolResponse, error)) (*ToolDefinition, error) {
var zero P
paramsSchema, err := generateSchema(zero)
if err != nil {
return nil, fmt.Errorf("generate schema for %s: %w", name, err)
}
wrappedHandler := func(ctx context.Context, raw json.RawMessage) (ToolResponse, error) {
var params P
if err := json.Unmarshal(raw, &params); err != nil {
return TextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
}
return handler(ctx, params)
}
return &ToolDefinition{
Name: name,
Description: description,
Params: paramsSchema,
Handler: wrappedHandler,
}, nil
}
type Registry struct {
tools map[string]*ToolDefinition
}
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]*ToolDefinition),
}
}
func (r *Registry) Register(tool *ToolDefinition) error {
if _, exists := r.tools[tool.Name]; exists {
return fmt.Errorf("tool %q already registered", tool.Name)
}
r.tools[tool.Name] = tool
return nil
}
func (r *Registry) Get(name string) (*ToolDefinition, bool) {
t, ok := r.tools[name]
return t, ok
}
func (r *Registry) All() []*ToolDefinition {
out := make([]*ToolDefinition, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t)
}
return out
}
func (r *Registry) OpenAITools() []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(r.tools))
for _, t := range r.tools {
out = append(out, t.ToOpenAITool())
}
return out
}
func (r *Registry) Execute(ctx context.Context, call ToolCall) (ToolResponse, error) {
tool, ok := r.tools[call.Name]
if !ok {
return TextErrorResponse(fmt.Sprintf("unknown tool: %s", call.Name)), nil
}
return tool.Execute(ctx, call)
}
func generateSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{
"type": goTypeToJSON(field.Type),
}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
data, err := json.Marshal(schema)
if err != nil {
return nil, err
}
return json.RawMessage(data), nil
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}

244
internal/api/chat_engine.go Normal file
View File

@@ -0,0 +1,244 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
const (
MaxToolIterations = 15
)
// ChatEngine handles chat interactions with tool execution.
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
type ChatEngine struct {
orchestrator *orchestrator.Orchestrator
registry *agent.Registry
tools json.RawMessage
onChunk func(map[string]interface{})
stream bool
}
// NewChatEngine creates a new ChatEngine instance.
func NewChatEngine(orb *orchestrator.Orchestrator, registry *agent.Registry, tools json.RawMessage) *ChatEngine {
return &ChatEngine{
orchestrator: orb,
registry: registry,
tools: tools,
stream: false,
}
}
// SetStream enables streaming mode for the chat engine.
func (ce *ChatEngine) SetStream(enabled bool) {
ce.stream = enabled
}
// OnChunk sets the callback for SSE chunk writing.
func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
ce.onChunk = fn
}
// RunWithTools executes the chat loop with tool calls.
// 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) {
var finalContent string
var allToolCalls []map[string]interface{}
var allToolResults []map[string]interface{}
for i := 0; i < MaxToolIterations; i++ {
var resp *orchestrator.ChatResponse
var err error
if ce.stream {
// Use streaming version
resp, err = ce.orchestrator.SendWithToolsStream(messages, func(content string, toolCalls []orchestrator.ToolCallMsg) {
if ce.onChunk != nil && content != "" {
ce.onChunk(map[string]interface{}{"content": content})
}
})
} else {
resp, err = ce.orchestrator.SendWithTools(messages)
}
if err != nil {
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"error": err.Error()})
}
return finalContent, allToolCalls, allToolResults, err
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"content": content})
}
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
toolCallData := map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
}
allToolCalls = append(allToolCalls, toolCallData)
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_call": toolCallData})
}
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := ce.registry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
resultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": result.Content,
"is_error": result.IsError,
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": result.Content,
"is_error": result.IsError,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": resultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
return finalContent, allToolCalls, allToolResults, nil
}
// RunNonStream executes chat without streaming content to client.
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
var finalContent string
for i := 0; i < MaxToolIterations; i++ {
resp, err := ce.orchestrator.SendWithTools(messages)
if err != nil {
return finalContent, err
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := ce.registry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
}
return finalContent, nil
}
// SSEWriter handles Server-Sent Events writing to HTTP response.
type SSEWriter struct {
w http.ResponseWriter
flusher http.Flusher
}
// NewSSEWriter creates a new SSEWriter.
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
sse := &SSEWriter{w: w}
if f, ok := w.(http.Flusher); ok {
sse.flusher = f
}
return sse
}
// Write sends an SSE message.
func (s *SSEWriter) Write(data map[string]interface{}) {
b, _ := json.Marshal(data)
s.w.Write([]byte("data: " + string(b) + "\n\n"))
if s.flusher != nil {
s.flusher.Flush()
}
}
// SetupSSEHeaders sets up SSE response headers.
func SetupSSEHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
}

View File

@@ -0,0 +1,262 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const maxTokensApprox = 100000
const summarizeThreshold = 80000
const charsPerToken = 4
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
}
type TokenCount struct {
total int
byRole map[string]int
byMessage int
}
type SearchResult struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
func NewConversationStore() *ConversationStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "conversation.json")
cs := &ConversationStore{path: path}
cs.load()
return cs
}
func (cs *ConversationStore) load() {
data, err := os.ReadFile(cs.path)
if err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
if conv.Messages == nil {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
}
func (cs *ConversationStore) save() error {
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cs.path)
os.MkdirAll(dir, 0755)
return os.WriteFile(cs.path, data, 0600)
}
func (cs *ConversationStore) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
out := make([]FeedMessage, len(cs.conv.Messages))
copy(out, cs.conv.Messages)
return out
}
func (cs *ConversationStore) GetSummary() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.conv.Summary
}
func (cs *ConversationStore) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save()
}
func (cs *ConversationStore) SetSummary(summary string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Summary = summary
cs.save()
}
func (cs *ConversationStore) TrimOld(keepCount int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount {
return
}
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
return cs.ApproxTokenCountDetailed().total
}
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
cs.mu.RLock()
defer cs.mu.RUnlock()
result := TokenCount{
byRole: make(map[string]int),
}
for _, m := range cs.conv.Messages {
count := utf8.RuneCountInString(m.Content) / charsPerToken
result.byMessage += count
result.byRole[m.Role] += count
}
if cs.conv.Summary != "" {
result.total = result.byMessage + utf8.RuneCountInString(cs.conv.Summary)/charsPerToken
} else {
result.total = result.byMessage
}
return result
}
func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > summarizeThreshold
}
func (cs *ConversationStore) Search(query string) []SearchResult {
cs.mu.RLock()
defer cs.mu.RUnlock()
var results []SearchResult
queryLower := strings.ToLower(query)
for _, msg := range cs.conv.Messages {
if strings.Contains(strings.ToLower(msg.Content), queryLower) {
results = append(results, SearchResult{
ID: msg.ID,
Role: msg.Role,
Content: msg.Content,
Time: msg.Time,
})
}
}
return results
}
func (cs *ConversationStore) ExportMarkdown() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
var sb strings.Builder
sb.WriteString("# Conversation Export\n\n")
sb.WriteString(fmt.Sprintf("Exporté le: %s\n\n", time.Now().Format(time.RFC3339)))
if cs.conv.Summary != "" {
sb.WriteString("## Résumé\n\n")
sb.WriteString(cs.conv.Summary)
sb.WriteString("\n\n---\n\n")
}
sb.WriteString("## Messages\n\n")
for i, msg := range cs.conv.Messages {
roleLabel := msg.Role
if roleLabel == "user" {
roleLabel = "👤 Utilisateur"
} else if roleLabel == "assistant" {
roleLabel = "🤖 Assistant"
} else if roleLabel == "system" {
roleLabel = "⚙️ Système"
}
timestamp := ""
if msg.Time != "" {
if t, err := time.Parse(time.RFC3339, msg.Time); err == nil {
timestamp = t.Format("2006-01-02 15:04")
}
}
sb.WriteString(fmt.Sprintf("### [%d] %s (%s)\n\n", i+1, roleLabel, timestamp))
sb.WriteString(msg.Content)
sb.WriteString("\n\n---\n\n")
}
return sb.String()
}
func (cs *ConversationStore) ExportJSON() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return "{}"
}
return string(data)
}
func generateMsgID() string {
return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
}

View File

@@ -0,0 +1,370 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
"github.com/muyue/muyue/internal/config"
)
// ConversationMeta represents metadata for a conversation (used for listing).
type ConversationMeta struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
MessageCount int `json:"message_count"`
}
// ConversationStoreMulti manages multiple conversations.
type ConversationStoreMulti struct {
mu sync.RWMutex
dir string
currentID string
conversations map[string]*Conversation
}
func NewConversationStoreMulti() *ConversationStoreMulti {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
dir = filepath.Join(dir, "conversations")
cs := &ConversationStoreMulti{
dir: dir,
conversations: make(map[string]*Conversation),
}
cs.loadIndex()
return cs
}
func (cs *ConversationStoreMulti) loadIndex() {
os.MkdirAll(cs.dir, 0755)
// Load index file if exists
indexPath := filepath.Join(cs.dir, "index.json")
data, err := os.ReadFile(indexPath)
if err != nil {
// Create default conversation
cs.createDefault()
return
}
var index struct {
CurrentID string `json:"current_id"`
Conversations []ConversationMeta `json:"conversations"`
}
if err := json.Unmarshal(data, &index); err != nil {
cs.createDefault()
return
}
cs.currentID = index.CurrentID
if cs.currentID == "" {
cs.createDefault()
return
}
// Load all conversations
for _, meta := range index.Conversations {
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", meta.ID))
data, err := os.ReadFile(convPath)
if err != nil {
continue
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
continue
}
cs.conversations[meta.ID] = &conv
}
// Ensure current conversation exists
if _, ok := cs.conversations[cs.currentID]; !ok {
cs.createDefault()
}
}
func (cs *ConversationStoreMulti) createDefault() {
cs.currentID = uuid.New().String()
cs.conversations[cs.currentID] = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
cs.saveIndex()
}
func (cs *ConversationStoreMulti) saveIndex() error {
var metas []ConversationMeta
for id, conv := range cs.conversations {
title := "Nouvelle conversation"
if len(conv.Messages) > 0 {
// Use first user message as title
for _, m := range conv.Messages {
if m.Role == "user" {
if len(m.Content) > 50 {
title = m.Content[:50] + "..."
} else {
title = m.Content
}
break
}
}
}
metas = append(metas, ConversationMeta{
ID: id,
Title: title,
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
MessageCount: len(conv.Messages),
})
}
index := struct {
CurrentID string `json:"current_id"`
Conversations []ConversationMeta `json:"conversations"`
}{
CurrentID: cs.currentID,
Conversations: metas,
}
data, err := json.MarshalIndent(index, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(cs.dir, "index.json"), data, 0600)
}
func (cs *ConversationStoreMulti) saveCurrent() error {
conv, ok := cs.conversations[cs.currentID]
if !ok {
return fmt.Errorf("no current conversation")
}
conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(conv, "", " ")
if err != nil {
return err
}
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", cs.currentID))
if err := os.WriteFile(convPath, data, 0600); err != nil {
return err
}
return cs.saveIndex()
}
// Current returns the current conversation store.
func (cs *ConversationStoreMulti) Current() *ConversationStore {
cs.mu.RLock()
defer cs.mu.RUnlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
return &ConversationStore{
conv: &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
},
}
}
return &ConversationStore{
conv: conv,
}
}
// Get returns the current conversation messages.
func (cs *ConversationStoreMulti) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
return []FeedMessage{}
}
out := make([]FeedMessage, len(conv.Messages))
copy(out, conv.Messages)
return out
}
// Add adds a message to the current conversation.
func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
cs.currentID = uuid.New().String()
conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
cs.conversations[cs.currentID] = conv
}
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
conv.Messages = append(conv.Messages, msg)
go cs.saveCurrent() // Fire and forget
return msg
}
// Clear clears the current conversation.
func (cs *ConversationStoreMulti) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
conv, ok := cs.conversations[cs.currentID]
if !ok {
return
}
conv.Messages = []FeedMessage{}
conv.Summary = ""
conv.CreatedAt = time.Now().Format(time.RFC3339)
conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.saveCurrent()
}
// List returns all conversations.
func (cs *ConversationStoreMulti) List() []ConversationMeta {
cs.mu.RLock()
defer cs.mu.RUnlock()
var metas []ConversationMeta
for id, conv := range cs.conversations {
title := "Nouvelle conversation"
if len(conv.Messages) > 0 {
for _, m := range conv.Messages {
if m.Role == "user" {
if len(m.Content) > 50 {
title = m.Content[:50] + "..."
} else {
title = m.Content
}
break
}
}
}
metas = append(metas, ConversationMeta{
ID: id,
Title: title,
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
MessageCount: len(conv.Messages),
})
}
return metas
}
// Create creates a new conversation and switches to it.
func (cs *ConversationStoreMulti) Create() string {
cs.mu.Lock()
defer cs.mu.Unlock()
id := uuid.New().String()
cs.conversations[id] = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
cs.currentID = id
cs.saveIndex()
return id
}
// Switch switches to a different conversation.
func (cs *ConversationStoreMulti) Switch(id string) error {
cs.mu.Lock()
defer cs.mu.Unlock()
if _, ok := cs.conversations[id]; !ok {
return fmt.Errorf("conversation not found: %s", id)
}
cs.currentID = id
cs.saveIndex()
return nil
}
// GetByID returns a conversation by ID.
func (cs *ConversationStoreMulti) GetByID(id string) (*Conversation, error) {
cs.mu.RLock()
defer cs.mu.RUnlock()
conv, ok := cs.conversations[id]
if !ok {
return nil, fmt.Errorf("conversation not found: %s", id)
}
return conv, nil
}
// Delete deletes a conversation.
func (cs *ConversationStoreMulti) Delete(id string) error {
cs.mu.Lock()
defer cs.mu.Unlock()
if _, ok := cs.conversations[id]; !ok {
return fmt.Errorf("conversation not found: %s", id)
}
delete(cs.conversations, id)
// Delete file
convPath := filepath.Join(cs.dir, fmt.Sprintf("conv_%s.json", id))
os.Remove(convPath)
// If deleted current, switch to another
if cs.currentID == id {
if len(cs.conversations) > 0 {
for newID := range cs.conversations {
cs.currentID = newID
break
}
} else {
// Create new default
cs.currentID = uuid.New().String()
cs.conversations[cs.currentID] = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
}
}
cs.saveIndex()
return nil
}
// CurrentID returns the current conversation ID.
func (cs *ConversationStoreMulti) CurrentID() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.currentID
}

View File

@@ -0,0 +1,237 @@
package api
import (
"context"
"encoding/json"
"net/http"
"regexp"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Message string `json:"message"`
Stream bool `json:"stream"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Message == "" {
writeError(w, "no message", http.StatusMethodNotAllowed)
return
}
s.convStore.Add("user", body.Message)
if s.convStore.NeedsSummarization() {
s.autoSummarize()
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
orb.SetTools(s.agentToolsJSON)
if body.Stream {
s.handleStreamChat(w, orb, body.Message)
} else {
s.handleNonStreamChat(w, orb, body.Message)
}
}
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
ctx := context.Background()
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
sseWriter.Write(data)
if canFlush {
flusher.Flush()
}
})
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{
"content": storeContent,
"tool_calls": allToolCalls,
"tool_results": allToolResults,
}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.convStore.Add("assistant", storeContent)
sseWriter.Write(map[string]interface{}{"done": "true"})
}
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
ctx := context.Background()
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.convStore.Add("assistant", finalContent)
writeJSON(w, map[string]string{"content": finalContent})
}
func cleanThinkingTags(content string) string {
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
}
const contextWindowMessages = 20
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
history := s.convStore.Get()
start := 0
if len(history) > contextWindowMessages {
start = len(history) - contextWindowMessages
}
messages := make([]orchestrator.Message, 0, len(history[start:])+1)
summary := s.convStore.GetSummary()
if summary != "" {
messages = append(messages, orchestrator.Message{
Role: "system",
Content: "Résumé de la conversation précédente:\n" + summary,
})
}
for _, m := range history[start:] {
content := m.Content
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
}
messages = append(messages, orchestrator.Message{
Role: role,
Content: content,
})
}
messages = append(messages, orchestrator.Message{
Role: "user",
Content: userMessage,
})
return messages
}
func (s *Server) autoSummarize() {
messages := s.convStore.Get()
if len(messages) < 10 {
return
}
half := len(messages) / 2
var oldText string
for _, m := range messages[:half] {
oldText += m.Role + ": " + m.Content + "\n\n"
}
summary := s.convStore.GetSummary()
if summary != "" {
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
}
orb, err := orchestrator.New(s.config)
if err != nil {
return
}
orb.SetSystemPrompt(summarizePrompt)
result, err := orb.Send(oldText)
if err != nil {
return
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox,
"summarize_at": summarizeThreshold,
"summary": s.convStore.GetSummary(),
})
}
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.autoSummarize()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": s.convStore.ApproxTokenCount(),
"summary": s.convStore.GetSummary(),
})
}

View File

@@ -0,0 +1,17 @@
package api
import (
"encoding/json"
"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.`
func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, msg string, code int) {
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,495 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Language string `json:"language"`
KeyboardLayout string `json:"keyboard_layout"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Language != "" {
s.config.Profile.Preferences.Language = body.Language
}
if body.KeyboardLayout != "" {
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
currentJSON, err := json.Marshal(s.config.Profile)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var currentMap map[string]interface{}
json.Unmarshal(currentJSON, &currentMap)
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
deepMerge(currentMap, updates)
mergedJSON, _ := json.Marshal(currentMap)
json.Unmarshal(mergedJSON, &s.config.Profile)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func deepMerge(dst, src map[string]interface{}) {
for k, sv := range src {
if dv, ok := dst[k]; ok {
dstMap, dOk := dv.(map[string]interface{})
srcMap, sOk := sv.(map[string]interface{})
if dOk && sOk {
deepMerge(dstMap, srcMap)
continue
}
}
dst[k] = sv
}
}
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
Active *bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Name == body.Name {
if body.APIKey != "" {
s.config.AI.Providers[i].APIKey = body.APIKey
}
if body.Model != "" {
s.config.AI.Providers[i].Model = body.Model
}
if body.BaseURL != "" {
s.config.AI.Providers[i].BaseURL = body.BaseURL
}
if body.Active != nil {
if *body.Active {
for j := range s.config.AI.Providers {
s.config.AI.Providers[j].Active = false
}
}
s.config.AI.Providers[i].Active = *body.Active
}
found = true
break
}
}
if !found {
writeError(w, "provider not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Model string `json:"model"`
BaseURL string `json:"base_url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.APIKey == "" {
writeError(w, "api_key required", http.StatusBadRequest)
return
}
baseURL := body.BaseURL
if baseURL == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
baseURL = p.BaseURL
break
}
}
}
if baseURL == "" {
switch body.Name {
case "minimax":
baseURL = "https://api.minimax.io/v1"
case "openai":
baseURL = "https://api.openai.com/v1"
case "anthropic":
baseURL = "https://api.anthropic.com/v1"
default:
baseURL = "https://api.minimax.io/v1"
}
}
model := body.Model
if model == "" {
for _, p := range s.config.AI.Providers {
if p.Name == body.Name {
model = p.Model
break
}
}
}
if model == "" {
model = "MiniMax-M2.7"
}
reqBody, _ := json.Marshal(map[string]interface{}{
"model": model,
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
"max_tokens": 5,
"stream": false,
})
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+body.APIKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
writeError(w, "invalid_api_key", http.StatusUnauthorized)
return
}
if resp.StatusCode != http.StatusOK {
writeError(w, "api_error: "+string(respBody), http.StatusBadGateway)
return
}
writeJSON(w, map[string]interface{}{"status": "valid"})
}
func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
FontSize int `json:"font_size"`
FontFamily string `json:"font_family"`
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.FontSize > 0 {
s.config.Terminal.FontSize = body.FontSize
}
if body.FontFamily != "" {
s.config.Terminal.FontFamily = body.FontFamily
}
if body.Theme != "" {
s.config.Terminal.Theme = body.Theme
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"theme": config.GetTerminalTheme(s.config.Terminal.Theme),
})
}
func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) {
themes := make([]map[string]string, 0, len(config.DEFAULT_TERMINAL_THEMES))
for id, theme := range config.DEFAULT_TERMINAL_THEMES {
themes = append(themes, map[string]string{
"id": id,
"name": theme.Name,
})
}
writeJSON(w, map[string]interface{}{"themes": themes})
}
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
dir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
path := filepath.Join(dir, "config.yaml")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.config = config.Default()
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Theme string `json:"theme"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Theme == "" {
body.Theme = s.config.Terminal.PromptTheme
}
cfgDir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
home, _ := os.UserHomeDir()
shellRCs := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
if _, err := os.Stat(rc); err != nil {
continue
}
content, _ := os.ReadFile(rc)
if strings.Contains(string(content), "STARSHIP_CONFIG") {
continue
}
exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile)
f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
continue
}
f.WriteString(exportLine)
f.Close()
}
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func getStarshipThemeConfig(theme string) string {
switch theme {
case "charm":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[➜](bold #00E676)"
error_symbol = "[✗](bold #FF0033)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #00BCD4"
[username]
show_on_left = false
style_user = "bold #FF0033"
style_root = "bold #FF0033"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #FFD740"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #FF1A5E"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #75715E"
`
case "zerotwo":
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$cmd_duration$line_break$character"""
[character]
success_symbol = "[](bold #3B82F6)"
error_symbol = "[](bold #EF4444)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold #8B5CF6"
[username]
show_on_left = false
style_user = "bold #EC4899"
style_root = "bold #EF4444"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold #F472B6"
[git_status]
format = "[$all_status$ahead_behind]($style) "
style = "bold #EF4444"
conflicted = "!"
untracked = "?"
modified = "~"
staged = "[+]"
renamed = "»"
deleted = "-"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold #6B7280"
`
default:
return `[format]
before_format = "$"
format = """
$username$directory$git_branch$git_status$line_break$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[directory]
truncation_length = 3
truncation_symbol = "…/"
style = "bold cyan"
[username]
show_on_left = false
style_user = "bold red"
style_root = "bold red"
[git_branch]
symbol = " "
format = "on [$symbol$branch]($style)"
style = "bold yellow"
[cmd_duration]
min_time = 500
format = "took [$duration]($style)"
style = "bold bright-black"
`
}
}

View File

@@ -0,0 +1,771 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/version"
)
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"name": version.Name,
"version": version.Version,
"author": version.Author,
"sudo": os.Geteuid() == 0,
})
}
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
writeJSON(w, map[string]interface{}{
"system": s.scanResult.System,
})
}
func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) {
if s.scanResult == nil {
s.scanResult = scanner.ScanSystem()
}
type toolInfo struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Version string `json:"version"`
Path string `json:"path"`
}
tools := make([]toolInfo, len(s.scanResult.Tools))
for i, t := range s.scanResult.Tools {
tools[i] = toolInfo{
Name: t.Name,
Installed: t.Installed,
Version: t.Version,
Path: t.Path,
}
}
writeJSON(w, map[string]interface{}{
"tools": tools,
"total": len(tools),
})
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"profile": s.config.Profile,
"terminal": s.config.Terminal,
"bmad": s.config.BMAD,
})
}
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"providers": s.config.AI.Providers,
})
}
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
list, err := skills.List()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),
})
}
func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
writeJSON(w, map[string]interface{}{
"servers": servers,
})
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
servers := mcp.ScanServers()
home, _ := os.UserHomeDir()
editors := mcp.DetectInstalledEditors(home)
statuses := mcp.GetAllStatuses()
writeJSON(w, map[string]interface{}{
"servers": servers,
"configured": true,
"detected_editors": editors,
"statuses": statuses,
})
}
func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Editor string `json:"editor,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
if body.Editor != "" {
if err := mcp.ConfigureForEditor(s.config, body.Editor); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
if err := mcp.ConfigureAll(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleMCPStatus(w http.ResponseWriter, r *http.Request) {
statuses := mcp.GetAllStatuses()
writeJSON(w, map[string]interface{}{
"statuses": statuses,
})
}
func (s *Server) handleMCPRegistry(w http.ResponseWriter, r *http.Request) {
reg, err := mcp.LoadRegistry()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"registry": reg,
})
}
func (s *Server) handleLSPHealth(w http.ResponseWriter, r *http.Request) {
servers := lsp.ScanServers()
type healthInfo struct {
Name string `json:"name"`
Language string `json:"language"`
Installed bool `json:"installed"`
Healthy bool `json:"healthy"`
Detail string `json:"detail,omitempty"`
}
var results []healthInfo
for _, srv := range servers {
healthy, detail := lsp.HealthCheck(srv.Name)
results = append(results, healthInfo{
Name: srv.Name,
Language: srv.Language,
Installed: srv.Installed,
Healthy: healthy,
Detail: detail,
})
}
writeJSON(w, map[string]interface{}{
"servers": results,
})
}
func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
ProjectDir string `json:"project_dir,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
if body.ProjectDir == "" {
home, _ := os.UserHomeDir()
body.ProjectDir = home
}
results, err := lsp.AutoInstallForProject(body.ProjectDir)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"results": results,
})
}
func (s *Server) handleLSPEditorConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Editor string `json:"editor"`
Names []string `json:"names,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
allServers := lsp.ScanServers()
var selected []lsp.LSPServer
if len(body.Names) > 0 {
nameSet := map[string]bool{}
for _, n := range body.Names {
nameSet[n] = true
}
for _, srv := range allServers {
if nameSet[srv.Name] {
selected = append(selected, srv)
}
}
} else {
for _, srv := range allServers {
if srv.Installed {
selected = append(selected, srv)
}
}
}
config, err := lsp.GenerateEditorConfigs(selected, body.Editor, "")
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"editor": body.Editor,
"config": config,
})
}
func (s *Server) handleSkillValidate(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
}
skill, err := skills.Get(body.Name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
errs := skills.Validate(skill)
writeJSON(w, map[string]interface{}{
"name": body.Name,
"valid": len(errs) == 0,
"errors": errs,
"dependencies": skills.CheckDependencies(skill),
})
}
func (s *Server) handleSkillTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
SampleTask string `json:"sample_task,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
result := skills.DryRun(body.Name, body.SampleTask)
writeJSON(w, result)
}
func (s *Server) handleSkillExport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
ExportPath string `json:"export_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
if body.ExportPath == "" {
body.ExportPath = home + "/.muyue/exports/" + body.Name + ".md"
}
if err := skills.Export(body.Name, body.ExportPath); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok", "path": body.ExportPath})
}
func (s *Server) handleSkillImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
ImportPath string `json:"import_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
skill, err := skills.Import(body.ImportPath)
if err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if err := skills.Create(skill); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"status": "ok", "skill": skill.Name})
}
func (s *Server) handleDashboardStatus(w http.ResponseWriter, r *http.Request) {
mcpStatuses := mcp.GetAllStatuses()
lspServers := lsp.ScanServers()
skillList, _ := skills.List()
mcpHealthy := 0
mcpTotal := len(mcpStatuses)
for _, st := range mcpStatuses {
if st.Healthy {
mcpHealthy++
}
}
lspInstalled := 0
lspTotal := len(lspServers)
for _, srv := range lspServers {
if srv.Installed {
lspInstalled++
}
}
skillsDeployed := len(skillList)
var skillIssues []string
for _, sk := range skillList {
missing := skills.CheckDependencies(&sk)
if len(missing) > 0 {
for _, dep := range missing {
skillIssues = append(skillIssues, sk.Name+": missing "+dep.Type+" "+dep.Name)
}
}
}
writeJSON(w, map[string]interface{}{
"mcp": map[string]interface{}{
"total": mcpTotal,
"healthy": mcpHealthy,
"servers": mcpStatuses,
},
"lsp": map[string]interface{}{
"total": lspTotal,
"installed": lspInstalled,
"servers": lspServers,
},
"skills": map[string]interface{}{
"total": skillsDeployed,
"issues": skillIssues,
"deployed": skillList,
},
})
}
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
s.scanResult = scanner.ScanSystem()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
editors := scanner.ScanEditors()
writeJSON(w, map[string]interface{}{"editors": editors})
}
func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
type providerQuota struct {
Name string `json:"name"`
Active bool `json:"active"`
Healthy bool `json:"healthy"`
Data map[string]interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
var results []providerQuota
client := &http.Client{Timeout: 8 * time.Second}
for _, p := range s.config.AI.Providers {
q := providerQuota{Name: p.Name, Active: p.Active}
switch p.Name {
case "minimax":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if models, ok := data["model_remains"].([]interface{}); ok {
filtered := make([]map[string]interface{}, 0)
for _, m := range models {
if mm, ok := m.(map[string]interface{}); ok {
usage, _ := mm["current_interval_usage_count"].(float64)
total, _ := mm["current_interval_total_count"].(float64)
if total > 0 {
filtered = append(filtered, map[string]interface{}{
"model": mm["model_name"],
"used": usage,
"total": total,
"remaining": total - usage,
"weekly_used": mm["current_weekly_usage_count"],
"weekly_total": mm["current_weekly_total_count"],
})
}
}
}
q.Data = map[string]interface{}{"models": filtered}
q.Healthy = true
}
}
}
case "zai":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if d, ok := data["data"].(map[string]interface{}); ok {
if limits, ok := d["limits"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
for _, l := range limits {
if lm, ok := l.(map[string]interface{}); ok {
name := "Z.AI"
if model, ok := lm["model"].(string); ok && model != "" {
name = model
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
name = t
}
usage, _ := lm["usage"].(float64)
remaining, _ := lm["remaining"].(float64)
limitVal, hasLimit := lm["limit"].(float64)
total := usage + remaining
if hasLimit && limitVal > 0 {
total = limitVal
}
if total > 0 {
models = append(models, map[string]interface{}{
"model": name,
"used": usage,
"total": total,
"remaining": remaining,
})
}
}
}
if len(models) > 0 {
q.Data = map[string]interface{}{"models": models}
q.Healthy = true
}
}
}
}
}
case "claude", "anthropic":
// 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:
q.Error = "quota not supported"
}
results = append(results, q)
}
writeJSON(w, map[string]interface{}{"providers": results})
}
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir()
type cmdEntry struct {
Cmd string `json:"cmd"`
Shell string `json:"shell"`
}
var entries []cmdEntry
for _, histFile := range []string{".bash_history", ".zsh_history"} {
path := filepath.Join(home, histFile)
data, err := os.ReadFile(path)
if err != nil {
continue
}
shell := "bash"
if strings.Contains(histFile, "zsh") {
shell = "zsh"
}
lines := strings.Split(string(data), "\n")
start := len(lines) - 50
if start < 0 {
start = 0
}
for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, ": ") {
parts := strings.SplitN(line, ";", 2)
if len(parts) == 2 {
line = strings.TrimSpace(parts[1])
} else {
continue
}
}
if line == "" {
continue
}
base := strings.Fields(line)[0]
if len(base) < 2 {
continue
}
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
continue
}
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
}
}
max := 20
if len(entries) > max {
entries = entries[:max]
}
writeJSON(w, map[string]interface{}{"commands": entries})
}
func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) {
type proc struct {
PID int `json:"pid"`
Name string `json:"name"`
Command string `json:"command"`
CPU string `json:"cpu"`
Mem string `json:"mem"`
}
var procs []proc
editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"}
langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"}
interesting := append(editors, langs...)
interesting = append(interesting, "muyue")
cmd := exec.Command("ps", "aux")
out, err := cmd.Output()
if err != nil {
writeJSON(w, map[string]interface{}{"processes": procs})
return
}
lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
fullCmd := strings.Join(fields[10:], " ")
name := filepath.Base(fields[10])
matched := false
for _, pattern := range interesting {
if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) {
matched = true
break
}
}
if !matched {
continue
}
var pid int
fmt.Sscanf(fields[1], "%d", &pid)
procs = append(procs, proc{
PID: pid,
Name: name,
Command: fullCmd,
CPU: fields[2],
Mem: fields[3],
})
}
writeJSON(w, map[string]interface{}{"processes": procs})
}
type sysMetrics struct {
CPUPercent float64 `json:"cpu_percent"`
MemPercent float64 `json:"mem_percent"`
MemUsedMB float64 `json:"mem_used_mb"`
MemTotalMB float64 `json:"mem_total_mb"`
NetRxKBs float64 `json:"net_rx_kbs"`
NetTxKBs float64 `json:"net_tx_kbs"`
}
var (
lastCPU [2]float64
lastNet [2]float64
lastNetTs time.Time
lastCPUSet bool
)
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
m := sysMetrics{}
// CPU from /proc/stat
if data, err := os.ReadFile("/proc/stat"); err == nil {
line := strings.Split(string(data), "\n")[0]
fields := strings.Fields(line)
if len(fields) >= 5 {
var idle, total float64
for i := 1; i < len(fields) && i <= 4; i++ {
var v float64
fmt.Sscanf(fields[i], "%f", &v)
total += v
if i == 4 {
idle = v
}
}
if lastCPUSet {
dIdle := idle - lastCPU[0]
dTotal := total - lastCPU[1]
if dTotal > 0 {
m.CPUPercent = (1 - dIdle/dTotal) * 100
}
}
lastCPU = [2]float64{idle, total}
lastCPUSet = true
}
}
// Memory from /proc/meminfo
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
var memTotal, memAvailable float64
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
var v float64
fmt.Sscanf(fields[1], "%f", &v)
switch fields[0] {
case "MemTotal:":
memTotal = v
case "MemAvailable:":
memAvailable = v
}
}
if memTotal > 0 {
m.MemTotalMB = memTotal / 1024
m.MemUsedMB = (memTotal - memAvailable) / 1024
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
}
}
// Network from /proc/net/dev
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
var rxBytes, txBytes float64
for _, line := range strings.Split(string(data), "\n")[2:] {
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
iface := strings.TrimSuffix(fields[0], ":")
if iface == "lo" {
continue
}
var rx, tx float64
fmt.Sscanf(fields[1], "%f", &rx)
fmt.Sscanf(fields[9], "%f", &tx)
rxBytes += rx
txBytes += tx
}
now := time.Now()
if !lastNetTs.IsZero() {
elapsed := now.Sub(lastNetTs).Seconds()
if elapsed > 0 {
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
if m.NetRxKBs < 0 {
m.NetRxKBs = 0
}
if m.NetTxKBs < 0 {
m.NetTxKBs = 0
}
}
}
lastNet = [2]float64{rxBytes, txBytes}
lastNetTs = now
}
writeJSON(w, m)
}

View File

@@ -0,0 +1,269 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/skills"
)
type SavedConversation struct {
ID string `json:"id"`
Title string `json:"title"`
Summary string `json:"summary,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Messages []MessageEntry `json:"messages,omitempty"`
}
type MessageEntry struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type conversationsStore struct {
Path string
Items []SavedConversation
}
func conversationsPath() string {
dir, _ := config.ConfigDir()
return filepath.Join(dir, "conversations.json")
}
func listConversations() ([]SavedConversation, error) {
path := conversationsPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []SavedConversation{}, nil
}
return nil, err
}
var store conversationsStore
if err := json.Unmarshal(data, &store); err != nil {
return []SavedConversation{}, nil
}
return store.Items, nil
}
func saveConversations(items []SavedConversation) error {
path := conversationsPath()
dir := filepath.Dir(path)
os.MkdirAll(dir, 0755)
data, err := json.MarshalIndent(struct {
Items []SavedConversation `json:"items"`
}{Items: items}, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
convs, err := listConversations()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
conv := s.convStore.Get()
tokenInfo := s.convStore.ApproxTokenCountDetailed()
writeJSON(w, map[string]interface{}{
"conversations": convs,
"current_messages": conv,
"tokens": tokenInfo.total,
"tokens_by_role": tokenInfo.byRole,
"summary": s.convStore.GetSummary(),
})
}
func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/conversations/")
id = strings.TrimPrefix(id, "/")
if id == "" {
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "cleared"})
return
}
convs, err := listConversations()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
filtered := make([]SavedConversation, 0, len(convs))
found := false
for _, c := range convs {
if c.ID == id {
found = true
continue
}
filtered = append(filtered, c)
}
if !found {
writeError(w, "conversation not found", http.StatusNotFound)
return
}
if err := saveConversations(filtered); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "deleted"})
}
func (s *Server) handleSearchConversations(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
results := s.convStore.Search(query)
writeJSON(w, map[string]interface{}{
"query": query,
"results": results,
"count": len(results),
})
}
func (s *Server) handleExportConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
format := r.URL.Query().Get("format")
if format == "markdown" || format == "md" {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Write([]byte(s.convStore.ExportMarkdown()))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(s.convStore.ExportJSON()))
}
func (s *Server) handleLSPInstall(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 := lsp.InstallServer(body.Name); err != nil {
writeJSON(w, map[string]interface{}{
"success": false,
"error": err.Error(),
})
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"server": body.Name,
})
}
func (s *Server) handleSkillsDeploy(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 != "" {
skill, err := skills.Get(body.Name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
if err := skills.Deploy(skill); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "deployed", "skill": body.Name})
return
}
if err := skills.DeployAll(); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "all deployed"})
}
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
cfg, err := config.Load()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"connections": cfg.Terminal.SSH,
})
}
func (s *Server) handleSSHTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Host == "" || body.User == "" {
writeError(w, "host and user are required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
writeJSON(w, map[string]interface{}{
"success": true,
"message": "SSH connection test not implemented (requires net.DialTimeout)",
})
}

View File

@@ -0,0 +1,292 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
type ShellChatRequest struct {
Message string `json:"message"`
Context string `json:"context,omitempty"`
Cwd string `json:"cwd,omitempty"`
Platform string `json:"platform,omitempty"`
Stream bool `json:"stream"`
}
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
if s.shellConvStore.AtLimit() {
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
return
}
var req ShellChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if req.Message == "" {
writeError(w, "message is required", http.StatusBadRequest)
return
}
s.shellConvStore.Add("user", req.Message)
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
if req.Stream {
s.handleShellChatStreamV2(w, orb)
} else {
s.handleShellChatNonStreamV2(w, orb)
}
}
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
var sb strings.Builder
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
RÈGLES STRICTES:
- Tu ne peux JAMAIS exécuter de commande ou de code
- Tu ne peux que analyser, expliquer, et proposer des solutions
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
`)
analysis := LoadSystemAnalysis()
if analysis != "" {
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
sb.WriteString(analysis)
sb.WriteString("\n=== FIN DE L'ANALYSE ===\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")
}
return sb.String()
}
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
// Rebuild history into orchestrator
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] { // all except last user msg
if m.Role == "system" {
continue
}
// Pre-load orchestrator history
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
lastUserMsg := history[len(history)-1].Content
var finalContent string
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
finalContent = chunk
sseWriter.Write(map[string]interface{}{"content": chunk})
if canFlush {
flusher.Flush()
}
})
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
content := result
if content == "" {
content = finalContent
}
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
sseWriter.Write(map[string]interface{}{
"done": "true",
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] {
if m.Role == "system" {
continue
}
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
lastUserMsg := history[len(history)-1].Content
result, err := orb.Send(lastUserMsg)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
writeJSON(w, map[string]interface{}{
"content": result,
"tokens": s.shellConvStore.ApproxTokens(),
})
}
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.shellConvStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.shellConvStore.ApproxTokens(),
"max_tokens": shellMaxTokens,
"at_limit": s.shellConvStore.AtLimit(),
})
}
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.shellConvStore.Clear()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": 0,
})
}
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var sysInfo strings.Builder
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
if hostname, err := os.Hostname(); err == nil {
sysInfo.WriteString("Hostname: " + hostname + "\n")
}
if user := os.Getenv("USER"); user != "" {
sysInfo.WriteString("User: " + user + "\n")
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "model name") {
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
break
}
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
}
}
}
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
lines := strings.Split(string(out), "\n")
if len(lines) >= 2 {
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
}
}
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
lines := strings.Split(string(out), "\n")
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
for i := 1; i < len(lines) && i <= 10; i++ {
fields := strings.Fields(lines[i])
if len(fields) >= 11 {
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
}
}
}
if s.scanResult != nil {
sysInfo.WriteString("\nOutils installés:\n")
for _, t := range s.scanResult.Tools {
status := "✗"
if t.Installed {
status = "✓"
}
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
}
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
1. Un résumé de l'état du système
2. Les points d'attention (performance, sécurité, configuration)
3. Des recommandations spécifiques d'optimisation
4. Les outils manquants qui pourraient être utiles
5. L'état du réseau et des connexions
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
` + sysInfo.String()
result, err := orb.Send(analysisPrompt)
if err != nil {
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
return
}
SaveSystemAnalysis(result)
writeJSON(w, map[string]interface{}{
"status": "ok",
"analysis": result,
})
}
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

@@ -0,0 +1,44 @@
package api
import (
"encoding/json"
"net/http"
"os/exec"
)
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Command == "" {
writeError(w, "no command", http.StatusBadRequest)
return
}
shell := detectShell()
cmd := exec.Command(shell, "-c", body.Command)
if body.Cwd != "" {
cmd.Dir = body.Cwd
}
out, err := cmd.CombinedOutput()
type termResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
result := termResult{Output: string(out)}
if err != nil {
result.Error = err.Error()
}
writeJSON(w, result)
}

View File

@@ -0,0 +1,66 @@
package api
import (
"context"
"encoding/json"
"testing"
"github.com/muyue/muyue/internal/agent"
)
func TestHandleToolCall(t *testing.T) {
// Test unknown tool returns error
registry := agent.NewRegistry()
// Register a test tool
testTool, _ := agent.NewTool[struct{ Command string }]("test_tool", "Test tool", func(ctx context.Context, params struct{ Command string }) (agent.ToolResponse, error) {
return agent.TextResponse("executed: " + params.Command), nil
})
registry.Register(testTool)
// Test executing known tool
resp, err := registry.Execute(context.Background(), agent.ToolCall{
ID: "test-id",
Name: "test_tool",
Arguments: json.RawMessage(`{"Command": "hello"}`),
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if resp.IsError {
t.Errorf("expected no error, got error response")
}
// Test executing unknown tool
resp, err = registry.Execute(context.Background(), agent.ToolCall{
ID: "test-id",
Name: "unknown_tool",
Arguments: json.RawMessage(`{}`),
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !resp.IsError {
t.Errorf("expected error for unknown tool")
}
}
func TestCleanThinkingTags(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello world", "hello world"},
{"<think>thinking</think>hello", "hello"},
{"<Think>THINKING</Think>hello", "hello"},
{"hello <think>thinking</think> world", "hello world"},
{"no tags here", "no tags here"},
}
for _, tc := range tests {
result := cleanThinkingTags(tc.input)
if result != tc.expected {
t.Errorf("cleanThinkingTags(%q) = %q, want %q", tc.input, result, tc.expected)
}
}
}

View File

@@ -0,0 +1,119 @@
package api
import (
"encoding/json"
"net/http"
"sync"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
)
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
type updateInfo struct {
Tool string `json:"tool"`
Current string `json:"current"`
Latest string `json:"latest"`
NeedsUpdate bool `json:"needsUpdate"`
Error string `json:"error,omitempty"`
}
updates := make([]updateInfo, len(statuses))
for i, u := range statuses {
updates[i] = updateInfo{
Tool: u.Tool,
Current: u.Current,
Latest: u.Latest,
NeedsUpdate: u.NeedsUpdate,
Error: u.Error,
}
}
writeJSON(w, map[string]interface{}{
"updates": updates,
})
}
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tools []string `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if len(body.Tools) == 0 {
writeError(w, "no tools specified", http.StatusBadRequest)
return
}
results := make([]installer.InstallResult, len(body.Tools))
var wg sync.WaitGroup
var mu sync.Mutex
for i, tool := range body.Tools {
wg.Add(1)
go func(idx int, name string) {
defer wg.Done()
inst := installer.New(s.config)
res := inst.InstallTool(name)
mu.Lock()
results[idx] = res
mu.Unlock()
}(i, tool)
}
wg.Wait()
writeJSON(w, map[string]interface{}{
"status": "done",
"tools": body.Tools,
"results": results,
})
}
func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Tool string `json:"tool"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
if body.Tool != "" {
for _, u := range statuses {
if u.Tool == body.Tool && u.NeedsUpdate {
updater.RunAutoUpdate([]updater.UpdateStatus{u})
}
}
writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool})
return
}
needsUpdate := make([]updater.UpdateStatus, 0)
for _, u := range statuses {
if u.NeedsUpdate {
needsUpdate = append(needsUpdate, u)
}
}
if len(needsUpdate) > 0 {
updater.RunAutoUpdate(needsUpdate)
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"updated": len(needsUpdate),
})
}

View File

@@ -0,0 +1,85 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/muyue/muyue/internal/agent"
)
type ToolCallRequest struct {
Tool string `json:"tool"`
Args json.RawMessage `json:"args"`
}
type ToolResult struct {
Success bool `json:"success"`
Tool string `json:"tool"`
Result *toolResponseData `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type toolResponseData struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
Meta map[string]string `json:"meta,omitempty"`
}
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req ToolCallRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if req.Tool == "" {
writeError(w, "tool is required", http.StatusBadRequest)
return
}
ctx := context.Background()
call := agent.ToolCall{
ID: generateMsgID(),
Name: req.Tool,
Arguments: req.Args,
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
writeJSON(w, ToolResult{
Success: false,
Tool: req.Tool,
Error: execErr.Error(),
})
return
}
writeJSON(w, ToolResult{
Success: true,
Tool: req.Tool,
Result: &toolResponseData{
Content: result.Content,
IsError: result.IsError,
Meta: result.Meta,
},
})
}
func (s *Server) handleToolList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
tools := s.agentRegistry.All()
writeJSON(w, map[string]interface{}{
"tools": tools,
"count": len(tools),
})
}

View File

@@ -0,0 +1,258 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/workflow"
)
func (s *Server) handleWorkflowCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
}
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
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf := engine.Create(body.Name, body.Description, body.Type, []workflow.Step{})
writeJSON(w, wf)
}
func (s *Server) handleWorkflowList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
workflows := engine.List()
writeJSON(w, map[string]interface{}{
"workflows": workflows,
"count": len(workflows),
})
}
func (s *Server) handleWorkflowGet(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf, ok := engine.Get(id)
if !ok {
writeError(w, "workflow not found", http.StatusNotFound)
return
}
writeJSON(w, wf)
}
func (s *Server) handleWorkflowDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
if err := engine.Delete(id); err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]string{"status": "deleted"})
}
func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Goal string `json:"goal"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Goal == "" {
writeError(w, "goal is required", http.StatusBadRequest)
return
}
planner, err := workflow.NewPlanner(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
steps, err := planner.GeneratePlan(context.Background(), body.Goal)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps)
writeJSON(w, wf)
}
func (s *Server) handleWorkflowExecute(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/execute/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
wf, ok := engine.Get(id)
if !ok {
writeError(w, "workflow not found", http.StatusNotFound)
return
}
if r.URL.Query().Get("stream") == "true" {
s.handleWorkflowExecuteStream(w, engine, wf)
} else {
err := engine.Execute(context.Background(), id, nil)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
wf, _ = engine.Get(id)
writeJSON(w, wf)
}
}
func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *workflow.Engine, wf *workflow.Workflow) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
writeSSE := func(data map[string]interface{}) {
b, _ := json.Marshal(data)
w.Write([]byte("data: " + string(b) + "\n\n"))
if canFlush {
flusher.Flush()
}
}
go func() {
engine.Execute(context.Background(), wf.ID, func(step *workflow.Step, event string) {
writeSSE(map[string]interface{}{
"event": event,
"step": step,
})
})
wf, _ = engine.Get(wf.ID)
writeSSE(map[string]interface{}{
"event": "workflow_done",
"status": wf.Status,
"workflow": wf,
})
}()
}
func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/workflow/approve/")
if id == "" {
writeError(w, "workflow id required", http.StatusBadRequest)
return
}
var body struct {
StepID string `json:"step_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
engine := s.workflowEngine
if engine == nil {
engine, _ = workflow.NewEngine(s.agentRegistry)
}
if err := engine.ApproveStep(id, body.StepID); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "approved"})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

143
internal/api/server.go Normal file
View File

@@ -0,0 +1,143 @@
package api
import (
"encoding/json"
"log"
"net/http"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow"
)
type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
}
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
mux: http.NewServeMux(),
}
// Auto-initialize config if nil or if no config file exists on disk
if cfg == nil || !config.Exists() {
defaultCfg := config.Default()
if cfg != nil {
// Preserve any user-provided settings from cfg
defaultCfg.Profile = cfg.Profile
defaultCfg.AI = cfg.AI
defaultCfg.Tools = cfg.Tools
defaultCfg.BMAD = cfg.BMAD
defaultCfg.Terminal = cfg.Terminal
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
log.Printf("config: initial save failed: %v", err)
}
cfg = defaultCfg
}
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore()
s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.routes()
return s
}
func (s *Server) routes() {
s.mux.HandleFunc("/api/info", s.handleInfo)
s.mux.HandleFunc("/api/system", s.handleSystem)
s.mux.HandleFunc("/api/tools", s.handleTools)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/providers", s.handleProviders)
s.mux.HandleFunc("/api/skills", s.handleSkills)
s.mux.HandleFunc("/api/lsp", s.handleLSP)
s.mux.HandleFunc("/api/mcp", s.handleMCP)
s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/editors", s.handleEditors)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS)
s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions)
s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete)
s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes)
s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
s.mux.HandleFunc("/api/config/reset", s.handleResetConfig)
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
s.mux.HandleFunc("/api/chat/summarize", s.handleChatSummarize)
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
s.mux.HandleFunc("/api/workflow/plan", s.handleWorkflowPlan)
s.mux.HandleFunc("/api/workflow/execute/", s.handleWorkflowExecute)
s.mux.HandleFunc("/api/workflow/approve/", s.handleWorkflowApprove)
s.mux.HandleFunc("/api/conversations", s.handleListConversations)
s.mux.HandleFunc("/api/conversations/search", s.handleSearchConversations)
s.mux.HandleFunc("/api/conversations/export", s.handleExportConversation)
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
s.mux.HandleFunc("/api/mcp/status", s.handleMCPStatus)
s.mux.HandleFunc("/api/mcp/registry", s.handleMCPRegistry)
s.mux.HandleFunc("/api/lsp/health", s.handleLSPHealth)
s.mux.HandleFunc("/api/lsp/auto-install", s.handleLSPAutoInstall)
s.mux.HandleFunc("/api/lsp/editor-config", s.handleLSPEditorConfig)
s.mux.HandleFunc("/api/skills/validate", s.handleSkillValidate)
s.mux.HandleFunc("/api/skills/test", s.handleSkillTest)
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
s.mux.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
s.mux.ServeHTTP(w, r)
}

View File

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

350
internal/api/terminal.go Normal file
View File

@@ -0,0 +1,350 @@
package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/creack/pty/v2"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/config"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
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
default:
return false
}
},
}
type wsMessage struct {
Type string `json:"type"`
Data string `json:"data"`
Rows uint16 `json:"rows,omitempty"`
Cols uint16 `json:"cols,omitempty"`
}
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
log.Printf("terminal: read init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
log.Printf("terminal: init message received: %s", string(raw))
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"})
return
}
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
return
}
if sshConf.Port == 0 {
sshConf.Port = 22
}
sshArgs := []string{
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshConf.KeyPath != "" {
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
}
if sshConf.Port != 22 {
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
cmd = exec.Command("ssh", sshArgs...)
} else {
shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
if shell == "" {
shell = detectShell()
log.Printf("terminal: auto-detected shell=%q", shell)
}
if shell == "" {
log.Printf("terminal: no shell detected, falling back to /bin/sh")
shell = "/bin/sh"
}
if path, err := exec.LookPath(shell); err == nil {
shell = path
log.Printf("terminal: resolved shell path=%q", shell)
}
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)})
return
}
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
ptmx, err := pty.Start(cmd)
if err != nil {
log.Printf("terminal: pty start failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
log.Printf("terminal: pty started successfully")
defer func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
var once sync.Once
cleanup := func() {
once.Do(func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
})
}
go func() {
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if err != nil {
cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
if err := conn.WriteJSON(wsMessage{
Type: "output",
Data: string(buf[:n]),
}); err != nil {
cleanup()
return
}
}
}()
conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Time{})
for {
_, raw, err := conn.ReadMessage()
if err != nil {
cleanup()
return
}
var msg wsMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "input":
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
cleanup()
return
}
case "resize":
if msg.Rows > 0 && msg.Cols > 0 {
pty.Setsize(ptmx, &pty.Winsize{
Rows: msg.Rows,
Cols: msg.Cols,
})
}
}
}
}
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
writeJSON(w, map[string]interface{}{
"ssh": s.config.Terminal.SSH,
"system": detectSystemTerminals(),
})
return
}
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" || body.Host == "" {
writeError(w, "name and host required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}
}
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i, c := range s.config.Terminal.SSH {
if c.Name == name {
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
found = true
break
}
}
if !found {
writeError(w, "not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Default Shell",
"shell": detectShell(),
})
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL",
"shell": "wsl",
})
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell",
"shell": "powershell",
})
}
if _, err := exec.LookPath("pwsh"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell Core",
"shell": "pwsh",
})
}
if _, err := exec.LookPath("cmd"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Command Prompt",
"shell": "cmd",
})
}
}
return terminals
}

View File

@@ -2,59 +2,137 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/muyue/muyue/internal/secret"
"github.com/muyue/muyue/internal/version"
"gopkg.in/yaml.v3"
)
type Profile struct {
Name string `yaml:"name"`
Pseudo string `yaml:"pseudo"`
Email string `yaml:"email"`
Languages []string `yaml:"languages"`
Name string `yaml:"name" json:"name"`
Pseudo string `yaml:"pseudo" json:"pseudo"`
Email string `yaml:"email" json:"email"`
Languages []string `yaml:"languages" json:"languages"`
Preferences struct {
Editor string `yaml:"editor"`
Shell string `yaml:"shell"`
Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"`
} `yaml:"preferences"`
Editor string `yaml:"editor" json:"editor"`
Shell string `yaml:"shell" json:"shell"`
Theme string `yaml:"theme" json:"theme"`
DefaultAI string `yaml:"default_ai" json:"default_ai"`
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
CheckOnStart bool `yaml:"check_on_start" json:"check_on_start"`
Language string `yaml:"language" json:"language"`
KeyboardLayout string `yaml:"keyboard_layout" json:"keyboard_layout"`
} `yaml:"preferences" json:"preferences"`
}
type AIProvider struct {
Name string `yaml:"name"`
APIKey string `yaml:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty"`
Model string `yaml:"model"`
Active bool `yaml:"active"`
Name string `yaml:"name" json:"name"`
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
Model string `yaml:"model" json:"model"`
Active bool `yaml:"active" json:"active"`
}
type ToolConfig struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
AutoUpdate bool `yaml:"auto_update"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
AutoUpdate bool `yaml:"auto_update" json:"auto_update"`
}
type SSHConnection struct {
Name string `yaml:"name" json:"name"`
Host string `yaml:"host" json:"host"`
Port int `yaml:"port" json:"port"`
User string `yaml:"user" json:"user"`
Password string `yaml:"password,omitempty" json:"password,omitempty"`
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
}
type MuyueConfig struct {
Version string `yaml:"version"`
Profile Profile `yaml:"profile"`
Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"`
AI struct {
Providers []AIProvider `yaml:"providers"`
} `yaml:"ai"`
Tools []ToolConfig `yaml:"tools"`
Providers []AIProvider `yaml:"providers" json:"providers"`
} `yaml:"ai" json:"ai"`
Tools []ToolConfig `yaml:"tools" json:"tools"`
BMAD struct {
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Global bool `yaml:"global"`
} `yaml:"bmad"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
Global bool `yaml:"global" json:"global"`
} `yaml:"bmad" json:"bmad"`
Terminal struct {
CustomPrompt bool `yaml:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme"`
} `yaml:"terminal"`
CustomPrompt bool `yaml:"custom_prompt" json:"custom_prompt"`
PromptTheme string `yaml:"prompt_theme" json:"prompt_theme"`
SSH []SSHConnection `yaml:"ssh" json:"ssh"`
FontSize int `yaml:"font_size" json:"font_size"`
FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"`
}
type TerminalTheme struct {
Name string `yaml:"name"`
Background string `yaml:"background"`
Foreground string `yaml:"foreground"`
Cursor string `yaml:"cursor"`
Black string `yaml:"black"`
Red string `yaml:"red"`
Green string `yaml:"green"`
Yellow string `yaml:"yellow"`
Blue string `yaml:"blue"`
Magenta string `yaml:"magenta"`
Cyan string `yaml:"cyan"`
White string `yaml:"white"`
}
var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
"default": {
Name: "Default", Background: "#0A0A0C", Foreground: "#EAE0E2",
Cursor: "#FF0033", Black: "#0A0A0C", Red: "#FF0033",
Green: "#00E676", Yellow: "#FFD740", Blue: "#448AFF",
Magenta: "#FF1A5E", Cyan: "#00BCD4", White: "#EAE0E2",
},
"monokai": {
Name: "Monokai", Background: "#272822", Foreground: "#F8F8F2",
Cursor: "#F8F8F0", Black: "#272822", Red: "#F92672",
Green: "#A6E22E", Yellow: "#E6DB74", Blue: "#66D9EF",
Magenta: "#AE81FF", Cyan: "#A1EFE4", White: "#F8F8F2",
},
"gruvbox": {
Name: "Gruvbox", Background: "#282828", Foreground: "#EBDBB2",
Cursor: "#FB4934", Black: "#282828", Red: "#CC241D",
Green: "#98971A", Yellow: "#D79921", Blue: "#458588",
Magenta: "#B16286", Cyan: "#689D6A", White: "#EBDBB2",
},
"nord": {
Name: "Nord", Background: "#2E3440", Foreground: "#D8DEE9",
Cursor: "#D8DEE9", Black: "#2E3440", Red: "#BF616A",
Green: "#A3BE8C", Yellow: "#EBCB8B", Blue: "#81A1C1",
Magenta: "#B48EAD", Cyan: "#88C0D0", White: "#D8DEE9",
},
"solarized-dark": {
Name: "Solarized Dark", Background: "#002B36", Foreground: "#839496",
Cursor: "#D33682", Black: "#002B36", Red: "#DC322F",
Green: "#859900", Yellow: "#B58900", Blue: "#268BD2",
Magenta: "#D33682", Cyan: "#2AA198", White: "#FDF6E3",
},
"dracula": {
Name: "Dracula", Background: "#282A36", Foreground: "#F8F8F2",
Cursor: "#F8F8F2", Black: "#282A36", Red: "#FF5555",
Green: "#50FA7B", Yellow: "#F1FA8C", Blue: "#BD93F9",
Magenta: "#FF79C6", Cyan: "#8BE9FD", White: "#F8F8F2",
},
}
func GetTerminalTheme(name string) TerminalTheme {
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
return theme
}
return DEFAULT_TERMINAL_THEMES["default"]
}
func ConfigDir() (string, error) {
@@ -67,7 +145,9 @@ func ConfigDir() (string, error) {
legacyDir := filepath.Join(homeDir(), ".muyue")
if _, err := os.Stat(legacyDir); err == nil {
if _, err := os.Stat(dir); err != nil {
os.Rename(legacyDir, dir)
if err := os.Rename(legacyDir, dir); err != nil {
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
}
}
}
@@ -167,7 +247,7 @@ func Save(cfg *MuyueConfig) error {
func Default() *MuyueConfig {
cfg := &MuyueConfig{
Version: "0.1.0",
Version: version.Version,
Profile: Profile{
Name: "",
Pseudo: "muyue",
@@ -179,6 +259,8 @@ func Default() *MuyueConfig {
cfg.Profile.Preferences.AutoUpdate = true
cfg.Profile.Preferences.CheckOnStart = true
cfg.Profile.Preferences.Theme = "charm"
cfg.Profile.Preferences.Language = "fr"
cfg.Profile.Preferences.KeyboardLayout = "azerty"
cfg.AI.Providers = []AIProvider{
{

View File

@@ -4,12 +4,14 @@ import (
"os"
"path/filepath"
"testing"
"github.com/muyue/muyue/internal/version"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.Version != "0.1.0" {
t.Errorf("Expected version 0.1.0, got %s", cfg.Version)
if cfg.Version != version.Version {
t.Errorf("Expected version %s, got %s", version.Version, cfg.Version)
}
if cfg.Profile.Pseudo != "muyue" {
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)

View File

@@ -1,173 +0,0 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
)
type Daemon struct {
config *config.MuyueConfig
interval time.Duration
stopCh chan struct{}
mu sync.RWMutex
running bool
lastCheck time.Time
lastStatus []updater.UpdateStatus
logs []string
onUpdate func([]updater.UpdateStatus)
}
func NewDaemon(cfg *config.MuyueConfig, interval time.Duration) *Daemon {
if interval == 0 {
interval = 1 * time.Hour
}
return &Daemon{
config: cfg,
interval: interval,
stopCh: make(chan struct{}, 1),
logs: []string{},
}
}
func (d *Daemon) OnUpdate(fn func([]updater.UpdateStatus)) {
d.onUpdate = fn
}
func (d *Daemon) Start() error {
d.mu.Lock()
if d.running {
d.mu.Unlock()
return fmt.Errorf("daemon already running")
}
d.running = true
d.mu.Unlock()
d.log("daemon started (interval: %s)", d.interval)
go d.run()
return nil
}
func (d *Daemon) Stop() {
d.mu.Lock()
defer d.mu.Unlock()
if !d.running {
return
}
d.running = false
d.stopCh <- struct{}{}
d.log("daemon stopped")
}
func (d *Daemon) IsRunning() bool {
d.mu.RLock()
defer d.mu.RUnlock()
return d.running
}
func (d *Daemon) LastCheck() time.Time {
d.mu.RLock()
defer d.mu.RUnlock()
return d.lastCheck
}
func (d *Daemon) LastStatus() []updater.UpdateStatus {
d.mu.RLock()
defer d.mu.RUnlock()
return d.lastStatus
}
func (d *Daemon) Logs() []string {
d.mu.RLock()
defer d.mu.RUnlock()
return d.logs
}
func (d *Daemon) TriggerCheck() []updater.UpdateStatus {
return d.checkUpdates()
}
func (d *Daemon) run() {
d.checkUpdates()
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
d.checkUpdates()
case <-d.stopCh:
return
}
}
}
func (d *Daemon) checkUpdates() []updater.UpdateStatus {
d.log("checking for updates...")
result := scanner.ScanSystem()
statuses := updater.CheckUpdates(result)
needsUpdate := false
for _, s := range statuses {
if s.NeedsUpdate {
needsUpdate = true
d.log("update available: %s %s -> %s", s.Tool, s.Current, s.Latest)
}
}
if !needsUpdate {
d.log("all tools up to date")
}
d.mu.Lock()
d.lastCheck = time.Now()
d.lastStatus = statuses
d.mu.Unlock()
if d.config.Profile.Preferences.AutoUpdate && needsUpdate {
d.log("auto-updating...")
results := updater.RunAutoUpdate(statuses)
for _, r := range results {
if r.Message != "" {
d.log(" %s: %s", r.Tool, r.Message)
}
}
}
if d.onUpdate != nil {
d.onUpdate(statuses)
}
return statuses
}
func (d *Daemon) log(format string, args ...interface{}) {
msg := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), fmt.Sprintf(format, args...))
d.mu.Lock()
d.logs = append(d.logs, msg)
if len(d.logs) > 500 {
d.logs = d.logs[250:]
}
d.mu.Unlock()
}
func RunStandalone(cfg *config.MuyueConfig) {
d := NewDaemon(cfg, 1*time.Hour)
d.Start()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
d.Stop()
}

131
internal/desktop/desktop.go Normal file
View File

@@ -0,0 +1,131 @@
package desktop
import (
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/muyue/muyue/internal/api"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/version"
"github.com/muyue/muyue/web"
)
type options struct {
port int
noOpen bool
}
type option func(*options)
func withPort(port int) option {
return func(o *options) { o.port = port }
}
func withNoOpen(noOpen bool) option {
return func(o *options) { o.noOpen = noOpen }
}
func parseFlags(args []string) []option {
var opts []option
for _, arg := range args {
switch {
case arg == "--no-open":
opts = append(opts, withNoOpen(true))
case strings.HasPrefix(arg, "--port="):
if p, err := strconv.Atoi(strings.TrimPrefix(arg, "--port=")); err == nil {
opts = append(opts, withPort(p))
}
case arg == "--port":
// handled as prefix case
}
}
return opts
}
func Run(cfg *config.MuyueConfig, args []string) error {
o := options{}
for _, opt := range parseFlags(args) {
opt(&o)
}
log.Printf("%s Desktop v%s", version.Name, version.Version)
srv := api.NewServer(cfg)
frontendFS, err := fs.Sub(web.Assets, "dist")
if err != nil {
return fmt.Errorf("frontend assets: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/api/", srv)
mux.Handle("/", spaHandler(http.FileServer(http.FS(frontendFS))))
addr := fmt.Sprintf("127.0.0.1:%d", o.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("bind %s: %w", addr, err)
}
port := listener.Addr().(*net.TCPAddr).Port
go func() {
if err := http.Serve(listener, mux); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
log.Printf("Listening on %s", url)
if !o.noOpen {
openBrowser(url)
log.Printf("Opened browser")
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
return nil
}
func spaHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path != "/" && !strings.Contains(path, ".") {
r.URL.Path = "/"
}
next.ServeHTTP(w, r)
})
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch {
case exists("xdg-open"):
cmd = exec.Command("xdg-open", url)
case exists("open"):
cmd = exec.Command("open", url)
case exists("cmd"):
cmd = exec.Command("cmd", "/c", "start", url)
default:
fmt.Printf("Open manually: %s\n", url)
return
}
_ = cmd.Start()
}
func exists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult {
return InstallResult{Tool: "bmad", Success: false, Message: err.Error()}
}
bmadDir := configDir + "/bmad"
bmadDir := filepath.Join(configDir, "bmad")
os.MkdirAll(bmadDir, 0755)
cmd := exec.Command("npx", "bmad-method@latest", "install",
@@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult {
}
home, _ := os.UserHomeDir()
goDir := home + "/.local/go"
goDir := filepath.Join(home, ".local", "go")
cmd := exec.Command("bash", "-c", fmt.Sprintf(
"curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -",
@@ -290,56 +291,16 @@ func (i *Installer) installGit() InstallResult {
return InstallResult{Tool: "git", Success: true, Message: "installed and configured"}
}
func (i *Installer) SetupPrompt() error {
starshipPath, err := exec.LookPath("starship")
if err != nil {
return fmt.Errorf("starship not found")
}
rcFile := i.getRCFile()
line := fmt.Sprintf("eval \"$(" + starshipPath + " init %s)\"", i.system.Shell)
appendLine(rcFile, line)
configDir, _ := config.ConfigDir()
starshipConfig := `format = """
$directory\
$git_branch\
$git_status\
$git_metrics\
$nodejs\
$python\
$golang\
$rust\
$cmd_duration\
$line_break\
$character"""
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
[git_branch]
format = "[$symbol$branch]($style) "
[git_status]
format = '([$all_status$ahead_behind]($style) )'
`
configPath := configDir + "/starship.toml"
os.MkdirAll(configDir, 0755)
os.WriteFile(configPath, []byte(starshipConfig), 0644)
return nil
}
func (i *Installer) getRCFile() string {
func (i *Installer) getRCFile() string {
home, _ := os.UserHomeDir()
switch i.system.Shell {
case "zsh":
return home + "/.zshrc"
return filepath.Join(home, ".zshrc")
case "fish":
return home + "/.config/fish/config.fish"
return filepath.Join(home, ".config", "fish", "config.fish")
default:
return home + "/.bashrc"
return filepath.Join(home, ".bashrc")
}
}
@@ -380,7 +341,7 @@ func (i *Installer) installUv() InstallResult {
return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))}
}
rcFile := i.getRCFile()
appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH")
appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH")
return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"}
case platform.Windows:
cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex")

View File

@@ -6,8 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
"github.com/muyue/muyue/internal/config"
"strings"
"time"
)
type LSPServer struct {
@@ -15,12 +15,11 @@ type LSPServer struct {
Language string `json:"language"`
Command string `json:"command"`
InstallCmd string `json:"install_cmd"`
ConfigFile string `json:"config_file"`
Installed bool `json:"installed"`
}
type LSPConfig struct {
Servers []LSPServer `json:"servers"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
}
var knownServers = []LSPServer{
@@ -48,27 +47,131 @@ func ScanServers() []LSPServer {
servers[i] = s
_, err := exec.LookPath(s.Command)
servers[i].Installed = err == nil
servers[i].Version = getInstalledLSPVersion(s.Name)
}
regServers, err := scanLSPRegistryServers()
if err == nil {
servers = append(servers, regServers...)
}
return servers
}
func scanLSPRegistryServers() ([]LSPServer, error) {
reg, err := LoadLSPRegistry()
if err != nil {
return nil, err
}
knownNames := map[string]bool{}
for _, s := range knownServers {
knownNames[s.Name] = true
}
var servers []LSPServer
for _, rs := range reg.Servers {
if knownNames[rs.Name] {
continue
}
servers = append(servers, LSPServer{
Name: rs.Name,
Language: rs.Language,
Command: rs.Command,
InstallCmd: rs.InstallCmd,
Installed: isLSPCommandAvailable(rs.Command),
Description: rs.Description,
Category: rs.Category,
Version: getInstalledLSPVersion(rs.Name),
})
}
return servers, nil
}
func isLSPCommandAvailable(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func getInstalledLSPVersion(name string) string {
home, _ := os.UserHomeDir()
if home == "" {
return ""
}
receiptPath := filepath.Join(home, ".muyue", "receipts", "lsp", name+".json")
data, err := os.ReadFile(receiptPath)
if err != nil {
return ""
}
var receipt struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &receipt) == nil {
return receipt.Version
}
return ""
}
func saveLSPReceipt(name, version string) error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
receiptDir := filepath.Join(home, ".muyue", "receipts", "lsp")
os.MkdirAll(receiptDir, 0755)
receipt := struct {
Name string `json:"name"`
Version string `json:"version"`
UpdatedAt string `json:"updated_at"`
}{
Name: name,
Version: version,
UpdatedAt: time.Now().Format(time.RFC3339),
}
data, _ := json.MarshalIndent(receipt, "", " ")
return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644)
}
func InstallServer(name string) error {
for _, s := range knownServers {
if s.Name == name {
if s.InstallCmd == "" {
return fmt.Errorf("%s has no auto-install command, install manually", name)
}
cmd := exec.Command("bash", "-c", s.InstallCmd)
cmd.Env = os.Environ()
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("install %s: %s: %w", name, string(output), err)
}
return nil
return doInstallLSP(s)
}
}
reg, err := LoadLSPRegistry()
if err == nil {
for _, s := range reg.Servers {
if s.Name == name {
return doInstallLSP(LSPServer{
Name: s.Name,
Language: s.Language,
Command: s.Command,
InstallCmd: s.InstallCmd,
})
}
}
}
return fmt.Errorf("unknown LSP server: %s", name)
}
func doInstallLSP(s LSPServer) error {
if s.InstallCmd == "" {
return fmt.Errorf("%s has no auto-install command, install manually", s.Name)
}
cmd := exec.Command("bash", "-c", s.InstallCmd)
cmd.Env = os.Environ()
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("install %s: %s: %w", s.Name, string(output), err)
}
saveLSPReceipt(s.Name, "latest")
return nil
}
func InstallForLanguages(languages []string) []LSPServer {
langMap := map[string][]string{
"go": {"gopls"},
@@ -111,85 +214,99 @@ func InstallForLanguages(languages []string) []LSPServer {
return results
}
func GenerateCrushConfig(cfg *config.MuyueConfig) error {
if cfg == nil {
return fmt.Errorf("config is nil")
func AutoInstallForProject(projectDir string) ([]LSPServer, error) {
languages := DetectProjectLanguages(projectDir)
if len(languages) == 0 {
return nil, nil
}
configDir, err := config.ConfigDir()
if err != nil {
return err
}
type lspEntry struct {
Command []string `json:"command"`
}
lspConfig := map[string]lspEntry{}
for _, lang := range cfg.Profile.Languages {
switch lang {
case "go":
lspConfig["go"] = lspEntry{Command: []string{"gopls"}}
case "python":
lspConfig["python"] = lspEntry{Command: []string{"pyright-langserver", "--stdio"}}
case "typescript", "javascript":
lspConfig["typescript"] = lspEntry{Command: []string{"typescript-language-server", "--stdio"}}
case "rust":
lspConfig["rust"] = lspEntry{Command: []string{"rust-analyzer"}}
case "c", "cpp":
lspConfig["c"] = lspEntry{Command: []string{"clangd"}}
case "lua":
lspConfig["lua"] = lspEntry{Command: []string{"lua-language-server"}}
}
}
if len(lspConfig) == 0 {
return nil
}
data, err := json.MarshalIndent(lspConfig, "", " ")
if err != nil {
return err
}
lspPath := filepath.Join(configDir, "crush.json")
existing, err := os.ReadFile(lspPath)
if err == nil {
var existingConfig map[string]interface{}
if unmarshalErr := json.Unmarshal(existing, &existingConfig); unmarshalErr == nil {
var newConfig map[string]interface{}
if unmarshalErr2 := json.Unmarshal(data, &newConfig); unmarshalErr2 == nil {
for k, v := range newConfig {
existingConfig[k] = v
}
data, _ = json.MarshalIndent(existingConfig, "", " ")
}
}
}
return os.WriteFile(lspPath, data, 0644)
results := InstallForLanguages(languages)
return results, nil
}
func EnsureCrushConfig(cfg *config.MuyueConfig) error {
configDir, _ := config.ConfigDir()
crusherPath := filepath.Join(configDir, "crush.json")
func HealthCheck(name string) (bool, string) {
for _, s := range knownServers {
if s.Name == name {
return healthCheckServer(s)
}
}
return false, "unknown server"
}
if _, err := os.Stat(crusherPath); err != nil {
func healthCheckServer(s LSPServer) (bool, string) {
path, err := exec.LookPath(s.Command)
if err != nil {
return false, fmt.Sprintf("command %q not found in PATH", s.Command)
}
versionArgs := map[string][]string{
"gopls": {"version"},
"pyright": {"--version"},
"typescript-language-server": {"--version"},
"rust-analyzer": {"--version"},
"clangd": {"--version"},
"lua-language-server": {"--version"},
"bash-language-server": {"--version"},
"yaml-language-server": {"--version"},
}
if args, ok := versionArgs[s.Command]; ok {
cmd := exec.Command(path, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return true, fmt.Sprintf("installed at %s but version check failed", path)
}
version := strings.TrimSpace(string(output))
if idx := strings.Index(version, "\n"); idx > 0 {
version = version[:idx]
}
saveLSPReceipt(s.Name, version)
return true, version
}
return true, fmt.Sprintf("installed at %s", path)
}
func GenerateEditorConfigs(servers []LSPServer, editor string, homeDir string) (string, error) {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeCrush := filepath.Join(home, ".config", "crush", "crush.json")
if _, err := os.Stat(homeCrush); err == nil {
return nil
}
defaultConfig := map[string]interface{}{
"version": "1",
}
data, _ := json.MarshalIndent(defaultConfig, "", " ")
os.MkdirAll(filepath.Dir(crusherPath), 0755)
return os.WriteFile(crusherPath, data, 0644)
homeDir = home
}
return nil
reg, err := LoadLSPRegistry()
if err != nil {
return "", err
}
regMap := map[string]RegistryEntry{}
for _, s := range reg.Servers {
regMap[s.Name] = s
}
var regEntries []RegistryEntry
for _, s := range servers {
if re, ok := regMap[s.Name]; ok {
regEntries = append(regEntries, re)
}
}
switch editor {
case "neovim", "nvim":
return GenerateNeovimConfig(regEntries), nil
case "helix", "hx":
return GenerateHelixConfig(regEntries), nil
case "vscode", "code", "cursor":
exts := GenerateVSCodeRecommendations(regEntries)
var b strings.Builder
b.WriteString("{\n \"recommendations\": [\n")
for i, ext := range exts {
if i > 0 {
b.WriteString(",\n")
}
b.WriteString(" \"" + ext + "\"")
}
b.WriteString("\n ]\n}")
return b.String(), nil
default:
return "", fmt.Errorf("unsupported editor: %s", editor)
}
}

333
internal/lsp/registry.go Normal file
View File

@@ -0,0 +1,333 @@
package lsp
import (
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
type RegistryEntry struct {
Name string `yaml:"name" json:"name"`
Language string `yaml:"language" json:"language"`
Description string `yaml:"description" json:"description"`
Command string `yaml:"command" json:"command"`
InstallCmd string `yaml:"install_cmd" json:"install_cmd"`
InstallType string `yaml:"install_type" json:"install_type"`
Category string `yaml:"category" json:"category"`
FilePatterns []string `yaml:"file_patterns,omitempty" json:"file_patterns,omitempty"`
ConfigFiles []string `yaml:"config_files,omitempty" json:"config_files,omitempty"`
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
NeovimSetup string `yaml:"neovim_setup,omitempty" json:"neovim_setup,omitempty"`
HelixLanguage string `yaml:"helix_language,omitempty" json:"helix_language,omitempty"`
}
type LSPRegistry struct {
SchemaVersion string `yaml:"schema_version"`
UpdatedAt time.Time `yaml:"updated_at"`
Servers []RegistryEntry `yaml:"servers"`
}
func DefaultLSPRegistry() *LSPRegistry {
return &LSPRegistry{
SchemaVersion: "v1",
UpdatedAt: time.Now(),
Servers: []RegistryEntry{
{
Name: "gopls", Language: "go", Description: "Go language server",
Command: "gopls", InstallCmd: "go install golang.org/x/tools/gopls@latest",
InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"},
ConfigFiles: []string{"go.mod"}, Tags: []string{"go", "linting", "completion"},
HomePage: "https://github.com/golang/tools",
NeovimSetup: `lspconfig.gopls.setup{}`,
HelixLanguage: "go",
},
{
Name: "pyright", Language: "python", Description: "Python type checker and language server",
Command: "pyright", InstallCmd: "npm install -g pyright",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.py", "*.pyi"},
ConfigFiles: []string{"requirements.txt", "pyproject.toml", "setup.py", "Pipfile"},
Tags: []string{"python", "type-checking"}, HomePage: "https://github.com/microsoft/pyright",
NeovimSetup: `lspconfig.pyright.setup{}`,
HelixLanguage: "python",
},
{
Name: "typescript-language-server", Language: "typescript", Description: "TypeScript and JavaScript language server",
Command: "typescript-language-server", InstallCmd: "npm install -g typescript-language-server typescript",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.ts", "*.tsx", "*.js", "*.jsx"},
ConfigFiles: []string{"tsconfig.json", "package.json"},
Tags: []string{"typescript", "javascript"}, HomePage: "https://github.com/typescript-language-server/typescript-language-server",
NeovimSetup: `lspconfig.tsserver.setup{}`,
HelixLanguage: "typescript",
},
{
Name: "vscode-json-language-server", Language: "json", Description: "JSON language server",
Command: "vscode-json-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.json", "*.jsonc"},
Tags: []string{"json"}, NeovimSetup: `lspconfig.jsonls.setup{}`,
HelixLanguage: "json",
},
{
Name: "vscode-html-language-server", Language: "html", Description: "HTML language server",
Command: "vscode-html-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.htm"},
Tags: []string{"html"}, NeovimSetup: `lspconfig.html.setup{}`,
HelixLanguage: "html",
},
{
Name: "vscode-css-language-server", Language: "css", Description: "CSS/SCSS/LESS language server",
Command: "vscode-css-language-server", InstallCmd: "npm install -g vscode-langservers-extracted",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.css", "*.scss", "*.less"},
Tags: []string{"css"}, NeovimSetup: `lspconfig.cssls.setup{}`,
HelixLanguage: "css",
},
{
Name: "yaml-language-server", Language: "yaml", Description: "YAML language server",
Command: "yaml-language-server", InstallCmd: "npm install -g yaml-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.yml", "*.yaml"},
Tags: []string{"yaml"}, NeovimSetup: `lspconfig.yamlls.setup{}`,
HelixLanguage: "yaml",
},
{
Name: "bash-language-server", Language: "bash", Description: "Bash language server",
Command: "bash-language-server", InstallCmd: "npm install -g bash-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.sh", "*.bash"},
Tags: []string{"bash", "shell"}, NeovimSetup: `lspconfig.bashls.setup{}`,
HelixLanguage: "bash",
},
{
Name: "rust-analyzer", Language: "rust", Description: "Rust language server",
Command: "rust-analyzer", InstallCmd: "rustup component add rust-analyzer",
InstallType: "rustup", Category: "lsp", FilePatterns: []string{"*.rs"},
ConfigFiles: []string{"Cargo.toml"}, Tags: []string{"rust"},
HomePage: "https://github.com/rust-lang/rust-analyzer",
NeovimSetup: `lspconfig.rust_analyzer.setup{}`,
HelixLanguage: "rust",
},
{
Name: "clangd", Language: "c/c++", Description: "C/C++ language server",
Command: "clangd", InstallCmd: "", InstallType: "system",
Category: "lsp", FilePatterns: []string{"*.c", "*.cpp", "*.h", "*.hpp"},
ConfigFiles: []string{"CMakeLists.txt", "Makefile"}, Tags: []string{"c", "cpp"},
NeovimSetup: `lspconfig.clangd.setup{}`,
HelixLanguage: "c",
},
{
Name: "lua-language-server", Language: "lua", Description: "Lua language server",
Command: "lua-language-server", InstallCmd: "npm install -g lua-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.lua"},
Tags: []string{"lua"}, NeovimSetup: `lspconfig.lua_ls.setup{}`,
HelixLanguage: "lua",
},
{
Name: "dockerfile-language-server", Language: "dockerfile", Description: "Dockerfile language server",
Command: "docker-langserver", InstallCmd: "npm install -g dockerfile-language-server-nodejs",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"Dockerfile", "Dockerfile.*"},
Tags: []string{"docker"}, NeovimSetup: `lspconfig.dockerls.setup{}`,
HelixLanguage: "dockerfile",
},
{
Name: "tailwindcss-language-server", Language: "tailwind", Description: "Tailwind CSS language server",
Command: "tailwindcss-language-server", InstallCmd: "npm install -g @tailwindcss/language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.html", "*.tsx", "*.jsx"},
ConfigFiles: []string{"tailwind.config.js", "tailwind.config.ts"},
Tags: []string{"tailwind", "css"}, NeovimSetup: `lspconfig.tailwindcss.setup{}`,
},
{
Name: "svelte-language-server", Language: "svelte", Description: "Svelte language server",
Command: "svelteserver", InstallCmd: "npm install -g svelte-language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.svelte"},
Tags: []string{"svelte"}, NeovimSetup: `lspconfig.svelte.setup{}`,
HelixLanguage: "svelte",
},
{
Name: "vue-language-server", Language: "vue", Description: "Vue language server",
Command: "vue-language-server", InstallCmd: "npm install -g @vue/language-server",
InstallType: "npm", Category: "lsp", FilePatterns: []string{"*.vue"},
Tags: []string{"vue"}, NeovimSetup: `lspconfig.vuels.setup{}`,
},
{
Name: "golangci-lint-langserver", Language: "go-lint", Description: "Go linter language server",
Command: "golangci-lint-langserver", InstallCmd: "go install github.com/nametake/golangci-lint-langserver@latest",
InstallType: "go", Category: "lsp", FilePatterns: []string{"*.go"},
Tags: []string{"go", "linting"},
},
},
}
}
var lspRegistryPath string
func init() {
home, _ := os.UserHomeDir()
if home != "" {
lspRegistryPath = filepath.Join(home, ".muyue", "lsp-registry.yaml")
}
}
func SetLSPRegistryPath(p string) {
lspRegistryPath = p
}
func LoadLSPRegistry() (*LSPRegistry, error) {
if lspRegistryPath == "" {
return DefaultLSPRegistry(), nil
}
data, err := os.ReadFile(lspRegistryPath)
if err != nil {
return DefaultLSPRegistry(), nil
}
var reg LSPRegistry
if err := yaml.Unmarshal(data, &reg); err != nil {
return nil, err
}
return &reg, nil
}
func SaveLSPRegistry(reg *LSPRegistry) error {
if lspRegistryPath == "" {
return nil
}
reg.UpdatedAt = time.Now()
data, err := yaml.Marshal(reg)
if err != nil {
return err
}
os.MkdirAll(filepath.Dir(lspRegistryPath), 0755)
return os.WriteFile(lspRegistryPath, data, 0644)
}
func InitLSPRegistry() error {
if lspRegistryPath == "" {
return nil
}
if _, err := os.Stat(lspRegistryPath); err == nil {
return nil
}
return SaveLSPRegistry(DefaultLSPRegistry())
}
func DetectProjectLanguages(projectDir string) []string {
if projectDir == "" {
return nil
}
langDetectors := map[string][]string{
"go": {"go.mod", "go.sum"},
"python": {"requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "uv.lock"},
"typescript": {"tsconfig.json", "package.json"},
"javascript": {"package.json"},
"rust": {"Cargo.toml"},
"ruby": {"Gemfile"},
"java": {"pom.xml", "build.gradle"},
"c": {"CMakeLists.txt", "Makefile"},
"cpp": {"CMakeLists.txt"},
"php": {"composer.json"},
"lua": {".luarc.json"},
"docker": {"Dockerfile"},
}
extDetectors := map[string]string{
".go": "go",
".py": "python",
".rs": "rust",
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
".jsx": "javascript",
".rb": "ruby",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".lua": "lua",
".vue": "vue",
".svelte": "svelte",
}
detected := map[string]bool{}
for lang, files := range langDetectors {
for _, f := range files {
if _, err := os.Stat(filepath.Join(projectDir, f)); err == nil {
detected[lang] = true
break
}
}
}
entries, err := os.ReadDir(projectDir)
if err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
ext := filepath.Ext(e.Name())
if lang, ok := extDetectors[ext]; ok {
detected[lang] = true
}
}
}
var languages []string
for lang := range detected {
languages = append(languages, lang)
}
return languages
}
func GenerateNeovimConfig(servers []RegistryEntry) string {
config := `-- Generated by Muyue LSP Manager
-- Add to your init.lua or require from lspconfig setup
local lspconfig = require('lspconfig')
`
for _, s := range servers {
if s.NeovimSetup != "" {
config += s.NeovimSetup + "\n"
}
}
return config
}
func GenerateHelixConfig(servers []RegistryEntry) string {
config := `# Generated by Muyue LSP Manager
# Add to ~/.config/helix/languages.toml
`
for _, s := range servers {
if s.HelixLanguage != "" {
config += "[[language]]\n"
config += "name = \"" + s.HelixLanguage + "\"\n"
config += "language-servers = [\"" + s.Name + "\"]\n\n"
}
}
return config
}
func GenerateVSCodeRecommendations(servers []RegistryEntry) []string {
extensionMap := map[string][]string{
"gopls": {"golang.go"},
"pyright": {"ms-python.python", "ms-python.vscode-pylance"},
"typescript-language-server": {"ms-vscode.vscode-typescript-next"},
"rust-analyzer": {"rust-lang.rust-analyzer"},
"lua-language-server": {"sumneko.lua"},
"tailwindcss-language-server": {"bradlc.vscode-tailwindcss"},
"svelte-language-server": {"svelte.svelte-vscode"},
"vue-language-server": {"vue.volar"},
"yaml-language-server": {"redhat.vscode-yaml"},
"bash-language-server": {"mads-hartmann.bash-ide-vscode"},
}
var extensions []string
for _, s := range servers {
if exts, ok := extensionMap[s.Name]; ok {
extensions = append(extensions, exts...)
}
}
return extensions
}

View File

@@ -0,0 +1,142 @@
package lsp
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultLSPRegistry(t *testing.T) {
reg := DefaultLSPRegistry()
if reg.SchemaVersion != "v1" {
t.Errorf("Expected v1, got %s", reg.SchemaVersion)
}
if len(reg.Servers) == 0 {
t.Error("Default LSP registry should have servers")
}
names := map[string]bool{}
for _, s := range reg.Servers {
if names[s.Name] {
t.Errorf("Duplicate server name: %s", s.Name)
}
names[s.Name] = true
if s.Command == "" {
t.Errorf("Server %s missing command", s.Name)
}
if s.Language == "" {
t.Errorf("Server %s missing language", s.Name)
}
}
}
func TestSaveAndLoadLSPRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-registry.yaml"))
reg := DefaultLSPRegistry()
if err := SaveLSPRegistry(reg); err != nil {
t.Fatalf("SaveLSPRegistry failed: %v", err)
}
loaded, err := LoadLSPRegistry()
if err != nil {
t.Fatalf("LoadLSPRegistry failed: %v", err)
}
if len(loaded.Servers) != len(reg.Servers) {
t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers))
}
}
func TestInitLSPRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetLSPRegistryPath(filepath.Join(tmpDir, "lsp-reg.yaml"))
if err := InitLSPRegistry(); err != nil {
t.Fatalf("InitLSPRegistry failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "lsp-reg.yaml")); os.IsNotExist(err) {
t.Error("LSP registry file should be created")
}
}
func TestDetectProjectLanguages(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test\ngo 1.24\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name": "test"}`), 0644)
languages := DetectProjectLanguages(tmpDir)
if len(languages) == 0 {
t.Error("Should detect languages")
}
langSet := map[string]bool{}
for _, l := range languages {
langSet[l] = true
}
if !langSet["go"] {
t.Error("Should detect Go")
}
if !langSet["typescript"] {
t.Error("Should detect TypeScript/JS from package.json")
}
}
func TestDetectProjectLanguagesEmpty(t *testing.T) {
tmpDir := t.TempDir()
languages := DetectProjectLanguages(tmpDir)
if len(languages) != 0 {
t.Errorf("Empty dir should detect no languages, got %v", languages)
}
}
func TestGenerateNeovimConfig(t *testing.T) {
servers := []RegistryEntry{
{Name: "gopls", Language: "go", NeovimSetup: "lspconfig.gopls.setup{}"},
{Name: "pyright", Language: "python", NeovimSetup: "lspconfig.pyright.setup{}"},
}
config := GenerateNeovimConfig(servers)
if config == "" {
t.Error("Config should not be empty")
}
if len(config) < 50 {
t.Error("Config seems too short")
}
}
func TestGenerateHelixConfig(t *testing.T) {
servers := []RegistryEntry{
{Name: "gopls", Language: "go", HelixLanguage: "go"},
}
config := GenerateHelixConfig(servers)
if config == "" {
t.Error("Config should not be empty")
}
}
func TestGenerateVSCodeRecommendations(t *testing.T) {
servers := []RegistryEntry{
{Name: "gopls", Language: "go"},
{Name: "pyright", Language: "python"},
}
exts := GenerateVSCodeRecommendations(servers)
if len(exts) == 0 {
t.Error("Should return some extensions")
}
}
func TestHealthCheck(t *testing.T) {
healthy, detail := HealthCheck("gopls")
if healthy && detail == "" {
t.Error("If healthy, should have version detail")
}
}
func TestHealthCheckUnknown(t *testing.T) {
_, _ = HealthCheck("nonexistent-server")
}

View File

@@ -6,17 +6,22 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
type MCPServer struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env,omitempty"`
Installed bool `json:"installed"`
Category string `json:"category"`
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env,omitempty"`
Installed bool `json:"installed"`
Category string `json:"category"`
Description string `json:"description,omitempty"`
Version string `json:"version,omitempty"`
Status string `json:"status,omitempty"`
}
type mcpEntry struct {
@@ -47,13 +52,55 @@ func ScanServers() []MCPServer {
servers[i] = s
_, err := exec.LookPath(s.Command)
servers[i].Installed = err == nil
servers[i].Version = GetInstalledVersion(s.Name)
}
regServers, err := scanRegistryServers()
if err == nil {
servers = append(servers, regServers...)
}
return servers
}
func scanRegistryServers() ([]MCPServer, error) {
reg, err := LoadRegistry()
if err != nil {
return nil, err
}
knownNames := map[string]bool{}
for _, s := range knownMCPServers {
knownNames[s.Name] = true
}
var servers []MCPServer
for _, rs := range reg.Servers {
if knownNames[rs.Name] {
continue
}
servers = append(servers, MCPServer{
Name: rs.Name,
Command: rs.Command,
Args: rs.Args,
Env: rs.Env,
Category: rs.Category,
Description: rs.Description,
Installed: isCommandAvailable(rs.Command),
Version: GetInstalledVersion(rs.Name),
})
}
return servers, nil
}
func isCommandAvailable(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
func getCoreEntries(homeDir string) []mcpEntry {
return []mcpEntry{
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil},
{"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil},
{"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil},
{"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil},
}
@@ -86,7 +133,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
existing := map[string]interface{}{}
data, err := os.ReadFile(configPath)
if err == nil {
json.Unmarshal(data, &existing)
if err := json.Unmarshal(data, &existing); err != nil {
return fmt.Errorf("parse existing config: %w", err)
}
}
mcpMap := map[string]interface{}{}
@@ -96,7 +145,8 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
"args": e.args,
}
if len(e.env) > 0 {
entry["env"] = e.env
resolved := ResolveEnv(e.env, nil)
entry["env"] = resolved
}
mcpMap[e.name] = entry
}
@@ -108,7 +158,49 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error
return err
}
return os.WriteFile(configPath, out, 0600)
if err := os.WriteFile(configPath, out, 0600); err != nil {
return err
}
return ValidateConfig(configPath)
}
func writeMCPConfigForEditor(editor EditorConfig, entries []mcpEntry) error {
configDir := filepath.Dir(editor.ConfigPath)
if err := os.MkdirAll(configDir, 0700); err != nil {
return fmt.Errorf("create config dir %s: %w", editor.Name, err)
}
existing := map[string]interface{}{}
data, err := os.ReadFile(editor.ConfigPath)
if err == nil {
_ = json.Unmarshal(data, &existing)
}
mcpMap := map[string]interface{}{}
for _, e := range entries {
if editor.TransformCommand != nil {
mcpMap[e.name] = editor.TransformCommand(e)
} else {
entry := map[string]interface{}{
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
entry["env"] = e.env
}
mcpMap[e.name] = entry
}
}
existing[editor.ConfigKey] = mcpMap
out, err := json.MarshalIndent(existing, "", " ")
if err != nil {
return err
}
return os.WriteFile(editor.ConfigPath, out, 0600)
}
func GenerateCrushMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
@@ -138,19 +230,154 @@ func GenerateClaudeMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
return writeMCPConfig(configPath, "mcpServers", entries)
}
func GenerateCursorMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
core := getCoreEntries(homeDir)
entries := withProviderEntries(core, cfg, nil)
editor := EditorConfig{
Name: "cursor",
ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"),
ConfigKey: "mcpServers",
Format: "json",
TransformCommand: func(e mcpEntry) interface{} {
m := map[string]interface{}{
"type": "stdio",
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
m["env"] = e.env
}
return m
},
}
return writeMCPConfigForEditor(editor, entries)
}
func GenerateVSCodeMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
core := getCoreEntries(homeDir)
entries := withProviderEntries(core, cfg, nil)
editor := EditorConfig{
Name: "vscode",
ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"),
ConfigKey: "servers",
Format: "json",
}
return writeMCPConfigForEditor(editor, entries)
}
func GenerateWindsurfMCPConfig(cfg *config.MuyueConfig, homeDir string) error {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
core := getCoreEntries(homeDir)
entries := withProviderEntries(core, cfg, nil)
editor := EditorConfig{
Name: "windsurf",
ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"),
ConfigKey: "mcpServers",
Format: "json",
}
return writeMCPConfigForEditor(editor, entries)
}
func ConfigureAll(cfg *config.MuyueConfig) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get home dir: %w", err)
}
if err := GenerateCrushMCPConfig(cfg, home); err != nil {
return fmt.Errorf("crush MCP config: %w", err)
editors := []struct {
name string
fn func(*config.MuyueConfig, string) error
}{
{"crush", GenerateCrushMCPConfig},
{"claude", GenerateClaudeMCPConfig},
{"cursor", GenerateCursorMCPConfig},
{"vscode", GenerateVSCodeMCPConfig},
{"windsurf", GenerateWindsurfMCPConfig},
}
if err := GenerateClaudeMCPConfig(cfg, home); err != nil {
return fmt.Errorf("claude MCP config: %w", err)
var errs []string
for _, e := range editors {
if err := e.fn(cfg, home); err != nil {
errs = append(errs, fmt.Sprintf("%s: %s", e.name, err))
}
}
SaveReceipt("all", time.Now().Format("2006-01-02"))
if len(errs) > 0 {
return fmt.Errorf("MCP config errors: %s", strings.Join(errs, "; "))
}
return nil
}
func ConfigureForEditor(cfg *config.MuyueConfig, editorName string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get home dir: %w", err)
}
switch editorName {
case "crush":
return GenerateCrushMCPConfig(cfg, home)
case "claude", "claude-code":
return GenerateClaudeMCPConfig(cfg, home)
case "cursor":
return GenerateCursorMCPConfig(cfg, home)
case "vscode", "code":
return GenerateVSCodeMCPConfig(cfg, home)
case "windsurf":
return GenerateWindsurfMCPConfig(cfg, home)
default:
return fmt.Errorf("unknown editor: %s (supported: crush, claude-code, cursor, vscode, windsurf)", editorName)
}
}
func DetectInstalledEditors(homeDir string) []string {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
editors := []struct {
name string
path string
}{
{"crush", filepath.Join(homeDir, ".config", "crush", "crush.json")},
{"claude-code", filepath.Join(homeDir, ".claude.json")},
{"cursor", filepath.Join(homeDir, ".cursor")},
{"vscode", filepath.Join(homeDir, ".vscode")},
{"windsurf", filepath.Join(homeDir, ".windsurf")},
}
var detected []string
for _, e := range editors {
if _, err := os.Stat(e.path); err == nil {
detected = append(detected, e.name)
}
}
return detected
}
func GetAllStatuses() []MCPStatus {
servers := ScanServers()
statuses := make([]MCPStatus, len(servers))
for i, s := range servers {
statuses[i] = CheckServerStatus(s.Name)
}
return statuses
}

520
internal/mcp/registry.go Normal file
View File

@@ -0,0 +1,520 @@
package mcp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
type RegistryServer struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Category string `yaml:"category" json:"category"`
Package string `yaml:"package" json:"package"`
Command string `yaml:"command" json:"command"`
Args []string `yaml:"args" json:"args"`
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
RequiredEnv []string `yaml:"required_env,omitempty" json:"required_env,omitempty"`
HomePage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
InstallType string `yaml:"install_type" json:"install_type"`
}
type Registry struct {
SchemaVersion string `yaml:"schema_version"`
UpdatedAt time.Time `yaml:"updated_at"`
Servers []RegistryServer `yaml:"servers"`
}
type MCPStatus struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Running bool `json:"running"`
Healthy bool `json:"healthy"`
Version string `json:"version"`
Error string `json:"error,omitempty"`
}
type EditorConfig struct {
Name string
ConfigPath string
ConfigKey string
LocalConfigPath string
Format string
TransformCommand func(entry mcpEntry) interface{}
}
var (
registryMu sync.RWMutex
registryCache *Registry
registryPath string
)
func init() {
home, _ := os.UserHomeDir()
if home != "" {
registryPath = filepath.Join(home, ".muyue", "mcp-registry.yaml")
}
}
func SetRegistryPath(p string) {
registryMu.Lock()
defer registryMu.Unlock()
registryPath = p
registryCache = nil
}
func DefaultRegistry() *Registry {
return &Registry{
SchemaVersion: "v1",
UpdatedAt: time.Now(),
Servers: []RegistryServer{
{
Name: "filesystem", Description: "File system operations for AI tools",
Category: "core", Package: "@modelcontextprotocol/server-filesystem",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-filesystem"},
InstallType: "npm", Tags: []string{"files", "core"},
},
{
Name: "github", Description: "GitHub API integration",
Category: "vcs", Package: "@modelcontextprotocol/server-github",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-github"},
Env: map[string]string{"GITHUB_PERSONAL_ACCESS_TOKEN": ""},
RequiredEnv: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"},
InstallType: "npm", Tags: []string{"github", "git"},
},
{
Name: "git", Description: "Git repository operations",
Category: "vcs", Package: "@modelcontextprotocol/server-git",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-git"},
InstallType: "npm", Tags: []string{"git"},
},
{
Name: "fetch", Description: "Web fetching and HTTP requests",
Category: "web", Package: "@modelcontextprotocol/server-fetch",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-fetch"},
InstallType: "npm", Tags: []string{"web", "http"},
},
{
Name: "memory", Description: "Persistent memory/knowledge graph",
Category: "core", Package: "@modelcontextprotocol/server-memory",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-memory"},
InstallType: "npm", Tags: []string{"memory", "core"},
},
{
Name: "sequential-thinking", Description: "Structured reasoning and chain-of-thought",
Category: "ai", Package: "@modelcontextprotocol/server-sequential-thinking",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sequential-thinking"},
InstallType: "npm", Tags: []string{"ai", "reasoning"},
},
{
Name: "brave-search", Description: "Web search via Brave Search API",
Category: "web", Package: "@modelcontextprotocol/server-brave-search",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-brave-search"},
Env: map[string]string{"BRAVE_API_KEY": ""},
RequiredEnv: []string{"BRAVE_API_KEY"},
InstallType: "npm", Tags: []string{"search", "web"},
},
{
Name: "sqlite", Description: "SQLite database operations",
Category: "database", Package: "@modelcontextprotocol/server-sqlite",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-sqlite"},
InstallType: "npm", Tags: []string{"database", "sqlite"},
},
{
Name: "postgres", Description: "PostgreSQL database operations",
Category: "database", Package: "@modelcontextprotocol/server-postgres",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-postgres"},
InstallType: "npm", Tags: []string{"database", "postgres"},
},
{
Name: "docker", Description: "Docker container management",
Category: "devops", Package: "@modelcontextprotocol/server-docker",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-docker"},
InstallType: "npm", Tags: []string{"docker", "devops"},
},
{
Name: "minimax-web-search", Description: "Web search via MiniMax API",
Category: "ai", Package: "@minimax/mcp-web-search",
Command: "npx", Args: []string{"-y", "@minimax/mcp-web-search"},
Env: map[string]string{"MINIMAX_API_KEY": ""},
RequiredEnv: []string{"MINIMAX_API_KEY"},
InstallType: "npm", Tags: []string{"ai", "search"},
},
{
Name: "minimax-image", Description: "Image understanding via MiniMax API",
Category: "ai", Package: "@minimax/mcp-image-understanding",
Command: "npx", Args: []string{"-y", "@minimax/mcp-image-understanding"},
Env: map[string]string{"MINIMAX_API_KEY": ""},
RequiredEnv: []string{"MINIMAX_API_KEY"},
InstallType: "npm", Tags: []string{"ai", "image"},
},
{
Name: "puppeteer", Description: "Browser automation with Puppeteer",
Category: "web", Package: "@modelcontextprotocol/server-puppeteer",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-puppeteer"},
InstallType: "npm", Tags: []string{"browser", "automation"},
},
{
Name: "everything", Description: "Test/debug MCP server with all features",
Category: "testing", Package: "@modelcontextprotocol/server-everything",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-everything"},
InstallType: "npm", Tags: []string{"testing", "debug"},
},
{
Name: "slack", Description: "Slack workspace integration",
Category: "communication", Package: "@modelcontextprotocol/server-slack",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-slack"},
Env: map[string]string{"SLACK_BOT_TOKEN": ""},
RequiredEnv: []string{"SLACK_BOT_TOKEN"},
InstallType: "npm", Tags: []string{"slack", "communication"},
},
{
Name: "google-maps", Description: "Google Maps integration",
Category: "web", Package: "@modelcontextprotocol/server-google-maps",
Command: "npx", Args: []string{"-y", "@modelcontextprotocol/server-google-maps"},
Env: map[string]string{"GOOGLE_MAPS_API_KEY": ""},
RequiredEnv: []string{"GOOGLE_MAPS_API_KEY"},
InstallType: "npm", Tags: []string{"maps", "location"},
},
},
}
}
func LoadRegistry() (*Registry, error) {
registryMu.RLock()
if registryCache != nil {
defer registryMu.RUnlock()
return registryCache, nil
}
registryMu.RUnlock()
reg, err := loadRegistryFromDisk()
if err != nil {
defaultReg := DefaultRegistry()
registryMu.Lock()
registryCache = defaultReg
registryMu.Unlock()
return defaultReg, nil
}
registryMu.Lock()
registryCache = reg
registryMu.Unlock()
return reg, nil
}
func loadRegistryFromDisk() (*Registry, error) {
if registryPath == "" {
return nil, fmt.Errorf("registry path not set")
}
data, err := os.ReadFile(registryPath)
if err != nil {
return nil, err
}
var reg Registry
if err := yaml.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("parse registry: %w", err)
}
return &reg, nil
}
func SaveRegistry(reg *Registry) error {
if registryPath == "" {
return fmt.Errorf("registry path not set")
}
reg.UpdatedAt = time.Now()
data, err := yaml.Marshal(reg)
if err != nil {
return fmt.Errorf("marshal registry: %w", err)
}
if err := os.MkdirAll(filepath.Dir(registryPath), 0755); err != nil {
return err
}
if err := os.WriteFile(registryPath, data, 0644); err != nil {
return err
}
registryMu.Lock()
registryCache = reg
registryMu.Unlock()
return nil
}
func AddToRegistry(server RegistryServer) error {
reg, err := LoadRegistry()
if err != nil {
return err
}
for _, s := range reg.Servers {
if s.Name == server.Name {
return fmt.Errorf("server %q already exists in registry", server.Name)
}
}
reg.Servers = append(reg.Servers, server)
return SaveRegistry(reg)
}
func RemoveFromRegistry(name string) error {
reg, err := LoadRegistry()
if err != nil {
return err
}
for i, s := range reg.Servers {
if s.Name == name {
reg.Servers = append(reg.Servers[:i], reg.Servers[i+1:]...)
return SaveRegistry(reg)
}
}
return fmt.Errorf("server %q not found in registry", name)
}
func InitRegistry() error {
if _, err := os.Stat(registryPath); err == nil {
return nil
}
return SaveRegistry(DefaultRegistry())
}
func ResolveEnv(env map[string]string, providerKeys map[string]string) map[string]string {
resolved := make(map[string]string)
for k, v := range env {
if v != "" {
resolved[k] = v
continue
}
if providerKeys != nil {
for providerKey, apiKey := range providerKeys {
if strings.EqualFold(k, providerKey) || strings.Contains(strings.ToUpper(k), strings.ToUpper(providerKey)) {
if apiKey != "" {
resolved[k] = apiKey
}
}
}
}
if resolved[k] == "" {
if envVal := os.Getenv(k); envVal != "" {
resolved[k] = envVal
}
}
}
return resolved
}
func ValidateConfig(configPath string) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
var cfg map[string]interface{}
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse config: %w", err)
}
return nil
}
func DiscoverNpmServers() ([]RegistryServer, error) {
var servers []RegistryServer
packages := []struct {
pkg string
name string
desc string
cat string
args []string
}{
{"@modelcontextprotocol/server-filesystem", "filesystem", "File system operations", "core", []string{"-y", "@modelcontextprotocol/server-filesystem"}},
{"@modelcontextprotocol/server-github", "github", "GitHub API integration", "vcs", []string{"-y", "@modelcontextprotocol/server-github"}},
{"@modelcontextprotocol/server-fetch", "fetch", "Web fetching", "web", []string{"-y", "@modelcontextprotocol/server-fetch"}},
{"@modelcontextprotocol/server-memory", "memory", "Persistent memory", "core", []string{"-y", "@modelcontextprotocol/server-memory"}},
}
for _, p := range packages {
servers = append(servers, RegistryServer{
Name: p.name,
Description: p.desc,
Category: p.cat,
Package: p.pkg,
Command: "npx",
Args: p.args,
InstallType: "npm",
})
}
return servers, nil
}
func GetInstalledVersion(name string) string {
home, _ := os.UserHomeDir()
if home == "" {
return ""
}
receiptPath := filepath.Join(home, ".muyue", "receipts", "mcp", name+".json")
data, err := os.ReadFile(receiptPath)
if err != nil {
return ""
}
var receipt struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &receipt) == nil {
return receipt.Version
}
return ""
}
func SaveReceipt(name, version string) error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
receiptDir := filepath.Join(home, ".muyue", "receipts", "mcp")
os.MkdirAll(receiptDir, 0755)
receipt := struct {
Name string `json:"name"`
Version string `json:"version"`
UpdatedAt string `json:"updated_at"`
}{
Name: name,
Version: version,
UpdatedAt: time.Now().Format(time.RFC3339),
}
data, _ := json.MarshalIndent(receipt, "", " ")
return os.WriteFile(filepath.Join(receiptDir, name+".json"), data, 0644)
}
func BuildProviderKeyMap(cfg interface{ GetAPIKeys() map[string]string }) map[string]string {
if cfg == nil {
return nil
}
return cfg.GetAPIKeys()
}
func EditorConfigs(homeDir string) []EditorConfig {
if homeDir == "" {
home, _ := os.UserHomeDir()
homeDir = home
}
transformStdio := func(e mcpEntry) interface{} {
m := map[string]interface{}{
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
m["env"] = e.env
}
return m
}
transformCursor := func(e mcpEntry) interface{} {
m := map[string]interface{}{
"type": "stdio",
"command": e.cmd,
"args": e.args,
}
if len(e.env) > 0 {
m["env"] = e.env
}
return m
}
return []EditorConfig{
{
Name: "crush", ConfigPath: filepath.Join(homeDir, ".config", "crush", "crush.json"),
ConfigKey: "mcps", Format: "json", TransformCommand: transformStdio,
},
{
Name: "claude-code", ConfigPath: filepath.Join(homeDir, ".claude.json"),
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
},
{
Name: "cursor", ConfigPath: filepath.Join(homeDir, ".cursor", "mcp.json"),
LocalConfigPath: ".cursor/mcp.json", ConfigKey: "mcpServers",
Format: "json", TransformCommand: transformCursor,
},
{
Name: "vscode", ConfigPath: filepath.Join(homeDir, ".vscode", "mcp.json"),
LocalConfigPath: ".vscode/mcp.json", ConfigKey: "servers",
Format: "json", TransformCommand: transformStdio,
},
{
Name: "windsurf", ConfigPath: filepath.Join(homeDir, ".windsurf", "mcp.json"),
ConfigKey: "mcpServers", Format: "json", TransformCommand: transformStdio,
},
}
}
func CheckServerStatus(name string) MCPStatus {
status := MCPStatus{Name: name}
reg, err := LoadRegistry()
if err != nil {
status.Error = "registry unavailable"
return status
}
var server *RegistryServer
for i := range reg.Servers {
if reg.Servers[i].Name == name {
server = &reg.Servers[i]
break
}
}
if server == nil {
status.Error = "not in registry"
return status
}
_, err = exec.LookPath(server.Command)
if err != nil {
status.Error = fmt.Sprintf("command %q not found", server.Command)
return status
}
status.Installed = true
status.Version = GetInstalledVersion(name)
home, _ := os.UserHomeDir()
if home != "" {
crushingPath := filepath.Join(home, ".config", "crush", "crush.json")
data, err := os.ReadFile(crushingPath)
if err == nil {
var cfg map[string]interface{}
if json.Unmarshal(data, &cfg) == nil {
if mcps, ok := cfg["mcps"].(map[string]interface{}); ok {
if _, exists := mcps[name]; exists {
status.Running = true
status.Healthy = true
}
}
}
}
}
return status
}

View File

@@ -0,0 +1,228 @@
package mcp
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultRegistry(t *testing.T) {
reg := DefaultRegistry()
if reg.SchemaVersion != "v1" {
t.Errorf("Expected v1, got %s", reg.SchemaVersion)
}
if len(reg.Servers) == 0 {
t.Error("Default registry should have servers")
}
names := map[string]bool{}
for _, s := range reg.Servers {
if names[s.Name] {
t.Errorf("Duplicate server name: %s", s.Name)
}
names[s.Name] = true
if s.Command == "" {
t.Errorf("Server %s missing command", s.Name)
}
}
}
func TestSaveAndLoadRegistry(t *testing.T) {
tmpDir := t.TempDir()
registryPath := filepath.Join(tmpDir, "mcp-registry.yaml")
SetRegistryPath(registryPath)
reg := DefaultRegistry()
if err := SaveRegistry(reg); err != nil {
t.Fatalf("SaveRegistry failed: %v", err)
}
if _, err := os.Stat(registryPath); os.IsNotExist(err) {
t.Error("Registry file should exist")
}
loaded, err := LoadRegistry()
if err != nil {
t.Fatalf("LoadRegistry failed: %v", err)
}
if len(loaded.Servers) != len(reg.Servers) {
t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(loaded.Servers))
}
}
func TestAddAndRemoveFromRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetRegistryPath(filepath.Join(tmpDir, "mcp-registry.yaml"))
SaveRegistry(DefaultRegistry())
newServer := RegistryServer{
Name: "test-server",
Description: "Test server",
Category: "test",
Command: "npx",
Args: []string{"-y", "test-pkg"},
InstallType: "npm",
}
if err := AddToRegistry(newServer); err != nil {
t.Fatalf("AddToRegistry failed: %v", err)
}
reg, _ := LoadRegistry()
found := false
for _, s := range reg.Servers {
if s.Name == "test-server" {
found = true
break
}
}
if !found {
t.Error("test-server should be in registry")
}
if err := RemoveFromRegistry("test-server"); err != nil {
t.Fatalf("RemoveFromRegistry failed: %v", err)
}
reg, _ = LoadRegistry()
for _, s := range reg.Servers {
if s.Name == "test-server" {
t.Error("test-server should have been removed")
}
}
}
func TestResolveEnv(t *testing.T) {
env := map[string]string{
"API_KEY": "",
"HOST": "localhost",
}
os.Setenv("API_KEY", "from-env")
defer os.Unsetenv("API_KEY")
resolved := ResolveEnv(env, nil)
if resolved["API_KEY"] != "from-env" {
t.Errorf("Expected from-env, got %s", resolved["API_KEY"])
}
if resolved["HOST"] != "localhost" {
t.Errorf("Expected localhost, got %s", resolved["HOST"])
}
}
func TestValidateConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test-config.json")
os.WriteFile(configPath, []byte(`{"mcps":{}}`), 0644)
if err := ValidateConfig(configPath); err != nil {
t.Errorf("Valid config should pass: %v", err)
}
badPath := filepath.Join(tmpDir, "nonexistent.json")
if err := ValidateConfig(badPath); err == nil {
t.Error("Nonexistent config should fail")
}
}
func TestEditorConfigs(t *testing.T) {
configs := EditorConfigs("/tmp")
if len(configs) < 3 {
t.Errorf("Expected at least 3 editor configs, got %d", len(configs))
}
names := map[string]bool{}
for _, c := range configs {
if names[c.Name] {
t.Errorf("Duplicate editor: %s", c.Name)
}
names[c.Name] = true
if c.ConfigPath == "" {
t.Errorf("Editor %s missing config path", c.Name)
}
if c.ConfigKey == "" {
t.Errorf("Editor %s missing config key", c.Name)
}
}
}
func TestDiscoverNpmServers(t *testing.T) {
servers, err := DiscoverNpmServers()
if err != nil {
t.Fatalf("DiscoverNpmServers failed: %v", err)
}
if len(servers) == 0 {
t.Error("Should discover some npm servers")
}
}
func TestReceiptRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("HOME", tmpDir)
defer os.Unsetenv("HOME")
SetRegistryPath(filepath.Join(tmpDir, "reg.yaml"))
if err := SaveReceipt("test-server", "1.2.3"); err != nil {
t.Fatalf("SaveReceipt failed: %v", err)
}
version := GetInstalledVersion("test-server")
if version != "1.2.3" {
t.Errorf("Expected 1.2.3, got %s", version)
}
}
func TestInitRegistry(t *testing.T) {
tmpDir := t.TempDir()
SetRegistryPath(filepath.Join(tmpDir, "init-reg.yaml"))
if err := InitRegistry(); err != nil {
t.Fatalf("InitRegistry failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "init-reg.yaml")); os.IsNotExist(err) {
t.Error("Registry file should be created")
}
if err := InitRegistry(); err != nil {
t.Fatalf("Second InitRegistry should not fail: %v", err)
}
}
func TestDetectInstalledEditors(t *testing.T) {
tmpDir := t.TempDir()
os.MkdirAll(filepath.Join(tmpDir, ".config", "crush"), 0755)
os.WriteFile(filepath.Join(tmpDir, ".config", "crush", "crush.json"), []byte(`{}`), 0644)
os.MkdirAll(filepath.Join(tmpDir, ".cursor"), 0755)
editors := DetectInstalledEditors(tmpDir)
if len(editors) < 2 {
t.Errorf("Expected at least 2 editors, got %d", len(editors))
}
found := map[string]bool{}
for _, e := range editors {
found[e] = true
}
if !found["crush"] {
t.Error("Should detect crush")
}
if !found["cursor"] {
t.Error("Should detect cursor")
}
}
func TestCheckServerStatus(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("HOME", tmpDir)
defer os.Unsetenv("HOME")
SetRegistryPath(filepath.Join(tmpDir, "reg.yaml"))
SaveRegistry(DefaultRegistry())
status := CheckServerStatus("nonexistent")
if status.Error == "" {
t.Error("Should have error for nonexistent server")
}
}

View File

@@ -1,10 +1,12 @@
package orchestrator
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
@@ -12,7 +14,6 @@ import (
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/workflow"
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
@@ -20,21 +21,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
const maxHistorySize = 100
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type ToolCallMsg struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFuncMsg `json:"function"`
}
type ToolCallFuncMsg struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Tools json.RawMessage `json:"tools,omitempty"`
}
type ChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
TotalTokens int `json:"total_tokens"`
@@ -42,18 +64,24 @@ type ChatResponse struct {
}
type Orchestrator struct {
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
Workflow *workflow.Workflow
config *config.MuyueConfig
provider *config.AIProvider
client *http.Client
history []Message
histMu sync.Mutex
systemPrompt string
tools json.RawMessage
}
var sharedHTTPClient = &http.Client{
Timeout: 120 * time.Second,
}
// requestClient creates an HTTP client with the specified timeout.
func requestClient(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout}
}
func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
var provider *config.AIProvider
for i := range cfg.AI.Providers {
@@ -72,14 +100,45 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
}
return &Orchestrator{
config: cfg,
config: cfg,
provider: provider,
client: sharedHTTPClient,
history: []Message{},
Workflow: workflow.New(),
client: sharedHTTPClient,
history: []Message{},
}, nil
}
func (o *Orchestrator) SetSystemPrompt(prompt string) {
o.systemPrompt = prompt
}
func (o *Orchestrator) SetTools(tools json.RawMessage) {
o.tools = tools
}
func (o *Orchestrator) ProviderName() string {
if o.provider == nil {
return ""
}
return o.provider.Name
}
func (o *Orchestrator) AppendHistory(msg Message) {
o.histMu.Lock()
defer o.histMu.Unlock()
o.history = append(o.history, msg)
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
}
func (o *Orchestrator) GetHistory() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
out := make([]Message, len(o.history))
copy(out, o.history)
return out
}
func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
@@ -91,216 +150,296 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
o.history = o.history[len(o.history)-maxHistorySize:]
}
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Stream: false,
}
o.histMu.Unlock()
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
baseURL := o.provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(o.provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no response from AI")
}
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
o.histMu.Unlock()
return content, nil
}
func (o *Orchestrator) StartWorkflow(goal string) (string, error) {
o.Workflow.Start(goal)
prompt := fmt.Sprintf("I want to: %s\nWhat questions do you need to ask me to fully understand this requirement? Ask ALL questions at once.", goal)
o.history = []Message{
{Role: "system", Content: workflow.BuildSystemPrompt(workflow.PhaseGathering, o.Workflow.Plan)},
{Role: "user", Content: prompt},
messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
}
messages = append(messages, o.history...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: o.history,
Messages: messages,
Stream: false,
Tools: o.tools,
}
o.histMu.Unlock()
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
baseURL := o.provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(o.provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no response from AI")
}
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
return content, nil
}
func (o *Orchestrator) AnswerQuestion(answer string) (string, error) {
o.Workflow.AddAnswer(answer)
return o.Send(answer)
}
func (o *Orchestrator) GeneratePlan() (string, error) {
o.Workflow.Phase = workflow.PhasePlanning
o.history = append(o.history, Message{
Role: "system",
Content: workflow.BuildSystemPrompt(workflow.PhasePlanning, o.Workflow.Plan),
})
prompt := "All questions have been answered. Now create a detailed step-by-step execution plan as a JSON array. Each step should have: id, title, description, agent (crush/claude/muyue)."
if len(o.Workflow.Plan.PreviewFiles) > 0 {
prompt += "\nInclude visual previews where helpful using the PREVIEW_JSON format."
}
resp, err := o.Send(prompt)
chatResp, providerName, err := o.sendWithFallback(reqBody, "")
if err != nil {
return "", err
}
steps, parseErr := workflow.ParsePlanResponse(resp)
if parseErr == nil {
o.Workflow.SetPlan("")
o.Workflow.Plan.Steps = steps
o.Workflow.Phase = workflow.PhaseReviewing
}
previewFiles := workflow.ParsePreviewFiles(resp)
if len(previewFiles) > 0 {
o.Workflow.SetPreviewFiles(previewFiles)
}
return resp, nil
}
func (o *Orchestrator) ReviewPlan(approved bool, feedback string) (string, error) {
if approved {
o.Workflow.Approve()
return o.executeNextStep()
}
o.Workflow.Reject(feedback)
return o.Send(fmt.Sprintf("The plan was rejected. Reason: %s. Please revise the plan.", feedback))
}
func (o *Orchestrator) executeNextStep() (string, error) {
step := o.Workflow.CurrentStep()
if step == nil {
return "All steps completed!", nil
}
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "system",
Content: workflow.BuildSystemPrompt(workflow.PhaseExecuting, o.Workflow.Plan),
Role: "assistant",
Content: content,
})
_ = providerName
o.histMu.Unlock()
return content, nil
}
func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) {
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "user",
Content: userMessage,
})
return o.Send(fmt.Sprintf("Execute step %s: %s\n%s", step.ID, step.Title, step.Description))
}
func (o *Orchestrator) ContinueExecution(output string) (string, error) {
o.Workflow.AdvanceStep(output)
if o.Workflow.Phase == workflow.PhaseDone {
return "Workflow completed! All steps have been executed.", nil
if len(o.history) > maxHistorySize {
o.history = o.history[len(o.history)-maxHistorySize:]
}
return o.executeNextStep()
}
func (o *Orchestrator) History() []Message {
o.histMu.Lock()
defer o.histMu.Unlock()
cp := make([]Message, len(o.history))
copy(cp, o.history)
return cp
}
messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
}
messages = append(messages, o.history...)
func (o *Orchestrator) ClearHistory() {
o.histMu.Lock()
o.history = []Message{}
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: messages,
Stream: true,
Tools: o.tools,
}
o.histMu.Unlock()
o.Workflow.Reset()
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
baseURL := o.provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(o.provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var fullContent strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chatResp ChatResponse
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
continue
}
if len(chatResp.Choices) > 0 {
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
onChunk(chunk)
}
}
}
if err := scanner.Err(); err != nil {
return fullContent.String(), fmt.Errorf("read stream: %w", err)
}
content := cleanAIResponse(fullContent.String())
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
Content: content,
})
o.histMu.Unlock()
return content, nil
}
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
}
fullMessages = append(fullMessages, messages...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: fullMessages,
Stream: false,
Tools: o.tools,
}
chatResp, _, err := o.sendWithFallback(reqBody, "")
if err != nil {
return nil, err
}
if len(chatResp.Choices) == 0 {
return nil, fmt.Errorf("no response from AI")
}
return chatResp, nil
}
// ChunkCallback is called for each streaming chunk.
type ChunkCallback func(content string, toolCalls []ToolCallMsg)
// SendWithToolsStream sends messages with streaming responses.
// The callback receives chunks of content and tool_calls as they arrive.
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
}
fullMessages = append(fullMessages, messages...)
reqBody := ChatRequest{
Model: o.provider.Model,
Messages: fullMessages,
Stream: true,
Tools: o.tools,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
provider := o.provider
baseURL := provider.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(provider.Name)
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+provider.APIKey)
resp, err := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var fullContent strings.Builder
var accumulatedToolCalls []ToolCallMsg
var totalTokens int
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chatResp ChatResponse
if err := json.Unmarshal([]byte(data), &chatResp); err != nil {
continue
}
if len(chatResp.Choices) > 0 {
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
onChunk(chunk, nil)
}
// Handle delta tool calls
if len(chatResp.Choices[0].Delta.ToolCalls) > 0 {
for _, tc := range chatResp.Choices[0].Delta.ToolCalls {
// Find or create the tool call in accumulated list
found := false
for i := range accumulatedToolCalls {
if accumulatedToolCalls[i].ID == tc.ID {
// Append arguments
accumulatedToolCalls[i].Function.Arguments += tc.Function.Arguments
found = true
break
}
}
if !found {
accumulatedToolCalls = append(accumulatedToolCalls, tc)
}
}
onChunk("", accumulatedToolCalls)
}
// Capture usage from final chunk
if chatResp.Usage.TotalTokens > 0 {
totalTokens = chatResp.Usage.TotalTokens
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read stream: %w", err)
}
// Build final response
finalResp := &ChatResponse{
Usage: struct {
TotalTokens int `json:"total_tokens"`
}{TotalTokens: totalTokens},
Choices: []struct {
Message struct {
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"message"`
Delta struct {
Content string `json:"content"`
ToolCalls []ToolCallMsg `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}{},
}
finalContent := cleanAIResponse(fullContent.String())
finalResp.Choices[0].Message.Content = finalContent
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
return finalResp, nil
}
func cleanAIResponse(content string) string {
@@ -341,3 +480,104 @@ func getProviderBaseURL(name string) string {
return ""
}
}
func (o *Orchestrator) getAvailableProviders() []*config.AIProvider {
var providers []*config.AIProvider
for i := range o.config.AI.Providers {
prov := &o.config.AI.Providers[i]
if prov.APIKey != "" {
providers = append(providers, prov)
}
}
return providers
}
func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride string) (*ChatResponse, string, error) {
providers := o.getAvailableProviders()
if len(providers) == 0 {
return nil, "", fmt.Errorf("no providers available")
}
providerOrder := make([]*config.AIProvider, 0, len(providers))
if o.provider != nil {
providerOrder = append(providerOrder, o.provider)
}
for _, p := range providers {
if o.provider == nil || p.Name != o.provider.Name {
providerOrder = append(providerOrder, p)
}
}
var lastErr error
var triedProviders []string
for _, prov := range providerOrder {
triedProviders = append(triedProviders, prov.Name)
baseURL := baseURLOverride
if baseURL == "" {
baseURL = prov.BaseURL
if baseURL == "" {
baseURL = getProviderBaseURL(prov.Name)
}
}
url := strings.TrimRight(baseURL, "/") + "/chat/completions"
body, err := json.Marshal(reqBody)
if err != nil {
lastErr = fmt.Errorf("marshal request: %w", err)
continue
}
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
lastErr = fmt.Errorf("create request: %w", err)
continue
}
req.Header.Set("Content-Type", "application/json")
// Provider-specific headers
if prov.Name == "anthropic" {
req.Header.Set("x-api-key", prov.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
} else {
req.Header.Set("Authorization", "Bearer "+prov.APIKey)
}
resp, err := o.client.Do(req)
if err != nil {
lastErr = fmt.Errorf("send request to %s: %w", prov.Name, err)
continue
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
lastErr = fmt.Errorf("read response: %w", err)
continue
}
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
continue
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
lastErr = fmt.Errorf("parse response: %w", err)
continue
}
if len(chatResp.Choices) == 0 {
lastErr = fmt.Errorf("no response from AI")
continue
}
o.provider = prov
return &chatResp, prov.Name, nil
}
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
return nil, "", lastErr
}

View File

@@ -1,19 +1,15 @@
package orchestrator
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/muyue/muyue/internal/config"
)
func testConfig() *config.MuyueConfig {
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-api-key-12345"
return cfg
}
func TestCleanAIResponse(t *testing.T) {
tests := []struct {
name string
@@ -21,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) {
expected string
}{
{
"removes standard think tags",
"malformed think tags pass through",
"<think internal reasoning</think Hello world",
"<think internal reasoning</think Hello world",
},
@@ -31,7 +27,7 @@ func TestCleanAIResponse(t *testing.T) {
"response",
},
{
"removes think with attrs",
"think with attrs, no closing bracket",
"<think type=re>reasoning</think result",
"<think type=re>reasoning</think result",
},
@@ -56,12 +52,12 @@ func TestCleanAIResponse(t *testing.T) {
"",
},
{
"removes valid think block",
"malformed think block no closing bracket",
"<think some reasoning here</think rest",
"<think some reasoning here</think rest",
},
{
"removes simple think",
"malformed simple think no closing bracket",
"before<think reasoning</think after",
"before<think reasoning</think after",
},
@@ -154,57 +150,127 @@ func TestNewNoAPIKey(t *testing.T) {
}
}
func TestHistoryManagement(t *testing.T) {
cfg := testConfig()
orch, err := New(cfg)
if err != nil {
t.Fatalf("New failed: %v", err)
}
h := orch.History()
if len(h) != 0 {
t.Errorf("Expected empty history, got %d", len(h))
}
orch.ClearHistory()
h = orch.History()
if len(h) != 0 {
t.Errorf("Expected 0 after clear, got %d", len(h))
}
}
func TestHistoryCopy(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
orch.history = []Message{
{Role: "user", Content: "hello"},
}
h := orch.History()
h[0].Content = "modified"
orig := orch.History()
if orig[0].Content == "modified" {
t.Error("History should return a copy")
}
}
func TestMaxHistorySize(t *testing.T) {
cfg := testConfig()
orch, _ := New(cfg)
for i := 0; i < maxHistorySize+10; i++ {
orch.histMu.Lock()
orch.history = append(orch.history, Message{Role: "user", Content: "msg"})
if len(orch.history) > maxHistorySize {
orch.history = orch.history[len(orch.history)-maxHistorySize:]
func TestSendStreamChunks(t *testing.T) {
sseBody := `data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" world"}}]}
data: {"choices":[{"delta":{"content":"!"}}]}
data: [DONE]
`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
orch.histMu.Unlock()
var reqBody ChatRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !reqBody.Stream {
http.Error(w, "stream must be true", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte(sseBody))
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
h := orch.History()
if len(h) > maxHistorySize {
t.Errorf("History should be capped at %d, got %d", maxHistorySize, len(h))
var chunks []string
result, err := orb.SendStream("hi", func(chunk string) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("SendStream: %v", err)
}
if result != "Hello world!" {
t.Errorf("SendStream result = %q, want %q", result, "Hello world!")
}
if len(chunks) != 3 {
t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks)
}
if strings.Join(chunks, "") != "Hello world!" {
t.Errorf("chunks joined = %q, want %q", strings.Join(chunks, ""), "Hello world!")
}
}
func TestSendStreamHistory(t *testing.T) {
callCount := 0
sseBody := `data: {"choices":[{"delta":{"content":"reply"}}]}
data: [DONE]
`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
var reqBody ChatRequest
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if callCount == 1 {
if len(reqBody.Messages) != 2 {
t.Errorf("first call: expected 2 messages (system + 1 user), got %d", len(reqBody.Messages))
}
} else {
if len(reqBody.Messages) != 4 {
t.Errorf("second call: expected 4 messages (system + 3 history), got %d", len(reqBody.Messages))
}
}
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte(sseBody))
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
orb.SetSystemPrompt("you are helpful")
_, _ = orb.SendStream("first", func(string) {})
_, _ = orb.SendStream("second", func(string) {})
orb.histMu.Lock()
if len(orb.history) != 4 {
t.Errorf("expected 4 history entries (2 user + 2 assistant), got %d", len(orb.history))
}
orb.histMu.Unlock()
}
func TestSendStreamAPIError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
}))
defer ts.Close()
cfg := config.Default()
cfg.AI.Providers[0].Active = true
cfg.AI.Providers[0].APIKey = "test-key"
cfg.AI.Providers[0].BaseURL = ts.URL
orb, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = orb.SendStream("hi", func(string) {})
if err == nil {
t.Error("expected error for non-200 response")
}
if !strings.Contains(err.Error(), "429") {
t.Errorf("error should mention status code, got: %v", err)
}
}

View File

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

View File

@@ -1,6 +1,7 @@
package platform
import (
"strings"
"testing"
)
@@ -43,16 +44,9 @@ func TestString(t *testing.T) {
if s == "" {
t.Error("String should not be empty")
}
if !contains(s, "linux") {
if !strings.Contains(s, "linux") {
t.Error("Should contain OS")
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -1,79 +0,0 @@
package preview
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
type PreviewServer struct {
dir string
server *http.Server
}
func NewPreviewServer(dir string) *PreviewServer {
return &PreviewServer{dir: dir}
}
func (p *PreviewServer) Start(port int) error {
fs := http.FileServer(http.Dir(p.dir))
mux := http.NewServeMux()
mux.Handle("/", fs)
p.server = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", port),
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
go func() {
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Preview server error: %s\n", err)
}
}()
url := fmt.Sprintf("http://127.0.0.1:%d", port)
fmt.Printf("Preview server running at %s\n", url)
return openBrowser(url)
}
func (p *PreviewServer) Stop() error {
if p.server != nil {
return p.server.Close()
}
return nil
}
func CreatePreviewFile(dir, filename, content string) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)
}
func openBrowser(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd = "xdg-open"
args = []string{url}
case "darwin":
cmd = "open"
args = []string{url}
case "windows":
cmd = "cmd"
args = []string{"/c", "start", url}
default:
return fmt.Errorf("unsupported platform")
}
return exec.Command(cmd, args...).Start()
}

View File

@@ -279,7 +279,7 @@ func AskAPIKey(providerName string) (string, error) {
field := huh.NewInput().
Title(fmt.Sprintf("Enter your %s API key:", providerName)).
Description("The key will be stored locally in ~/.muyue/config.yaml").
Description("The key will be stored locally in ~/.config/muyue/config.yaml").
EchoMode(huh.EchoModePassword).
Value(&apiKey)

View File

@@ -1,250 +0,0 @@
package proxy
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
)
type AgentType string
const (
AgentCrush AgentType = "crush"
AgentClaude AgentType = "claude"
)
type AgentStatus string
const (
StatusIdle AgentStatus = "idle"
StatusRunning AgentStatus = "running"
StatusStopped AgentStatus = "stopped"
StatusError AgentStatus = "error"
)
type LogEntry struct {
Timestamp time.Time
Agent AgentType
Level string
Message string
}
type Agent struct {
Type AgentType
Status AgentStatus
cmd *exec.Cmd
stdout io.Reader
stderr io.Reader
cancel context.CancelFunc
mu sync.Mutex
logs []LogEntry
}
type Manager struct {
agents map[AgentType]*Agent
mu sync.RWMutex
}
func NewManager() *Manager {
return &Manager{
agents: make(map[AgentType]*Agent),
}
}
func (m *Manager) Start(agentType AgentType, args ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
if a, exists := m.agents[agentType]; exists && a.Status == StatusRunning {
return fmt.Errorf("%s already running", agentType)
}
ctx, cancel := context.WithCancel(context.Background())
var cmdName string
switch agentType {
case AgentCrush:
cmdName = "crush"
case AgentClaude:
cmdName = "claude"
default:
cancel()
return fmt.Errorf("unknown agent type: %s", agentType)
}
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Env = os.Environ()
stdout, pipeErr := cmd.StdoutPipe()
if pipeErr != nil {
cancel()
return fmt.Errorf("stdout pipe: %w", pipeErr)
}
stderr, pipeErr := cmd.StderrPipe()
if pipeErr != nil {
cancel()
return fmt.Errorf("stderr pipe: %w", pipeErr)
}
agent := &Agent{
Type: agentType,
Status: StatusRunning,
cmd: cmd,
stdout: stdout,
stderr: stderr,
cancel: cancel,
}
m.agents[agentType] = agent
go agent.captureOutput(stdout, "info")
go agent.captureOutput(stderr, "error")
if err := cmd.Start(); err != nil {
agent.Status = StatusError
cancel()
return fmt.Errorf("start %s: %w", agentType, err)
}
go func() {
err := cmd.Wait()
m.mu.Lock()
defer m.mu.Unlock()
if err != nil && ctx.Err() == nil {
agent.Status = StatusError
agent.log("error", fmt.Sprintf("exited with error: %s", err))
} else {
agent.Status = StatusStopped
agent.log("info", "stopped")
}
}()
return nil
}
func (m *Manager) Stop(agentType AgentType) error {
m.mu.Lock()
defer m.mu.Unlock()
agent, exists := m.agents[agentType]
if !exists {
return fmt.Errorf("%s not found", agentType)
}
if agent.Status != StatusRunning {
return fmt.Errorf("%s is not running", agentType)
}
agent.cancel()
agent.Status = StatusStopped
return nil
}
func (m *Manager) Status(agentType AgentType) (AgentStatus, []LogEntry) {
m.mu.RLock()
defer m.mu.RUnlock()
agent, exists := m.agents[agentType]
if !exists {
return StatusIdle, nil
}
agent.mu.Lock()
defer agent.mu.Unlock()
return agent.Status, agent.logs
}
func (m *Manager) AllStatus() map[AgentType]AgentStatus {
m.mu.RLock()
defer m.mu.RUnlock()
statuses := make(map[AgentType]AgentStatus)
for t, a := range m.agents {
statuses[t] = a.Status
}
return statuses
}
func (a *Agent) captureOutput(reader io.Reader, level string) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
a.mu.Lock()
a.logs = append(a.logs, LogEntry{
Timestamp: time.Now(),
Agent: a.Type,
Level: level,
Message: line,
})
if len(a.logs) > 1000 {
a.logs = a.logs[500:]
}
a.mu.Unlock()
}
}
func (a *Agent) log(level, msg string) {
a.mu.Lock()
defer a.mu.Unlock()
a.logs = append(a.logs, LogEntry{
Timestamp: time.Now(),
Agent: a.Type,
Level: level,
Message: msg,
})
}
func (m *Manager) IsAvailable(agentType AgentType) bool {
var cmdName string
switch agentType {
case AgentCrush:
cmdName = "crush"
case AgentClaude:
cmdName = "claude"
default:
return false
}
path, err := exec.LookPath(cmdName)
return err == nil && path != ""
}
func (m *Manager) GetLogs(agentType AgentType, lastN int) []LogEntry {
m.mu.RLock()
agent, exists := m.agents[agentType]
m.mu.RUnlock()
if !exists {
return nil
}
agent.mu.Lock()
defer agent.mu.Unlock()
logs := agent.logs
if lastN > 0 && len(logs) > lastN {
logs = logs[len(logs)-lastN:]
}
return logs
}
func FormatLogs(logs []LogEntry) string {
var b strings.Builder
for _, l := range logs {
b.WriteString(fmt.Sprintf("[%s] %s %s: %s\n",
l.Timestamp.Format("15:04:05"),
l.Agent,
l.Level,
l.Message,
))
}
return b.String()
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
@@ -13,31 +14,31 @@ import (
)
type ToolStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Path string `yaml:"path"`
Latest string `yaml:"latest"`
NeedsUpdate bool `yaml:"needs_update"`
Category string `yaml:"category"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
Path string `yaml:"path" json:"path"`
Latest string `yaml:"latest" json:"latest"`
NeedsUpdate bool `yaml:"needs_update" json:"needs_update"`
Category string `yaml:"category" json:"category"`
}
type RuntimeStatus struct {
Name string `yaml:"name"`
Installed bool `yaml:"installed"`
Version string `yaml:"version"`
Name string `yaml:"name" json:"name"`
Installed bool `yaml:"installed" json:"installed"`
Version string `yaml:"version" json:"version"`
}
type ScanResult struct {
System platform.SystemInfo `yaml:"system"`
Tools []ToolStatus `yaml:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes"`
ShellSetup bool `yaml:"shell_setup"`
GitConfigured bool `yaml:"git_configured"`
System platform.SystemInfo `yaml:"system" json:"system"`
Tools []ToolStatus `yaml:"tools" json:"tools"`
Runtimes []RuntimeStatus `yaml:"runtimes" json:"runtimes"`
ShellSetup bool `yaml:"shell_setup" json:"shell_setup"`
GitConfigured bool `yaml:"git_configured" json:"git_configured"`
}
var (
cacheMu sync.RWMutex
cacheMu sync.RWMutex
cacheResult *ScanResult
cacheTime time.Time
cacheTTL = 5 * time.Minute
@@ -169,7 +170,7 @@ func checkShellSetup() bool {
home, _ := os.UserHomeDir()
rcFiles := []string{".bashrc", ".zshrc", ".config/fish/config.fish"}
for _, f := range rcFiles {
data, err := os.ReadFile(home + "/" + f)
data, err := os.ReadFile(filepath.Join(home, f))
if err != nil {
continue
}
@@ -192,6 +193,43 @@ func checkGitConfig() bool {
return true
}
var editorsList = []struct {
name string
cmd []string
version []string
}{
{"vim", []string{"vim"}, []string{"--version"}},
{"nvim", []string{"nvim"}, []string{"--version"}},
{"code", []string{"code"}, []string{"--version"}},
{"emacs", []string{"emacs"}, []string{"--version"}},
{"nano", []string{"nano"}, []string{"--version"}},
{"helix", []string{"hx"}, []string{"--version"}},
{"subl", []string{"subl"}, []string{"--version"}},
{"zed", []string{"zed"}, []string{"--version"}},
}
func ScanEditors() []ToolStatus {
var results []ToolStatus
for _, e := range editorsList {
status := ToolStatus{Name: e.name}
path, err := exec.LookPath(e.name)
if err != nil {
continue
}
status.Installed = true
status.Path = path
if len(e.version) > 0 {
cmd := exec.Command(e.cmd[0], e.version...)
out, err := cmd.Output()
if err == nil {
status.Version = strings.TrimSpace(string(out))
}
}
results = append(results, status)
}
return results
}
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func (s *ScanResult) Summary() string {

View File

@@ -11,9 +11,10 @@ var builtinSkills = []Skill{
Name: "env-setup",
Description: "Set up a complete development environment for any language. Detects missing tools, installs them, and configures the project.",
Author: "muyue",
Version: "1.0.0",
Version: "1.1.0",
Target: "both",
Tags: []string{"setup", "environment", "install"},
Category: "setup",
Content: `# Environment Setup
Use this skill when setting up a new development environment or project.
@@ -58,9 +59,14 @@ Use this skill when setting up a new development environment or project.
Name: "git-workflow",
Description: "Manage git branches, commits, and pull requests following best practices. Handles branching strategy, conventional commits, and PR creation.",
Author: "muyue",
Version: "1.0.0",
Version: "1.1.0",
Target: "both",
Tags: []string{"git", "workflow", "branching", "commits"},
Category: "workflow",
Dependencies: []SkillDependency{
{Type: "tool", Name: "git", Required: true},
{Type: "tool", Name: "gh", Required: false},
},
Content: `# Git Workflow
Use this skill when the user needs to create branches, make commits, or manage pull requests.
@@ -114,9 +120,10 @@ Follow Conventional Commits:
Name: "api-design",
Description: "Design and implement REST or GraphQL APIs following best practices. Includes endpoint design, error handling, and documentation.",
Author: "muyue",
Version: "1.0.0",
Version: "1.1.0",
Target: "both",
Tags: []string{"api", "rest", "graphql", "design"},
Category: "design",
Content: `# API Design
Use this skill when designing or implementing an API.
@@ -171,9 +178,10 @@ Use this skill when designing or implementing an API.
Name: "debug-assist",
Description: "Systematic debugging assistant. Helps identify, isolate, and fix bugs using a structured approach.",
Author: "muyue",
Version: "1.0.0",
Version: "1.1.0",
Target: "both",
Tags: []string{"debug", "troubleshooting", "bugs"},
Category: "debugging",
Content: `# Debug Assist
Use this skill when the user reports a bug or asks for help debugging.
@@ -188,7 +196,7 @@ Use this skill when the user reports a bug or asks for help debugging.
3. **Hypothesize** — Form a hypothesis about the root cause
4. **Verify** — Add logging or breakpoints to confirm
5. **Fix** — Make the minimal change to fix the issue
6. **Test** — Verify the fix works and doesn't break other things
6. **Test** — Verify the fix works and does not break other things
7. **Prevent** — Add a test to prevent regression
## Common Patterns
@@ -211,9 +219,10 @@ Use this skill when the user reports a bug or asks for help debugging.
Name: "code-review",
Description: "Perform a thorough code review. Checks for bugs, security issues, performance problems, and style consistency.",
Author: "muyue",
Version: "1.0.0",
Version: "1.1.0",
Target: "both",
Tags: []string{"review", "quality", "security"},
Category: "quality",
Content: `# Code Review
Use this skill when reviewing code changes or pull requests.
@@ -221,7 +230,7 @@ Use this skill when reviewing code changes or pull requests.
## Review Checklist
### Correctness
- Does the code do what it's supposed to?
- Does the code do what it is supposed to?
- Are edge cases handled?
- Are there off-by-one errors?
- Are error paths handled?
@@ -254,7 +263,7 @@ Use this skill when reviewing code changes or pull requests.
## Review Format
1. Summary of changes
2. Issues found (critical minor)
2. Issues found (critical to minor)
3. Suggestions for improvement
4. Positive observations
@@ -265,6 +274,351 @@ Use this skill when reviewing code changes or pull requests.
- **Minor**: Style issues, naming, minor refactoring opportunities
- **Suggestion**: Alternative approaches, improvements`,
},
{
Name: "docker-setup",
Description: "Set up Docker and docker-compose for a project with best practices including multi-stage builds, health checks, and proper networking.",
Author: "muyue",
Version: "1.0.0",
Target: "both",
Tags: []string{"docker", "containers", "devops", "compose"},
Category: "devops",
Dependencies: []SkillDependency{
{Type: "tool", Name: "docker", Required: true},
},
Content: `# Docker Setup
Use this skill when the user needs Docker configuration for a project.
## Dockerfile Best Practices
1. Use multi-stage builds to reduce image size:
- Builder stage: install dependencies, compile
- Runtime stage: copy only the binary/artifacts
2. Use specific base image tags (not ` + "`latest`" + `):
- ` + "`golang:1.24-alpine`" + ` for Go
- ` + "`node:22-slim`" + ` for Node.js
- ` + "`python:3.12-slim`" + ` for Python
3. Order layers for cache efficiency:
- Copy dependency files first (go.mod, package.json, requirements.txt)
- Install dependencies
- Copy source code last
4. Add health checks:
` + "```" + `dockerfile
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1
` + "```" + `
5. Run as non-root user:
` + "```" + `dockerfile
RUN adduser -D appuser
USER appuser
` + "```" + `
## docker-compose.yml Structure
` + "```" + `yaml
version: "3.9"
services:
app:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 10s
timeout: 3s
retries: 5
volumes:
pgdata:
` + "```" + `
## Error Handling
- If Docker is not installed, provide install instructions for the platform
- If port is already in use, suggest alternative ports
- If build fails, check for missing .dockerignore and suggest one`,
},
{
Name: "security-audit",
Description: "Perform a security audit on code, dependencies, and configuration. Checks for OWASP Top 10 vulnerabilities, dependency vulnerabilities, and misconfigurations.",
Author: "muyue",
Version: "1.0.0",
Target: "both",
Tags: []string{"security", "audit", "vulnerabilities", "owasp"},
Category: "security",
Content: `# Security Audit
Use this skill when the user needs a security review or vulnerability assessment.
## Audit Checklist
### Input Validation (OWASP A03:2021)
- All user input is validated and sanitized
- SQL queries use parameterized statements
- File paths are validated (no path traversal)
- Input length limits are enforced
### Authentication and Authorization (OWASP A07:2021)
- Passwords are hashed with bcrypt/argon2 (never MD5/SHA1)
- JWT tokens have short expiry with refresh rotation
- Session management is secure
- RBAC or ABAC is properly implemented
- API endpoints have proper auth checks
### Data Protection (OWASP A02:2021)
- Secrets are not in source code (use env vars or secret managers)
- Sensitive data is encrypted at rest and in transit
- PII is properly handled and not logged
- TLS is enforced for all connections
### Dependency Security (OWASP A06:2021)
- Run ` + "`npm audit`" + `, ` + "`pip audit`" + `, or ` + "`go vuln check`" + `
- Check for known CVEs in dependencies
- Keep dependencies up to date
- Use lock files for reproducible builds
### Configuration Security
- Debug mode is disabled in production
- CORS is properly configured
- Rate limiting is in place
- Security headers are set (CSP, HSTS, X-Frame-Options)
- Error messages do not leak internal details
## Automated Checks
Run these tools if available:
- ` + "`gosec ./...`" + ` for Go security
- ` + "`bandit -r .`" + ` for Python security
- ` + "`npm audit`" + ` for Node.js vulnerabilities
- ` + "`trivy fs .`" + ` for container/Dockerfile scanning
## Report Format
1. Executive Summary (risk level, total findings)
2. Critical findings (immediate action required)
3. High findings (fix within 24h)
4. Medium findings (fix within sprint)
5. Low findings (address when convenient)
6. Recommendations`,
},
{
Name: "mcp-setup",
Description: "Configure MCP (Model Context Protocol) servers for AI tools. Discovers, installs, and configures MCP servers across multiple editors.",
Author: "muyue",
Version: "1.0.0",
Target: "both",
Tags: []string{"mcp", "ai", "configuration", "editors"},
Category: "setup",
Dependencies: []SkillDependency{
{Type: "tool", Name: "npx", Required: true},
},
Content: `# MCP Server Setup
Use this skill when the user wants to configure MCP servers for their AI coding tools.
## Supported Editors
Muyue can generate MCP configs for:
- **Crush**: ` + "`~/.config/crush/crush.json`" + ` (key: ` + "`mcps`" + `)
- **Claude Code**: ` + "`~/.claude.json`" + ` (key: ` + "`mcpServers`" + `)
- **Cursor**: ` + "`~/.cursor/mcp.json`" + ` (key: ` + "`mcpServers`" + `, adds ` + "`type: stdio`" + `)
- **VS Code**: ` + "`~/.vscode/mcp.json`" + ` (key: ` + "`servers`" + `)
- **Windsurf**: ` + "`~/.windsurf/mcp.json`" + ` (key: ` + "`mcpServers`" + `)
## Common MCP Servers
| Server | Package | Required Env |
|--------|---------|-------------|
| filesystem | @modelcontextprotocol/server-filesystem | None |
| fetch | @modelcontextprotocol/server-fetch | None |
| github | @modelcontextprotocol/server-github | GITHUB_PERSONAL_ACCESS_TOKEN |
| brave-search | @modelcontextprotocol/server-brave-search | BRAVE_API_KEY |
| memory | @modelcontextprotocol/server-memory | None |
| postgres | @modelcontextprotocol/server-postgres | DATABASE_URL |
| sqlite | @modelcontextprotocol/server-sqlite | None |
| docker | @modelcontextprotocol/server-docker | None |
## Setup Steps
1. Ask which editors the user wants to configure
2. Ask which MCP servers they need
3. For servers requiring API keys, prompt for the key
4. Generate configs for each selected editor
5. Validate configs (check JSON is valid, commands exist)
6. Test connectivity if possible
## Credential Management
- API keys should be stored in the Muyue config (encrypted)
- When generating MCP configs, inject keys from the Muyue config
- Never hardcode API keys in config files in version control
- Suggest adding MCP config files to ` + "`.gitignore`" + `
## Troubleshooting
- If npx fails, suggest ` + "`npm install -g`" + ` the package
- If a server does not start, check the command and args
- If auth fails, verify the API key is correct and active`,
},
{
Name: "lsp-setup",
Description: "Configure Language Server Protocol servers for code intelligence. Detects project languages, installs LSPs, and generates editor configs.",
Author: "muyue",
Version: "1.0.0",
Target: "both",
Tags: []string{"lsp", "language-server", "ide", "configuration"},
Category: "setup",
Content: `# LSP Server Setup
Use this skill when the user wants to set up language servers for code intelligence.
## Supported Languages
| Language | Server | Install Method |
|----------|--------|---------------|
| Go | gopls | ` + "`go install`" + ` |
| Python | pyright | ` + "`npm install -g`" + ` |
| TypeScript/JS | typescript-language-server | ` + "`npm install -g`" + ` |
| Rust | rust-analyzer | ` + "`rustup component add`" + ` |
| C/C++ | clangd | System package |
| Lua | lua-language-server | ` + "`npm install -g`" + ` |
| HTML | vscode-html-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` |
| CSS | vscode-css-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` |
| JSON | vscode-json-language-server | ` + "`npm install -g vscode-langservers-extracted`" + ` |
| YAML | yaml-language-server | ` + "`npm install -g`" + ` |
| Bash | bash-language-server | ` + "`npm install -g`" + ` |
| Docker | dockerfile-language-server | ` + "`npm install -g`" + ` |
| Vue | vue-language-server | ` + "`npm install -g`" + ` |
| Svelte | svelte-language-server | ` + "`npm install -g`" + ` |
## Auto-Detection
Detect project languages from:
- Config files: ` + "`go.mod`" + `, ` + "`package.json`" + `, ` + "`Cargo.toml`" + `, ` + "`pyproject.toml`" + `
- Source file extensions: ` + "`*.go`" + `, ` + "`*.py`" + `, ` + "`*.ts`" + `, ` + "`*.rs`" + `
## Editor Config Generation
### Neovim
Generate ` + "`lspconfig`" + ` setup snippet for each LSP.
### Helix
Generate ` + "`languages.toml`" + ` entries with language-server mappings.
### VS Code / Cursor
Generate ` + "`extensions.json`" + ` recommendations for each LSP.
## Health Checks
After installation, verify:
1. The binary is in PATH
2. The version matches expected
3. A basic ` + "`initialize`" + ` request succeeds (if applicable)`,
},
{
Name: "workflow-design",
Description: "Design development workflows and automations. Creates CI/CD pipelines, git hooks, and development process documentation.",
Author: "muyue",
Version: "1.0.0",
Target: "both",
Tags: []string{"workflow", "ci-cd", "automation", "process"},
Category: "workflow",
Content: `# Workflow Design
Use this skill when the user wants to establish development workflows or CI/CD pipelines.
## CI/CD Pipeline Design
### GitHub Actions Template
` + "```" + `yaml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24" }
- run: go vet ./...
- run: golint ./...
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24" }
- run: go test -race -coverprofile=coverage.out ./...
- run: go tool cover -func=coverage.out
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: go build -o bin/app ./cmd/app
` + "```" + `
## Git Hooks
Use ` + "`pre-commit`" + ` framework:
- ` + "`pre-commit`" + `: lint, format check, trailing whitespace
- ` + "`commit-msg`" + `: validate conventional commit format
- ` + "`pre-push`" + `: run tests
## Branch Protection Rules
- Require PR reviews (at least 1 approval)
- Require status checks to pass
- Require up-to-date branch before merge
- Require linear history (rebase merge)
## Development Process
1. Pick a task from the backlog
2. Create a feature branch
3. Implement with tests
4. Run linter and tests locally
5. Push and create PR
6. Address review feedback
7. Merge when approved and CI passes
8. Delete feature branch
## Error Handling
- If CI fails, provide clear error output and suggested fixes
- If hooks fail, explain what failed and how to fix
- Suggest ` + "`--no-verify`" + ` only as a last resort, with a warning`,
},
}
func InstallBuiltinSkills() error {

View File

@@ -1,36 +1,54 @@
package skills
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Skill struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
type SkillDependency struct {
Type string `yaml:"type,omitempty" json:"type,omitempty"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
}
type Target string
type Skill struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
}
const (
TargetCrush Target = "crush"
TargetClaude Target = "claude"
TargetBoth Target = "both"
)
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (v ValidationError) Error() string {
return fmt.Sprintf("%s: %s", v.Field, v.Message)
}
type SkillTestResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Message string `json:"message"`
}
func SkillsDir() (string, error) {
home, err := os.UserHomeDir()
@@ -74,10 +92,6 @@ func List() ([]Skill, error) {
skills = append(skills, *skill)
}
sort.Slice(skills, func(i, j int) bool {
return skills[i].Name < skills[j].Name
})
return skills, nil
}
@@ -103,6 +117,10 @@ func Get(name string) (*Skill, error) {
}
func Create(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs)
}
dir, err := SkillsDir()
if err != nil {
return err
@@ -122,27 +140,6 @@ func Create(skill *Skill) error {
return Deploy(skill)
}
func Update(skill *Skill) error {
dir, err := SkillsDir()
if err != nil {
return err
}
skillDir := filepath.Join(dir, skill.Name)
skillPath := filepath.Join(skillDir, "SKILL.md")
if _, err := os.Stat(skillPath); err != nil {
return fmt.Errorf("skill '%s' not found", skill.Name)
}
skill.UpdatedAt = time.Now()
content := renderSkill(skill)
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
return err
}
return Deploy(skill)
}
func Delete(name string) error {
dir, err := SkillsDir()
if err != nil {
@@ -158,13 +155,35 @@ func Delete(name string) error {
return nil
}
func Update(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs)
}
dir, err := SkillsDir()
if err != nil {
return err
}
skillDir := filepath.Join(dir, skill.Name)
skillPath := filepath.Join(skillDir, "SKILL.md")
skill.UpdatedAt = time.Now()
content := renderSkill(skill)
if err := os.WriteFile(skillPath, []byte(content), 0644); err != nil {
return err
}
return Deploy(skill)
}
func Deploy(skill *Skill) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get home dir: %w", err)
}
if skill.Target == string(TargetCrush) || skill.Target == string(TargetBoth) {
if skill.Target == "crush" || skill.Target == "both" {
crushSkillsDir := filepath.Join(home, ".config", "crush", "skills")
if err := os.MkdirAll(crushSkillsDir, 0755); err != nil {
return fmt.Errorf("create crush skills dir: %w", err)
@@ -179,7 +198,7 @@ func Deploy(skill *Skill) error {
}
}
if skill.Target == string(TargetClaude) || skill.Target == string(TargetBoth) {
if skill.Target == "claude" || skill.Target == "both" {
claudeSkillsDir := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeSkillsDir, 0755); err != nil {
return fmt.Errorf("create claude skills dir: %w", err)
@@ -217,6 +236,206 @@ func undeployFromTargets(name string) {
os.RemoveAll(filepath.Join(home, ".claude", "skills", name))
}
func Validate(skill *Skill) []ValidationError {
var errs []ValidationError
if skill.Name == "" {
errs = append(errs, ValidationError{Field: "name", Message: "name is required"})
}
if skill.Name != "" {
if matched, _ := regexp.MatchString(`^[a-z0-9][a-z0-9-]*$`, skill.Name); !matched {
errs = append(errs, ValidationError{Field: "name", Message: "name must be lowercase alphanumeric with dashes"})
}
}
if skill.Description == "" {
errs = append(errs, ValidationError{Field: "description", Message: "description is required"})
}
if skill.Content == "" {
errs = append(errs, ValidationError{Field: "content", Message: "content is required"})
}
if skill.Target != "" && skill.Target != "crush" && skill.Target != "claude" && skill.Target != "both" {
errs = append(errs, ValidationError{Field: "target", Message: "target must be crush, claude, or both"})
}
if skill.Version != "" {
if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+$`, skill.Version); !matched {
errs = append(errs, ValidationError{Field: "version", Message: "version must be semver (e.g. 1.0.0)"})
}
}
for i, dep := range skill.Dependencies {
if dep.Type != "mcp_server" && dep.Type != "lsp" && dep.Type != "tool" && dep.Type != "runtime" && dep.Type != "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("dependencies[%d].type", i),
Message: "dependency type must be mcp_server, lsp, tool, or runtime",
})
}
if dep.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("dependencies[%d].name", i),
Message: "dependency name is required",
})
}
}
return errs
}
func CheckDependencies(skill *Skill) []SkillDependency {
var missing []SkillDependency
for _, dep := range skill.Dependencies {
switch dep.Type {
case "mcp_server":
if !isMCPServerAvailable(dep.Name) {
missing = append(missing, dep)
}
case "lsp", "tool", "runtime":
if !isToolAvailable(dep.Name) {
missing = append(missing, dep)
}
}
}
return missing
}
func isToolAvailable(name string) bool {
_, err := lookPath(name)
return err == nil
}
func lookPath(name string) (string, error) {
pathEnv := os.Getenv("PATH")
home, _ := os.UserHomeDir()
if home != "" {
pathEnv = home + "/.local/bin:" + home + "/go/bin:" + pathEnv
}
for _, dir := range filepath.SplitList(pathEnv) {
candidate := filepath.Join(dir, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("%s not found", name)
}
func isMCPServerAvailable(name string) bool {
home, _ := os.UserHomeDir()
if home == "" {
return false
}
configPath := filepath.Join(home, ".config", "crush", "crush.json")
data, err := os.ReadFile(configPath)
if err != nil {
return false
}
var cfg map[string]interface{}
if err := json.Unmarshal(data, &cfg); err != nil {
return false
}
mcps, ok := cfg["mcps"].(map[string]interface{})
if !ok {
return false
}
_, exists := mcps[name]
return exists
}
func Export(name string, exportPath string) error {
skill, err := Get(name)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(exportPath), 0755); err != nil {
return err
}
content := renderSkill(skill)
return os.WriteFile(exportPath, []byte(content), 0644)
}
func Import(exportPath string) (*Skill, error) {
data, err := os.ReadFile(exportPath)
if err != nil {
return nil, fmt.Errorf("read export file: %w", err)
}
skill, err := parseSkill(data)
if err != nil {
return nil, err
}
name := filepath.Base(filepath.Dir(exportPath))
if skill.Name == "" {
skill.Name = strings.TrimSuffix(filepath.Base(exportPath), ".md")
if skill.Name == "SKILL" {
skill.Name = filepath.Base(filepath.Dir(exportPath))
}
}
_ = name
if errs := Validate(skill); len(errs) > 0 {
return nil, fmt.Errorf("validation failed: %v", errs)
}
return skill, nil
}
func DryRun(name string, sampleTask string) SkillTestResult {
skill, err := Get(name)
if err != nil {
return SkillTestResult{Name: name, Passed: false, Message: fmt.Sprintf("skill not found: %s", err)}
}
if skill.Content == "" {
return SkillTestResult{Name: name, Passed: false, Message: "skill has no content"}
}
if len(skill.Dependencies) > 0 {
missing := CheckDependencies(skill)
if len(missing) > 0 {
var names []string
for _, d := range missing {
names = append(names, d.Name)
}
return SkillTestResult{
Name: name,
Passed: false,
Message: fmt.Sprintf("missing dependencies: %s", strings.Join(names, ", ")),
}
}
}
if sampleTask != "" {
tags := skill.Tags
taskLower := strings.ToLower(sampleTask)
matched := false
for _, tag := range tags {
if strings.Contains(taskLower, strings.ToLower(tag)) {
matched = true
break
}
}
if len(tags) > 0 && !matched {
return SkillTestResult{
Name: name,
Passed: true,
Message: "skill loaded but sample task does not match skill tags",
}
}
}
return SkillTestResult{
Name: name,
Passed: true,
Message: "skill validated successfully",
}
}
func parseSkill(data []byte) (*Skill, error) {
content := string(data)
@@ -256,9 +475,25 @@ func renderSkill(skill *Skill) string {
if skill.Target != "" {
b.WriteString(fmt.Sprintf("target: %s\n", skill.Target))
}
if skill.Category != "" {
b.WriteString(fmt.Sprintf("category: %s\n", skill.Category))
}
if len(skill.Tags) > 0 {
b.WriteString(fmt.Sprintf("tags: [%s]\n", strings.Join(skill.Tags, ", ")))
}
if len(skill.Languages) > 0 {
b.WriteString(fmt.Sprintf("languages: [%s]\n", strings.Join(skill.Languages, ", ")))
}
if len(skill.Dependencies) > 0 {
b.WriteString("dependencies:\n")
for _, dep := range skill.Dependencies {
req := ""
if dep.Required {
req = ", required: true"
}
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
}
}
b.WriteString("---\n\n")
b.WriteString(skill.Content)
b.WriteString("\n")
@@ -274,7 +509,7 @@ DESCRIPTION: %s
TARGET: %s (crush = Crush with GLM, claude = Claude Code, both = both tools)
The skill must follow this EXACT format:
1. YAML frontmatter with: name, description
1. YAML frontmatter with: name, description, tags, dependencies (if needed)
2. Markdown body with detailed instructions
The skill should be practical, specific, and actionable.
@@ -284,5 +519,10 @@ Include:
- Examples where relevant
- Error handling guidance
If the skill requires specific tools, MCP servers, or LSP servers, declare them as dependencies:
- type: mcp_server, name: <server-name>
- type: lsp, name: <language-server-name>
- type: tool, name: <tool-name>
Output ONLY the skill file content, starting with ---`, name, description, target)
}

View File

@@ -113,7 +113,7 @@ func TestCreateAndGet(t *testing.T) {
Description: "Test description",
Content: "Test content body",
Author: "tester",
Version: "0.1",
Version: "1.0.0",
Target: "both",
}
@@ -198,3 +198,242 @@ func TestInstallBuiltinSkills(t *testing.T) {
t.Error("Expected env-setup skill")
}
}
func TestValidate(t *testing.T) {
skill := &Skill{
Name: "valid-skill",
Description: "A valid skill",
Content: "## Steps\nDo things",
Version: "1.0.0",
Target: "both",
}
errs := Validate(skill)
if len(errs) != 0 {
t.Errorf("Valid skill should have no errors, got %v", errs)
}
}
func TestValidateMissingFields(t *testing.T) {
skill := &Skill{}
errs := Validate(skill)
if len(errs) == 0 {
t.Error("Empty skill should have validation errors")
}
fields := map[string]bool{}
for _, e := range errs {
fields[e.Field] = true
}
if !fields["name"] {
t.Error("Should require name")
}
if !fields["description"] {
t.Error("Should require description")
}
if !fields["content"] {
t.Error("Should require content")
}
}
func TestValidateBadVersion(t *testing.T) {
skill := &Skill{
Name: "test-skill",
Description: "desc",
Content: "content",
Version: "not-semver",
}
errs := Validate(skill)
hasVersionErr := false
for _, e := range errs {
if e.Field == "version" {
hasVersionErr = true
}
}
if !hasVersionErr {
t.Error("Should reject non-semver version")
}
}
func TestValidateBadTarget(t *testing.T) {
skill := &Skill{
Name: "test",
Description: "desc",
Content: "content",
Target: "invalid",
}
errs := Validate(skill)
hasTargetErr := false
for _, e := range errs {
if e.Field == "target" {
hasTargetErr = true
}
}
if !hasTargetErr {
t.Error("Should reject invalid target")
}
}
func TestValidateBadName(t *testing.T) {
skill := &Skill{
Name: "INVALID",
Description: "desc",
Content: "content",
}
errs := Validate(skill)
hasNameErr := false
for _, e := range errs {
if e.Field == "name" {
hasNameErr = true
}
}
if !hasNameErr {
t.Error("Should reject uppercase name")
}
}
func TestValidateDependencies(t *testing.T) {
skill := &Skill{
Name: "test",
Description: "desc",
Content: "content",
Dependencies: []SkillDependency{
{Type: "mcp_server", Name: "github", Required: true},
{Type: "invalid_type", Name: "test"},
},
}
errs := Validate(skill)
hasDepErr := false
for _, e := range errs {
if e.Field == "dependencies[1].type" {
hasDepErr = true
}
}
if !hasDepErr {
t.Error("Should reject invalid dependency type")
}
}
func TestExportImport(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", tmpDir)
skill := &Skill{
Name: "export-test",
Description: "Export test skill",
Content: "## Content",
Author: "tester",
Version: "1.0.0",
Target: "both",
Tags: []string{"test"},
}
Create(skill)
exportPath := filepath.Join(tmpDir, "export", "export-test.md")
if err := Export("export-test", exportPath); err != nil {
t.Fatalf("Export failed: %v", err)
}
if _, err := os.Stat(exportPath); os.IsNotExist(err) {
t.Error("Export file should exist")
}
imported, err := Import(exportPath)
if err != nil {
t.Fatalf("Import failed: %v", err)
}
if imported.Description != "Export test skill" {
t.Errorf("Expected 'Export test skill', got %s", imported.Description)
}
}
func TestDryRun(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", tmpDir)
skill := &Skill{
Name: "dry-run-test",
Description: "Dry run test",
Content: "## Steps\nDo something",
Version: "1.0.0",
Target: "both",
Tags: []string{"test"},
}
Create(skill)
result := DryRun("dry-run-test", "test something")
if !result.Passed {
t.Errorf("DryRun should pass, got: %s", result.Message)
}
}
func TestDryRunMissing(t *testing.T) {
result := DryRun("nonexistent", "")
if result.Passed {
t.Error("DryRun of nonexistent skill should fail")
}
}
func TestUpdate(t *testing.T) {
tmpDir := t.TempDir()
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", tmpDir)
skill := &Skill{
Name: "update-test",
Description: "Original",
Content: "Original content",
Version: "1.0.0",
Target: "both",
}
Create(skill)
skill.Description = "Updated"
skill.Content = "Updated content"
skill.Version = "2.0.0"
if err := Update(skill); err != nil {
t.Fatalf("Update failed: %v", err)
}
got, err := Get("update-test")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.Description != "Updated" {
t.Errorf("Expected 'Updated', got %s", got.Description)
}
}
func TestBuiltinSkillCount(t *testing.T) {
if len(builtinSkills) < 5 {
t.Errorf("Expected at least 5 builtin skills, got %d", len(builtinSkills))
}
expectedSkills := []string{"env-setup", "git-workflow", "api-design", "debug-assist", "code-review", "docker-setup", "security-audit", "mcp-setup", "lsp-setup", "workflow-design"}
for _, name := range expectedSkills {
found := false
for _, s := range builtinSkills {
if s.Name == name {
found = true
break
}
}
if !found {
t.Errorf("Expected builtin skill: %s", name)
}
}
}
func TestBuiltinSkillsHaveDependencies(t *testing.T) {
hasDeps := 0
for _, s := range builtinSkills {
if len(s.Dependencies) > 0 {
hasDeps++
}
}
if hasDeps == 0 {
t.Error("At least some builtin skills should declare dependencies")
}
}

View File

@@ -1,115 +0,0 @@
package tui
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
var glitchChars = "!@#$%^&*()_+-=[]{}|;':,./<>?~`"
func init() {
rand.Seed(time.Now().UnixNano())
}
func randomGlitchChar() string {
return string(glitchChars[rand.Intn(len(glitchChars))])
}
func glitchText(text string, intensity int) string {
runes := []rune(text)
for i := 0; i < intensity; i++ {
pos := rand.Intn(len(runes))
if runes[pos] != ' ' && runes[pos] != '\n' {
runes[pos] = []rune(randomGlitchChar())[0]
}
}
return string(runes)
}
func generateScanLine(width int, frame int) string {
pos := frame % width
line := strings.Repeat(" ", pos) +
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(strings.Repeat("━", min(20, width-pos)))
if pos+20 < width {
line += strings.Repeat(" ", width-pos-20)
}
return line[:min(width, len(line))]
}
func typewriterRender(text string, pos int) string {
if pos >= len(text) {
return text
}
if pos <= 0 {
return ""
}
shown := text[:pos]
cursor := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("█")
return shown + cursor
}
func renderGlitchEffect(width, height, frame int) string {
var b strings.Builder
for y := 0; y < height; y++ {
line := ""
for x := 0; x < width; x++ {
if rand.Float64() < 0.15 {
c := randomGlitchChar()
style := lipgloss.NewStyle()
r := rand.Float64()
if r < 0.4 {
style = style.Foreground(cyberRed)
} else if r < 0.7 {
style = style.Foreground(cyberPink)
} else {
style = style.Foreground(textMuted)
}
line += style.Render(c)
} else {
line += " "
}
}
b.WriteString(line)
if y < height-1 {
b.WriteString("\n")
}
}
return b.String()
}
func renderScanEffect(width, height, frame int) string {
var b strings.Builder
scanY := frame % (height + 10)
for y := 0; y < height; y++ {
if y == scanY || y == scanY+1 {
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Render(strings.Repeat("━", width)))
} else if y == scanY-1 || y == scanY+2 {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(strings.Repeat("─", width)))
} else if y < scanY {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf("%*s", width, "")))
} else {
b.WriteString(strings.Repeat(" ", width))
}
if y < height-1 {
b.WriteString("\n")
}
}
return b.String()
}
func generateHexStream(width, lines int) string {
var b strings.Builder
for y := 0; y < lines; y++ {
for x := 0; x < width/3; x++ {
b.WriteString(fmt.Sprintf("%02X", rand.Intn(256)))
}
if y < lines-1 {
b.WriteString("\n")
}
}
return lipgloss.NewStyle().Foreground(dimRed).Render(b.String())
}

View File

@@ -1,108 +0,0 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/workflow"
)
func startInstallCmd(cfg *config.MuyueConfig, tools []string, index int) tea.Cmd {
return tea.Cmd(func() tea.Msg {
inst := installer.New(cfg)
result := inst.InstallTool(tools[index])
if index+1 < len(tools) {
return installBatchMsg{
result: result,
tools: tools,
index: index,
config: cfg,
}
}
return installCompleteMsg{results: []installer.InstallResult{result}}
})
}
func sendAIMessage(orch *orchestrator.Orchestrator, input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
if orch == nil {
return aiErrMsg{err: fmt.Errorf("orchestrator not configured")}
}
resp, err := orch.Send(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func startWorkflowCmd(orch *orchestrator.Orchestrator, goal string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.StartWorkflow(goal)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func workflowChatCmd(orch *orchestrator.Orchestrator, input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
wf := orch.Workflow
switch wf.Phase {
case workflow.PhaseGathering:
resp, err := orch.AnswerQuestion(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
case workflow.PhaseReviewing:
approved, feedback := workflow.ParseApproval(input)
resp, err := orch.ReviewPlan(approved, feedback)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
default:
resp, err := orch.Send(input)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
}
})
}
func generatePlanCmd(orch *orchestrator.Orchestrator) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.GeneratePlan()
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func reviewPlanCmd(orch *orchestrator.Orchestrator, approved bool, feedback string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.ReviewPlan(approved, feedback)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}
func continueWorkflowCmd(orch *orchestrator.Orchestrator, output string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
resp, err := orch.ContinueExecution(output)
if err != nil {
return aiErrMsg{err: err}
}
return aiResponseMsg{content: resp}
})
}

View File

@@ -1,114 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderConfig() string {
colWidth := m.width / 2
if colWidth < 30 {
colWidth = 30
}
var left, right strings.Builder
left.WriteString(renderSectionHeader("PROFILE", "[@]"))
left.WriteString("\n")
if m.config != nil {
fields := []struct {
label string
value string
}{
{"Name", m.config.Profile.Name},
{"Pseudo", m.config.Profile.Pseudo},
{"Email", m.config.Profile.Email},
{"Editor", m.config.Profile.Preferences.Editor},
{"Shell", m.config.Profile.Preferences.Shell},
{"Theme", m.config.Profile.Preferences.Theme},
{"Default AI", m.config.Profile.Preferences.DefaultAI},
}
for _, f := range fields {
left.WriteString(fmt.Sprintf(" %s %s\n",
labelStyle.Render(f.label+":"),
valueStyle.Render(f.value)))
}
if len(m.config.Profile.Languages) > 0 {
left.WriteString(fmt.Sprintf(" %s %s\n",
labelStyle.Render("Languages:"),
valueStyle.Render(strings.Join(m.config.Profile.Languages, ", "))))
}
}
left.WriteString("\n")
left.WriteString(renderSectionHeader("AI PROVIDERS", "[AI]"))
left.WriteString("\n")
if m.config != nil {
for _, p := range m.config.AI.Providers {
active := ""
if p.Active {
active = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" >>")
}
keyStatus := itemMissingStyle.Render("no key")
if p.APIKey != "" {
keyStatus = itemOKStyle.Render("configured")
}
nameStyle := lipgloss.NewStyle().Foreground(textBright).Bold(true)
left.WriteString(fmt.Sprintf(" %s %s key=%s%s\n",
nameStyle.Render(p.Name),
lipgloss.NewStyle().Foreground(dimRed).Render("model="+p.Model),
keyStatus, active))
}
}
left.WriteString("\n")
right.WriteString(renderSectionHeader("TERMINAL", "[$]"))
right.WriteString("\n")
if m.config != nil {
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Custom Prompt:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Terminal.CustomPrompt))))
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Prompt Theme:"), valueStyle.Render(m.config.Terminal.PromptTheme)))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Auto Update:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.AutoUpdate))))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Check on Start:"), valueStyle.Render(fmt.Sprintf("%v", m.config.Profile.Preferences.CheckOnStart))))
}
right.WriteString("\n")
right.WriteString(renderSectionHeader("BMAD METHOD", "[B]"))
right.WriteString("\n")
if m.config != nil {
installed := itemMissingStyle.Render("[--] no")
if m.config.BMAD.Installed {
installed = itemOKStyle.Render("[OK] yes")
}
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Installed:"), installed))
right.WriteString(fmt.Sprintf(" %s %v\n", labelStyle.Render("Global:"), valueStyle.Render(fmt.Sprintf("%v", m.config.BMAD.Global))))
if m.config.BMAD.Version != "" {
right.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Version:"), valueStyle.Render(m.config.BMAD.Version)))
}
}
right.WriteString("\n")
right.WriteString(renderSectionHeader(fmt.Sprintf("SKILLS (%d)", len(m.skillList)), "[!]"))
right.WriteString("\n")
if len(m.skillList) > 0 {
for _, s := range m.skillList {
target := s.Target
if target == "" {
target = "both"
}
right.WriteString(fmt.Sprintf(" %s %s %s\n",
lipgloss.NewStyle().Foreground(textMain).Render(s.Name),
lipgloss.NewStyle().Foreground(cyberRed).Render("["+target+"]"),
lipgloss.NewStyle().Foreground(dimRed).Render(s.Description)))
}
} else {
right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(" No skills. Run `muyue skills init`."))
right.WriteString("\n")
}
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
}

View File

@@ -1,176 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderDashboard() string {
colWidth := m.width / 2
if colWidth < 30 {
colWidth = 30
}
var left, right strings.Builder
left.WriteString(renderSectionHeader("SYSTEM", "[*]"))
left.WriteString("\n")
if m.scanResult != nil {
sysInfo := m.scanResult.System.String()
left.WriteString(" ")
left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(sysInfo))
}
left.WriteString("\n\n")
left.WriteString(renderSectionHeader("INSTALLED TOOLS", "[+]"))
left.WriteString("\n")
if m.scanResult != nil {
installed := 0
total := len(m.scanResult.Tools)
for _, t := range m.scanResult.Tools {
if t.Installed {
installed++
left.WriteString(" ")
left.WriteString(itemOKStyle.Render("[OK] "))
left.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(t.Name))
left.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s", extractVersion(t.Version))))
left.WriteString("\n")
} else {
left.WriteString(" ")
left.WriteString(itemMissingStyle.Render("[--] "))
left.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(t.Name))
left.WriteString(itemPendingStyle.Render(" (missing)"))
left.WriteString("\n")
}
}
barWidth := 20
pct := 0
if total > 0 {
pct = (installed * barWidth) / total
}
bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(strings.Repeat("█", pct)) +
lipgloss.NewStyle().Foreground(dimRed).Render(strings.Repeat("░", barWidth-pct))
left.WriteString(fmt.Sprintf("\n %s %d/%d\n", bar, installed, total))
}
left.WriteString("\n")
if m.installing {
left.WriteString(renderSectionHeader("INSTALLING", "[~]"))
left.WriteString("\n")
progBar := m.progressBar.View()
label := ""
if m.installTool != "" {
label = fmt.Sprintf(" %d/%d - %s", m.installCurrent+1, m.installTotal, m.installTool)
} else {
label = fmt.Sprintf(" %d/%d", m.installCurrent, m.installTotal)
}
left.WriteString(fmt.Sprintf(" %s%s\n", progBar, label))
left.WriteString("\n")
}
if len(m.installLog) > 0 {
left.WriteString(renderSectionHeader("INSTALL LOG", "[#]"))
left.WriteString("\n")
for _, l := range m.installLog {
left.WriteString(l + "\n")
}
left.WriteString("\n")
}
right.WriteString(renderSectionHeader("QUICK ACTIONS", "[!]"))
right.WriteString("\n")
actions := []struct {
key string
desc string
color lipgloss.Color
}{
{"i", "Install missing tools", cyberRed},
{"u", "Check for updates", neonRed},
{"s", "Rescan system", cyberPink},
{"l", "Scan LSP servers", cyberRose},
{"m", "Configure MCP servers", brightRed},
}
for _, a := range actions {
right.WriteString(fmt.Sprintf(" %s %s\n",
lipgloss.NewStyle().Foreground(a.color).Bold(true).Render("["+a.key+"]"),
lipgloss.NewStyle().Foreground(textMain).Render(a.desc)))
}
right.WriteString("\n")
right.WriteString(renderSectionHeader("ACTIVE AGENTS", "[*]"))
right.WriteString("\n")
agents := []struct {
name string
}{
{"Crush"},
{"Claude Code"},
}
for _, a := range agents {
right.WriteString(" ")
right.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(">> "))
right.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render(a.name + " "))
right.WriteString(itemPendingStyle.Render("[stopped]"))
right.WriteString("\n")
}
right.WriteString("\n")
if len(m.updateStatus) > 0 {
right.WriteString(renderSectionHeader("UPDATES", "[^]"))
right.WriteString("\n")
for _, s := range m.updateStatus {
if s.NeedsUpdate {
right.WriteString(" ")
right.WriteString(itemWarnStyle.Render("[!!] "))
right.WriteString(fmt.Sprintf("%s: %s -> %s\n", s.Tool, s.Current, s.Latest))
} else if s.Error == "" {
right.WriteString(" ")
right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%s: up to date\n", s.Tool))
}
}
right.WriteString("\n")
}
if len(m.lspServers) > 0 {
right.WriteString(renderSectionHeader("LSP SERVERS", "[L]"))
right.WriteString("\n")
lspInstalled := 0
for _, s := range m.lspServers {
if s.Installed {
lspInstalled++
right.WriteString(" ")
right.WriteString(itemOKStyle.Render("[OK] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
} else {
right.WriteString(" ")
right.WriteString(itemPendingStyle.Render("[ ] "))
right.WriteString(fmt.Sprintf("%-22s (%s)\n", s.Name, s.Language))
}
}
right.WriteString(fmt.Sprintf("\n %d/%d available\n", lspInstalled, len(m.lspServers)))
right.WriteString("\n")
}
mcpStatus := itemPendingStyle.Render("[ ] not configured")
if m.mcpConfigured {
mcpStatus = itemOKStyle.Render("[OK] configured")
}
right.WriteString(fmt.Sprintf(" MCP: %s\n", mcpStatus))
if m.daemon != nil {
daemonStatus := itemPendingStyle.Render("[ ] stopped")
if m.daemon.IsRunning() {
daemonStatus = itemOKStyle.Render("[OK] running")
}
right.WriteString(fmt.Sprintf(" Daemon: %s\n", daemonStatus))
}
leftCol := lipgloss.NewStyle().Width(colWidth).Render(left.String())
rightCol := lipgloss.NewStyle().Width(colWidth).Render(right.String())
return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
}

View File

@@ -1,74 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/version"
)
func (m Model) renderFooter() string {
profile := "unknown"
if m.config != nil && m.config.Profile.Pseudo != "" {
profile = m.config.Profile.Pseudo
}
left := fmt.Sprintf(" %s@%s",
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(profile),
lipgloss.NewStyle().Foreground(dimRed).Render(version.Name))
leftR := statusBarStyle.Render(left)
var helpText string
switch m.activeTab {
case tabDashboard:
helpText = "[i] install [u] update [s] scan [ctrl+t] tabs"
case tabStudio:
helpText = "[enter] send [ctrl+s] sidebar [ctrl+t] tabs"
case tabShell:
helpText = "[enter] run [ctrl+a] AI panel [ctrl+c] kill"
case tabConfig:
helpText = "[up/down] sections [ctrl+t] tabs"
default:
helpText = "[ctrl+t] tabs [ctrl+c] quit"
}
rightR := statusBarStyle.Render(helpText)
updateIndicator := ""
if len(m.updateStatus) > 0 {
needsUpdate := false
for _, s := range m.updateStatus {
if s.NeedsUpdate {
needsUpdate = true
break
}
}
if needsUpdate {
updateIndicator = lipgloss.NewStyle().Foreground(warnAmber).Render(" [UPD] ")
} else {
updateIndicator = lipgloss.NewStyle().Foreground(successGreen).Render(" [OK] ")
}
}
verStr := lipgloss.NewStyle().Foreground(dimRed).Render("v" + version.Version)
midContent := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
updateIndicator + verStr,
)
gap := m.width - lipgloss.Width(leftR) - lipgloss.Width(rightR) - lipgloss.Width(midContent)
if gap < 0 {
gap = 0
}
statusLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
leftR,
strings.Repeat(" ", gap),
midContent,
rightR,
)
helpLine := lipgloss.NewStyle().Background(bgSurface).Foreground(textMuted).Render(
lipgloss.NewStyle().Padding(0, 1).Render(m.helpModel.View(keys)))
return lipgloss.JoinVertical(lipgloss.Left, statusLine, helpLine)
}

View File

@@ -1,360 +0,0 @@
package tui
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.showingQuit {
return m.handleQuitConfirm(msg)
}
if m.showingTabMenu {
return m.handleTabMenu(msg)
}
if m.activeTab == tabShell {
return m.handleShellKey(msg)
}
switch msg.String() {
case "ctrl+c":
now := time.Now()
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
return m, tea.Quit
}
m.ctrlCCount++
m.lastCtrlC = now
m.showingQuit = true
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+t":
m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab)
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+s":
if m.activeTab == tabStudio {
m.studioSidebarOpen = !m.studioSidebarOpen
m.viewport.SetContent(m.renderContent())
}
case "enter":
if m.activeTab == tabStudio && m.chatInput != "" && !m.chatLoading {
return m.handleChatSubmit()
}
case "backspace":
if m.activeTab == tabStudio && len(m.chatInput) > 0 {
m.chatInput = m.chatInput[:len(m.chatInput)-1]
m.viewport.SetContent(m.renderContent())
}
default:
if m.activeTab == tabStudio && len(msg.String()) == 1 && !m.chatLoading {
m.chatInput += msg.String()
m.viewport.SetContent(m.renderContent())
}
}
if m.activeTab == tabDashboard {
return m.handleDashboardKey(msg)
}
if m.activeTab == tabStudio {
return m.handleStudioKey(msg)
}
return m, nil
}
func cleanup(m Model) {
if m.daemon != nil {
m.daemon.Stop()
}
if m.previewSrv != nil {
m.previewSrv.Stop()
}
for _, agentType := range []proxy.AgentType{proxy.AgentCrush, proxy.AgentClaude} {
m.proxyMgr.Stop(agentType)
}
}
func (m Model) handleQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y", "Y", "o", "O":
m.showingQuit = false
cleanup(m)
return m, tea.Quit
case "n", "N", "esc":
m.showingQuit = false
m.ctrlCCount = 0
m.viewport.SetContent(m.renderContent())
return m, nil
case "left", "h":
m.confirmCursor = 0
m.viewport.SetContent(m.renderContent())
return m, nil
case "right", "l":
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "enter":
if m.confirmCursor == 0 {
m.showingQuit = false
cleanup(m)
return m, tea.Quit
}
m.showingQuit = false
m.ctrlCCount = 0
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+c":
m.showingQuit = false
cleanup(m)
return m, tea.Quit
}
return m, nil
}
func (m Model) handleTabMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
m.showingTabMenu = false
m.viewport.SetContent(m.renderContent())
return m, nil
case "up", "k":
if m.tabMenuCursor > 0 {
m.tabMenuCursor--
}
return m, nil
case "down", "j":
if m.tabMenuCursor < int(tabCount)-1 {
m.tabMenuCursor++
}
return m, nil
case "enter":
m.switchTab(tab(m.tabMenuCursor))
m.showingTabMenu = false
return m, nil
default:
for i := 0; i < int(tabCount); i++ {
if msg.String() == fmt.Sprintf("%d", i+1) {
m.switchTab(tab(i))
m.showingTabMenu = false
return m, nil
}
}
}
return m, nil
}
func (m *Model) switchTab(t tab) {
if t == m.activeTab {
return
}
m.prevTab = m.activeTab
m.activeTab = t
m.transition = transitionGlitch
m.transitionTick = 0
m.resizeViewport()
}
func (m Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "i":
if m.installing {
return m, nil
}
var missing []string
if m.scanResult != nil {
for _, t := range m.scanResult.Tools {
if !t.Installed {
missing = append(missing, t.Name)
}
}
}
if len(missing) == 0 {
m.installLog = append(m.installLog, itemOKStyle.Render("[OK] All tools already installed!"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
needsSudo := checkNeedsSudo(m.scanResult)
if needsSudo && !hasSudo() {
m.installLog = append(m.installLog, errMsgStyle.Render("[!!] Some tools require sudo. Run: sudo muyue install"))
m.viewport.SetContent(m.renderContent())
return m, nil
}
m.installing = true
m.installCurrent = 0
m.installTotal = len(missing)
m.installTool = missing[0]
m.progressBar.SetPercent(0)
m.viewport.SetContent(m.renderContent())
return m, startInstallCmd(m.config, missing, 0)
case "u":
return m, tea.Cmd(func() tea.Msg {
result := scanner.ScanSystem()
return updateCheckMsg{statuses: updater.CheckUpdates(result)}
})
case "s":
return m, tea.Cmd(func() tea.Msg {
return scanCompleteMsg{result: scanner.ScanSystem()}
})
case "l":
return m, tea.Cmd(func() tea.Msg {
servers := lsp.ScanServers()
return lspScanMsg{servers: servers}
})
case "m":
return m, tea.Cmd(func() tea.Msg {
err := mcp.ConfigureAll(m.config)
return mcpConfigMsg{err: err}
})
}
return m, nil
}
func (m Model) handleStudioKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if !m.studioSidebarOpen {
return m, nil
}
switch msg.String() {
case "1":
m.studioPanel = panelChat
m.viewport.SetContent(m.renderContent())
case "2":
m.studioPanel = panelAgents
m.viewport.SetContent(m.renderContent())
case "3":
m.studioPanel = panelWorkflows
m.viewport.SetContent(m.renderContent())
}
if m.studioPanel == panelAgents {
return m.handleAgentsKey(msg)
}
if m.studioPanel == panelWorkflows {
return m.handleWorkflowKey(msg)
}
return m, nil
}
func (m Model) handleAgentsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "c":
if m.proxyMgr.IsAvailable(proxy.AgentCrush) {
m.proxyMgr.Start(proxy.AgentCrush)
}
m.viewport.SetContent(m.renderContent())
case "l":
if m.proxyMgr.IsAvailable(proxy.AgentClaude) {
m.proxyMgr.Start(proxy.AgentClaude)
}
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func (m Model) handleWorkflowKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.orch == nil || m.orch.Workflow == nil {
return m, nil
}
wf := m.orch.Workflow
switch msg.String() {
case "a":
if wf.Phase == workflow.PhaseReviewing {
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Plan approved]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, reviewPlanCmd(m.orch, true, "")
}
case "r":
if wf.Phase == workflow.PhaseReviewing {
m.chatInput = ""
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Type your rejection feedback below:"))
m.viewport.SetContent(m.renderContent())
}
case "g":
if wf.Phase == workflow.PhaseGathering && len(wf.Plan.Questions) > 0 && len(wf.Plan.Answers) >= len(wf.Plan.Questions) {
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> [Generate plan]"))
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, generatePlanCmd(m.orch)
}
case "n":
if wf.Phase == workflow.PhaseExecuting {
current := wf.CurrentStep()
if current != nil {
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
return m, continueWorkflowCmd(m.orch, "proceeding")
}
}
case "x":
wf.Reset()
m.chatLog = append(m.chatLog, itemWarnStyle.Render("Workflow reset."))
m.viewport.SetContent(m.renderContent())
}
return m, nil
}
func checkNeedsSudo(scan *scanner.ScanResult) bool {
if scan == nil {
return false
}
sudoTools := map[string]bool{
"docker": true, "git": true, "gh": true, "node": true, "python3": true,
}
for _, t := range scan.Tools {
if !t.Installed && sudoTools[t.Name] {
return true
}
}
return false
}
func hasSudo() bool {
if os.Geteuid() == 0 {
return true
}
if _, err := exec.LookPath("sudo"); err == nil {
return true
}
if _, err := exec.LookPath("pkexec"); err == nil {
return true
}
return false
}
func (m Model) handleChatSubmit() (tea.Model, tea.Cmd) {
input := m.chatInput
m.chatLog = append(m.chatLog, userMsgStyle.Render(">> "+input))
m.chatInput = ""
m.chatLoading = true
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
if strings.HasPrefix(input, "/plan ") {
goal := strings.TrimPrefix(input, "/plan ")
return m, startWorkflowCmd(m.orch, goal)
}
if m.orch != nil && m.orch.Workflow != nil && m.orch.Workflow.Phase != workflow.PhaseIdle {
return m, workflowChatCmd(m.orch, input)
}
return m, sendAIMessage(m.orch, input)
}

View File

@@ -1,178 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/version"
)
func (m Model) renderHeader() string {
var tabs []string
for i, name := range tabNames {
icon := tabIcons[i]
if tab(i) == m.activeTab {
tabStyle := lipgloss.NewStyle().
Background(cyberRed).
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
} else {
tabStyle := lipgloss.NewStyle().
Background(bgSurface).
Foreground(textDim).
Padding(0, 2)
tabs = append(tabs, tabStyle.Render(icon+" "+name))
}
}
tabLine := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...))
timeStr := ""
if !m.currentTime.IsZero() {
timeStr = m.currentTime.Format("15:04:05")
}
dateStr := ""
if !m.currentTime.IsZero() {
dateStr = m.currentTime.Format("02/01/2006")
}
rightInfo := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center,
lipgloss.NewStyle().Foreground(textDim).Render(dateStr+" "),
lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(timeStr),
lipgloss.NewStyle().Foreground(textMuted).Render(" "+getAnimFrame(m.animationFrame)),
),
)
statusDots := ""
if m.config != nil {
hasAI := false
for _, p := range m.config.AI.Providers {
if p.Active && p.APIKey != "" {
hasAI = true
break
}
}
if hasAI {
statusDots += lipgloss.NewStyle().Foreground(successGreen).Render("●")
} else {
statusDots += lipgloss.NewStyle().Foreground(errorRed).Render("●")
}
} else {
statusDots += lipgloss.NewStyle().Foreground(warnAmber).Render("●")
}
statusDots += lipgloss.NewStyle().Foreground(textMuted).Render(" ")
if m.mcpConfigured {
statusDots += lipgloss.NewStyle().Foreground(successGreen).Render("●")
} else {
statusDots += lipgloss.NewStyle().Foreground(warnAmber).Render("●")
}
statusInfo := lipgloss.NewStyle().Background(bgSurface).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center,
lipgloss.NewStyle().Foreground(textDim).Render("SYS "),
statusDots,
),
)
badge := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("MUYUE")
versionBadge := lipgloss.NewStyle().Foreground(dimRed).Render("v" + version.Version)
logoLine := lipgloss.NewStyle().Background(bgVoid).Padding(0, 1).Render(
lipgloss.JoinHorizontal(lipgloss.Center, badge, " ", versionBadge),
)
topLine := lipgloss.JoinHorizontal(lipgloss.Bottom,
logoLine,
strings.Repeat(" ", max(0, m.width-lipgloss.Width(logoLine)-lipgloss.Width(rightInfo)-lipgloss.Width(statusInfo))),
statusInfo,
rightInfo,
)
return lipgloss.JoinVertical(lipgloss.Left, topLine, tabLine)
}
func (m Model) renderTabMenuOverlay() string {
menuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Background(bgCard).
Padding(1, 3)
tabItemStyle := lipgloss.NewStyle().
Foreground(textDim).
Padding(0, 2)
tabItemActiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(cyberRed).
Bold(true).
Padding(0, 2)
descs := []string{
"tools, updates & system status",
"chat, agents & workflows",
"terminal + AI assistant",
"profile, API keys & settings",
}
var items []string
for i, name := range tabNames {
num := lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %d.", i+1))
icon := tabIcons[i] + " "
if i == m.tabMenuCursor {
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(cyberRose).Render(descs[i]))
items = append(items, tabItemActiveStyle.Render(">"+item))
} else {
item := fmt.Sprintf("%s %s%-10s %s", num, icon, name, lipgloss.NewStyle().Foreground(textMuted).Render(descs[i]))
items = append(items, tabItemStyle.Render(" "+item))
}
}
header := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("SWITCH TAB")
content := header + "\n\n" +
strings.Join(items, "\n") +
"\n\n" +
lipgloss.NewStyle().Foreground(textMuted).Render("up/down navigate | enter select | esc cancel")
box := menuStyle.Render(content)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
box,
lipgloss.WithWhitespaceBackground(bgVoid),
lipgloss.WithWhitespaceForeground(textMuted),
)
}
func (m Model) renderQuitOverlay() string {
yesStyle := confirmNoStyle
noStyle := confirmYesStyle
if m.confirmCursor == 0 {
yesStyle = confirmYesStyle
noStyle = confirmNoStyle
}
frame := lipgloss.NewStyle().Foreground(cyberRed).Render(getAnimFrame(m.animationFrame))
box := fmt.Sprintf("\n\n %s Quit muyue?\n\n %s %s",
frame,
yesStyle.Render("[ Yes ]"),
noStyle.Render("[ No ]"),
)
content := confirmBoxStyle.Render(box)
return lipgloss.Place(m.width, m.height,
0.5, 0.5,
content,
lipgloss.WithWhitespaceBackground(bgVoid),
lipgloss.WithWhitespaceForeground(textMuted),
)
}

View File

@@ -1,19 +0,0 @@
package tui
import (
"regexp"
"github.com/muyue/muyue/internal/workflow"
)
var versionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
func extractVersion(s string) string {
return versionRegex.FindString(s)
}
type previewFile = workflow.PreviewFile
func parsePreviewFiles(response string) []previewFile {
return workflow.ParsePreviewFiles(response)
}

View File

@@ -1,395 +0,0 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/daemon"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/preview"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
)
func NewModel(cfg *config.MuyueConfig, scan *scanner.ScanResult) Model {
orch, _ := orchestrator.New(cfg)
proxyMgr := proxy.NewManager()
d := daemon.NewDaemon(cfg, 1*time.Hour)
lspServers := lsp.ScanServers()
skillList, _ := skills.List()
mcpConfigured := false
if err := mcp.ConfigureAll(cfg); err == nil {
mcpConfigured = true
}
if cfg.Profile.Preferences.AutoUpdate {
d.Start()
}
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(cyberRed)
prog := progress.New(progress.WithGradient("#FF0033", "#FF1A5E"))
cwd, _ := os.Getwd()
return Model{
config: cfg,
scanResult: scan,
activeTab: tabDashboard,
chatLog: []string{
aiMsgStyle.Render(" >> Welcome to Studio! Chat with your AI assistant here."),
aiMsgStyle.Render(" >> Configure agents and workflows from the sidebar. Type /plan <goal> to start."),
},
orch: orch,
proxyMgr: proxyMgr,
chatInput: "",
chatLoading: false,
daemon: d,
lspServers: lspServers,
mcpConfigured: mcpConfigured,
skillList: skillList,
helpModel: help.New(),
progressBar: prog,
spinner: sp,
showingQuit: false,
confirmCursor: 1,
showingTabMenu: false,
tabMenuCursor: 0,
termCwd: cwd,
studioPanel: panelChat,
studioSidebarOpen: true,
termAIChat: []string{
aiMsgStyle.Render(" >> I know your system inside out. Ask me anything."),
},
termAIShow: true,
configSection: configProfile,
configField: 0,
animationFrame: 0,
currentTime: time.Now(),
transition: transitionNone,
}
}
func animTick() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return animTickMsg{time: t}
})
}
func clockTick() tea.Cmd {
return tea.Tick(1*time.Second, func(t time.Time) tea.Msg {
return clockTickMsg{time: t}
})
}
func (m Model) Init() tea.Cmd {
return tea.Batch(spinner.Tick, animTick(), clockTick(), tea.EnterAltScreen)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case animTickMsg:
m.animationFrame++
if m.transition == transitionGlitch {
m.transitionTick++
if m.transitionTick > 5 {
m.transition = transitionScan
m.transitionTick = 0
}
} else if m.transition == transitionScan {
m.transitionTick++
if m.transitionTick > 8 {
m.transition = transitionTypewriter
m.transitionTick = 0
m.typewriterBuf = m.renderContent()
m.typewriterPos = 0
}
} else if m.transition == transitionTypewriter {
m.typewriterPos += 3
if m.typewriterPos >= len(m.typewriterBuf) {
m.transition = transitionNone
}
}
return m, animTick()
case clockTickMsg:
m.currentTime = msg.time
return m, clockTick()
case progress.FrameMsg:
pm, cmd := m.progressBar.Update(msg)
m.progressBar = pm.(progress.Model)
return m, cmd
case termOutputMsg:
m.termLog = append(m.termLog, msg.line)
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
return m, nil
case termExitMsg:
m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render("(process exited)"))
m.termCmd = nil
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
}
return m, nil
case aiResponseMsg:
m.chatLoading = false
m.termAILoading = false
content := msg.content
if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, aiMsgStyle.Render(" "+content))
if m.activeTab == tabShell {
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
} else {
m.chatLog = append(m.chatLog, aiMsgStyle.Render(" "+content))
if m.orch != nil && m.orch.Workflow != nil {
previewFiles := parsePreviewFiles(content)
if len(previewFiles) > 0 {
m.handlePreview(previewFiles)
}
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
}
return m, nil
case aiErrMsg:
m.chatLoading = false
m.termAILoading = false
errText := errMsgStyle.Render(" [ERROR] " + msg.err.Error())
if m.activeTab == tabShell && m.termAIShow {
m.termAIChat = append(m.termAIChat, errText)
} else {
m.chatLog = append(m.chatLog, errText)
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
case scanCompleteMsg:
m.scanResult = msg.result
m.viewport.SetContent(m.renderContent())
return m, nil
case installCompleteMsg:
m.installing = false
for _, r := range msg.results {
status := itemOKStyle.Render("[OK]")
if !r.Success {
status = itemMissingStyle.Render("[--]")
}
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, r.Tool, r.Message))
}
m.scanResult = scanner.ScanSystem()
m.progressBar.SetPercent(1)
m.viewport.SetContent(m.renderContent())
return m, nil
case installProgressMsg:
status := itemOKStyle.Render("[OK]")
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s installed", status, msg.tool))
m.installCurrent = msg.current
m.installTool = ""
pct := float64(msg.current) / float64(max(msg.total, 1))
m.progressBar.SetPercent(pct)
m.viewport.SetContent(m.renderContent())
return m, nil
case installBatchMsg:
status := itemOKStyle.Render("[OK]")
if !msg.result.Success {
status = itemMissingStyle.Render("[--]")
}
m.installLog = append(m.installLog, fmt.Sprintf(" %s %s: %s", status, msg.result.Tool, msg.result.Message))
m.installCurrent = msg.index + 1
m.installTotal = len(msg.tools)
pct := float64(m.installCurrent) / float64(max(m.installTotal, 1))
m.progressBar.SetPercent(pct)
if msg.index+1 < len(msg.tools) {
m.installTool = msg.tools[msg.index+1]
m.viewport.SetContent(m.renderContent())
return m, startInstallCmd(msg.config, msg.tools, msg.index+1)
}
m.installing = false
m.scanResult = scanner.ScanSystem()
m.viewport.SetContent(m.renderContent())
return m, nil
case updateCheckMsg:
m.updateStatus = msg.statuses
m.viewport.SetContent(m.renderContent())
return m, nil
case previewReadyMsg:
m.previewURL = msg.url
m.viewport.SetContent(m.renderContent())
return m, nil
case lspScanMsg:
m.lspServers = msg.servers
m.viewport.SetContent(m.renderContent())
return m, nil
case mcpConfigMsg:
if msg.err == nil {
m.mcpConfigured = true
}
m.viewport.SetContent(m.renderContent())
return m, nil
case daemonLogMsg:
m.viewport.SetContent(m.renderContent())
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.helpModel.Width = msg.Width
headerH := 2
footerH := 2
inputH := 0
if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2
}
contentH := msg.Height - headerH - footerH - inputH
if contentH < 1 {
contentH = 1
}
m.viewport = viewport.New(msg.Width, contentH)
m.viewport.Width = msg.Width
m.viewport.Height = contentH
m.progressBar.Width = msg.Width - 20
m.ready = true
m.viewport.SetContent(m.renderContent())
return m, nil
}
return m, nil
}
func (m Model) View() string {
if !m.ready {
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render("Initializing muyue...")
}
if m.showingQuit {
return m.renderQuitOverlay()
}
if m.showingTabMenu {
return m.renderTabMenuOverlay()
}
if m.transition == transitionGlitch {
return renderGlitchEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionScan {
return renderScanEffect(m.width, m.height, m.transitionTick)
}
if m.transition == transitionTypewriter {
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(typewriterRender(m.typewriterBuf, m.typewriterPos))
if m.activeTab == tabStudio {
b.WriteString("\n")
b.WriteString(m.renderStudioInput())
}
if m.activeTab == tabShell {
b.WriteString("\n")
b.WriteString(m.renderShellInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n")
b.WriteString(m.viewport.View())
if m.activeTab == tabStudio {
b.WriteString("\n")
b.WriteString(m.renderStudioInput())
}
if m.activeTab == tabShell {
b.WriteString("\n")
b.WriteString(m.renderShellInput())
}
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
func (m Model) renderContent() string {
switch m.activeTab {
case tabDashboard:
return m.renderDashboard()
case tabStudio:
return m.renderStudio()
case tabShell:
return m.renderShell()
case tabConfig:
return m.renderConfig()
default:
return ""
}
}
func (m *Model) resizeViewport() {
headerH := 2
footerH := 2
inputH := 0
if m.activeTab == tabStudio || m.activeTab == tabShell {
inputH = 2
}
contentH := m.height - headerH - footerH - inputH
if contentH < 1 {
contentH = 1
}
m.viewport = viewport.New(m.width, contentH)
m.viewport.Width = m.width
m.viewport.Height = contentH
m.viewport.SetContent(m.renderContent())
}
func (m *Model) handlePreview(files []previewFile) {
dir := filepath.Join(os.TempDir(), "muyue-preview")
os.RemoveAll(dir)
os.MkdirAll(dir, 0755)
for _, f := range files {
preview.CreatePreviewFile(dir, f.Filename, f.Content)
}
if m.previewSrv != nil {
m.previewSrv.Stop()
}
m.previewSrv = preview.NewPreviewServer(dir)
if err := m.previewSrv.Start(8765); err != nil {
m.chatLog = append(m.chatLog, errMsgStyle.Render(" preview error: "+err.Error()))
} else {
m.previewURL = "http://127.0.0.1:8765"
m.chatLog = append(m.chatLog, itemOKStyle.Render(" Preview: http://127.0.0.1:8765"))
}
}

View File

@@ -1,253 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/workflow"
)
func (m Model) renderStudio() string {
if m.studioSidebarOpen {
sidebarWidth := 28
chatWidth := m.width - sidebarWidth - 2
if chatWidth < 20 {
chatWidth = 20
sidebarWidth = m.width - chatWidth - 2
}
sidebar := m.renderStudioSidebar(sidebarWidth)
chat := m.renderStudioChat(chatWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, chat)
}
return m.renderStudioChat(m.width)
}
func (m Model) renderStudioSidebar(width int) string {
var b strings.Builder
b.WriteString(renderSectionHeader("STUDIO", "[<>]"))
b.WriteString("\n\n")
panels := []struct {
name string
panel studioPanel
icon string
}{
{"Chat", panelChat, "[#]"},
{"Agents", panelAgents, "[*]"},
{"Workflows", panelWorkflows, "[~]"},
}
for _, p := range panels {
if m.studioPanel == p.panel {
activeStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(cyberRed).
Bold(true).
Padding(0, 1)
b.WriteString(activeStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
} else {
inactiveStyle := lipgloss.NewStyle().
Foreground(textDim).
Padding(0, 1)
b.WriteString(inactiveStyle.Render(p.icon + " " + p.name))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", width-4)))
b.WriteString("\n\n")
switch m.studioPanel {
case panelAgents:
m.renderAgentsSidebar(&b, width)
case panelWorkflows:
m.renderWorkflowSidebar(&b, width)
default:
m.renderChatSidebar(&b, width)
}
return sidebarStyle.Width(width).Render(b.String())
}
func (m Model) renderChatSidebar(b *strings.Builder, width int) {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Active Provider"))
b.WriteString("\n")
provider := "none"
if m.config != nil {
provider = m.config.Profile.Preferences.DefaultAI
}
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" " + provider))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Commands"))
b.WriteString("\n")
cmds := []string{"/plan <goal>", "/help"}
for _, c := range cmds {
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(" " + c))
b.WriteString("\n")
}
if m.previewURL != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Preview"))
b.WriteString("\n")
b.WriteString(itemOKStyle.Render(" " + m.previewURL))
b.WriteString("\n")
}
}
func (m Model) renderAgentsSidebar(b *strings.Builder, width int) {
agents := []struct {
name string
agentType proxy.AgentType
tool string
}{
{"Crush", proxy.AgentCrush, "GLM"},
{"Claude Code", proxy.AgentClaude, "Anthropic"},
}
for _, a := range agents {
status, _ := m.proxyMgr.Status(a.agentType)
available := m.proxyMgr.IsAvailable(a.agentType)
var statusIcon string
switch status {
case proxy.StatusRunning:
statusIcon = lipgloss.NewStyle().Foreground(neonRed).Render("[>> running]")
case proxy.StatusStopped:
statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[|| stopped]")
case proxy.StatusError:
statusIcon = lipgloss.NewStyle().Foreground(errorRed).Render("[!! error]")
default:
if available {
statusIcon = lipgloss.NewStyle().Foreground(successGreen).Render("[OK available]")
} else {
statusIcon = lipgloss.NewStyle().Foreground(textMuted).Render("[-- not installed]")
}
}
b.WriteString(lipgloss.NewStyle().Foreground(textBright).Bold(true).Render(a.name))
b.WriteString("\n")
b.WriteString(fmt.Sprintf(" %s\n", statusIcon))
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render(fmt.Sprintf(" %s\n", a.tool)))
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Actions"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [c]"))
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Crush"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" [l]"))
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(" Start Claude"))
b.WriteString("\n")
}
func (m Model) renderWorkflowSidebar(b *strings.Builder, width int) {
if m.orch == nil || m.orch.Workflow == nil {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("No active workflow."))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("Use /plan <goal> in chat"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(dimRed).Render("to start a workflow."))
b.WriteString("\n")
return
}
wf := m.orch.Workflow
phaseColors := map[workflow.Phase]lipgloss.Style{
workflow.PhaseIdle: lipgloss.NewStyle().Foreground(textMuted),
workflow.PhaseGathering: lipgloss.NewStyle().Foreground(warnAmber).Bold(true),
workflow.PhasePlanning: lipgloss.NewStyle().Foreground(cyberPink).Bold(true),
workflow.PhaseReviewing: lipgloss.NewStyle().Foreground(cyberRose).Bold(true),
workflow.PhaseExecuting: lipgloss.NewStyle().Foreground(cyberRed).Bold(true),
workflow.PhaseDone: lipgloss.NewStyle().Foreground(successGreen).Bold(true),
workflow.PhaseError: lipgloss.NewStyle().Foreground(errorRed).Bold(true),
}
if style, ok := phaseColors[wf.Phase]; ok {
b.WriteString(style.Render(string(wf.Phase)))
}
b.WriteString("\n\n")
if wf.Plan.Goal != "" {
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Goal"))
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(textMain).Render(wf.Plan.Goal))
b.WriteString("\n\n")
}
if wf.Phase == workflow.PhaseExecuting {
done, total := wf.Progress()
m.progressBar.SetPercent(float64(done) / float64(max(total, 1)))
b.WriteString(m.progressBar.View())
b.WriteString(fmt.Sprintf(" %d/%d", done, total))
b.WriteString("\n\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(textMuted).Render("Controls"))
b.WriteString("\n")
controls := []struct {
key string
desc string
}{
{"[a]", "Approve plan"},
{"[r]", "Reject plan"},
{"[g]", "Generate plan"},
{"[n]", "Next step"},
{"[x]", "Cancel"},
}
for _, c := range controls {
b.WriteString(lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(" "+c.key))
b.WriteString(lipgloss.NewStyle().Foreground(textDim).Render(" "+c.desc))
b.WriteString("\n")
}
}
func (m Model) renderStudioChat(width int) string {
var b strings.Builder
chatHeader := renderSectionHeader("CHAT", "[#]")
if m.chatLoading {
chatHeader += " " + m.spinner.View() + lipgloss.NewStyle().Foreground(warnAmber).Render("thinking...")
}
b.WriteString(chatHeader)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.chatLog {
b.WriteString(msg)
b.WriteString("\n\n")
}
return b.String()
}
func (m Model) renderStudioInput() string {
if m.chatLoading {
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.spinner.View() + lipgloss.NewStyle().Foreground(textMuted).Render(" thinking..."),
)
}
cursor := lipgloss.NewStyle().Foreground(cyberRed).Render("▎")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
inputStyle.Render(">> ") + m.chatInput + cursor,
)
}
func (m Model) handleStudioPanelSwitch(panel studioPanel) {
m.studioPanel = panel
m.viewport.SetContent(m.renderContent())
}

View File

@@ -1,196 +0,0 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
)
var (
cyberRed = lipgloss.Color("#FF0033")
cyberRedDark = lipgloss.Color("#8B0020")
cyberRedDeep = lipgloss.Color("#5C0015")
cyberPink = lipgloss.Color("#FF1A5E")
cyberRose = lipgloss.Color("#FF4D6D")
neonRed = lipgloss.Color("#FF1744")
brightRed = lipgloss.Color("#FF5252")
dimRed = lipgloss.Color("#6B2033")
mutedRed = lipgloss.Color("#4A1525")
textBright = lipgloss.Color("#EAE0E2")
textMain = lipgloss.Color("#D4C4C8")
textDim = lipgloss.Color("#8A7A7E")
textMuted = lipgloss.Color("#5A4F52")
successGreen = lipgloss.Color("#00E676")
warnAmber = lipgloss.Color("#FFD740")
errorRed = lipgloss.Color("#FF1744")
bgVoid = lipgloss.Color("#0A0A0C")
bgBase = lipgloss.Color("#0F0D10")
bgSurface = lipgloss.Color("#161218")
bgPanel = lipgloss.Color("#1C1719")
bgCard = lipgloss.Color("#221B1E")
bgInput = lipgloss.Color("#2A2225")
borderDim = lipgloss.Color("#2A1F22")
borderRed = lipgloss.Color("#FF003344")
borderRedFull = lipgloss.Color("#FF0033")
)
var (
baseStyle = lipgloss.NewStyle()
titleBlockStyle = lipgloss.NewStyle().
Foreground(cyberRed).
Bold(true)
sectionTitleStyle = lipgloss.NewStyle().
Foreground(cyberRed).
Bold(true)
labelStyle = lipgloss.NewStyle().
Foreground(textDim).
Width(14)
valueStyle = lipgloss.NewStyle().
Foreground(textMain)
cardStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(borderDim).
Padding(0, 1)
cardActiveStyle = lipgloss.NewStyle().
Background(bgCard).
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Padding(0, 1)
sidebarStyle = lipgloss.NewStyle().
Background(bgSurface).
Border(lipgloss.Border{Right: "│"}).
BorderForeground(borderDim).
Padding(0, 1)
statusBarStyle = lipgloss.NewStyle().
Background(bgSurface).
Foreground(textDim).
Padding(0, 1)
inputStyle = lipgloss.NewStyle().
Foreground(cyberRed)
userMsgStyle = lipgloss.NewStyle().
Foreground(cyberRose)
aiMsgStyle = lipgloss.NewStyle().
Foreground(textMain)
errMsgStyle = lipgloss.NewStyle().
Foreground(errorRed)
itemOKStyle = lipgloss.NewStyle().Foreground(successGreen)
itemMissingStyle = lipgloss.NewStyle().Foreground(errorRed)
itemWarnStyle = lipgloss.NewStyle().Foreground(warnAmber)
itemPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
confirmBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cyberRed).
Background(bgCard).
Foreground(textBright).
Padding(1, 3).
Bold(true)
confirmYesStyle = lipgloss.NewStyle().Foreground(successGreen).Bold(true)
confirmNoStyle = lipgloss.NewStyle().Foreground(textMuted)
badgeStyle = lipgloss.NewStyle().
Background(cyberRed).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1).
Bold(true)
tabBarStyle = lipgloss.NewStyle().Background(bgSurface)
stepDoneStyle = lipgloss.NewStyle().Foreground(successGreen)
stepPendingStyle = lipgloss.NewStyle().Foreground(textMuted)
stepCurrentStyle = lipgloss.NewStyle().Foreground(cyberRed).Bold(true)
stepErrorStyle = lipgloss.NewStyle().Foreground(errorRed)
)
var logoLines = []string{
"███╗ ███╗██╗ ██╗ █████╗ ███╗ ██╗███████╗",
"████╗ ████║╚██╗ ██╔╝██╔══██╗████╗ ██║██╔════╝",
"██╔████╔██║ ╚████╔╝ ███████║██╔██╗ ██║███████╗",
"██║╚██╔╝██║ ╚██╔╝ ██╔══██║██║╚██╗██║╚════██║",
"██║ ╚═╝ ██║ ██║ ██║ ██║██║ ╚████║███████║",
"╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝",
}
var scanFrames = []string{
"─────────────────────────── ───",
" ─────────────────────────── ─── ",
"── ──────────────────────────── ",
"─ ─── ────────────────────────────",
"─── ─────────────────────────── ─",
" ──── ─────────────────────────────",
}
func getAnimFrame(frame int) string {
frames := []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
}
return frames[frame%len(frames)]
}
func getScanFrame(frame int) string {
return scanFrames[frame%len(scanFrames)]
}
func renderLogo() string {
styled := make([]string, len(logoLines))
for i, line := range logoLines {
styled[i] = lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(line)
}
return lipgloss.JoinVertical(lipgloss.Left, styled...)
}
func renderBlockTitle(text string) string {
width := len(text) + 6
top := lipgloss.NewStyle().Foreground(dimRed).Render(
"╭" + repeatStr("─", width) + "╮",
)
content := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"│ ■ " + text + " ■ │",
)
bottom := lipgloss.NewStyle().Foreground(dimRed).Render(
"╰" + repeatStr("─", width) + "╯",
)
return lipgloss.JoinVertical(lipgloss.Left, top, content, bottom)
}
func renderSectionHeader(title string, icon string) string {
return lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
"■ "+icon+" "+title+" ■",
)
}
func renderProgressBar(pct float64, width int) string {
filled := int(float64(width) * pct)
empty := width - filled
bar := lipgloss.NewStyle().Foreground(cyberRed).Bold(true).Render(
repeatStr("█", filled),
) + lipgloss.NewStyle().Foreground(dimRed).Render(
repeatStr("░", empty),
)
return bar
}
func repeatStr(s string, n int) string {
result := ""
for i := 0; i < n; i++ {
result += s
}
return result
}

View File

@@ -1,216 +0,0 @@
package tui
import (
"os"
"os/exec"
"regexp"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var dangerousPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|/)`),
regexp.MustCompile(`(?i)\bmkfs\b`),
regexp.MustCompile(`(?i)\bdd\s+if=`),
regexp.MustCompile(`(?i)\b(format\s+[A-Za-z]:)\b`),
regexp.MustCompile(`(?i):\(\)\{.*\}`),
regexp.MustCompile(`(?i)>(/dev/|/etc/|/boot/)`),
regexp.MustCompile(`(?i)\bshutdown\b`),
regexp.MustCompile(`(?i)\breboot\b`),
regexp.MustCompile(`(?i)\bhalt\b`),
regexp.MustCompile(`(?i)\bpoweroff\b`),
}
func isDangerousCommand(input string) bool {
for _, pat := range dangerousPatterns {
if pat.MatchString(input) {
return true
}
}
return false
}
func (m Model) renderShell() string {
if m.termAIShow {
aiWidth := 36
termWidth := m.width - aiWidth - 2
if termWidth < 20 {
termWidth = 20
aiWidth = m.width - termWidth - 2
}
termPanel := m.renderTermPanel(termWidth)
aiPanel := m.renderAIPanel(aiWidth)
return lipgloss.JoinHorizontal(lipgloss.Top, termPanel, aiPanel)
}
return m.renderTermPanel(m.width)
}
func (m Model) renderTermPanel(width int) string {
var b strings.Builder
cwdStyle := lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd)
b.WriteString(renderSectionHeader("TERMINAL", "[$]"))
b.WriteString(" ")
b.WriteString(cwdStyle)
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n")
for _, line := range m.termLog {
b.WriteString(line + "\n")
}
return b.String()
}
func (m Model) renderAIPanel(width int) string {
var b strings.Builder
b.WriteString(renderSectionHeader("AI ASSISTANT", "[?]"))
b.WriteString("\n\n")
sep := lipgloss.NewStyle().Foreground(borderDim).Render(strings.Repeat("─", max(width-4, 10)))
b.WriteString(" " + sep)
b.WriteString("\n\n")
for _, msg := range m.termAIChat {
b.WriteString(msg)
b.WriteString("\n\n")
}
if m.termAILoading {
b.WriteString(lipgloss.NewStyle().Foreground(neonRed).Render(" " + getAnimFrame(m.animationFrame) + " thinking..."))
b.WriteString("\n")
}
inputLabel := lipgloss.NewStyle().Foreground(cyberRed).Render(">> ")
b.WriteString(inputLabel)
b.WriteString(m.termAIInput)
return lipgloss.NewStyle().
Background(bgSurface).
Border(lipgloss.Border{Left: "│"}).
BorderForeground(borderDim).
Width(width).
Padding(0, 1).
Render(b.String())
}
func (m Model) renderShellInput() string {
prompt := lipgloss.NewStyle().Foreground(successGreen).Render("> ")
return lipgloss.NewStyle().Background(bgInput).Padding(0, 1).Render(
prompt + m.termInput + lipgloss.NewStyle().Foreground(cyberRed).Render("▎"),
)
}
func (m Model) handleShellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
if m.termCmd != nil && m.termCmd.Process != nil {
m.termCmd.Process.Kill()
m.termRunning = false
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(errorRed).Render("^C"))
m.termCmd = nil
m.viewport.SetContent(m.renderContent())
return m, nil
}
now := time.Now()
if m.ctrlCCount > 0 && now.Sub(m.lastCtrlC) < 2*time.Second {
return m, tea.Quit
}
m.ctrlCCount++
m.lastCtrlC = now
m.showingQuit = true
m.confirmCursor = 1
m.viewport.SetContent(m.renderContent())
return m, nil
case "ctrl+t":
m.showingTabMenu = true
m.tabMenuCursor = int(m.activeTab)
return m, nil
case "ctrl+a":
m.termAIShow = !m.termAIShow
m.viewport.SetContent(m.renderContent())
return m, nil
case "enter":
if m.termRunning {
return m, nil
}
input := strings.TrimSpace(m.termInput)
m.termInput = ""
if input == "" {
return m, nil
}
if input == "exit" || input == "quit" {
return m, nil
}
if input == "clear" {
m.termLog = nil
m.viewport.SetContent(m.renderContent())
return m, nil
}
if isDangerousCommand(input) {
m.termLog = append(m.termLog, errMsgStyle.Render(" [BLOCKED] potentially dangerous command"))
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
if strings.HasPrefix(input, "cd ") {
dir := strings.TrimPrefix(input, "cd ")
dir = strings.TrimSpace(dir)
if dir == "~" {
home, _ := os.UserHomeDir()
dir = home
}
if err := os.Chdir(dir); err == nil {
m.termCwd, _ = os.Getwd()
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
} else {
m.termLog = append(m.termLog, errMsgStyle.Render(" cd: "+err.Error()))
}
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, nil
}
m.termLog = append(m.termLog, lipgloss.NewStyle().Foreground(dimRed).Render(m.termCwd+" $ ")+input)
m.viewport.SetContent(m.renderContent())
m.viewport.GotoBottom()
return m, m.runTermCommand(input)
case "backspace":
if len(m.termInput) > 0 {
m.termInput = m.termInput[:len(m.termInput)-1]
m.viewport.SetContent(m.renderContent())
}
return m, nil
default:
if len(msg.String()) == 1 {
m.termInput += msg.String()
m.viewport.SetContent(m.renderContent())
}
}
return m, nil
}
func (m Model) runTermCommand(input string) tea.Cmd {
return tea.Cmd(func() tea.Msg {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
cmd := exec.Command(shell, "-c", input)
cmd.Dir = m.termCwd
out, err := cmd.CombinedOutput()
if err != nil {
return termOutputMsg{line: string(out) + errMsgStyle.Render(err.Error())}
}
return termOutputMsg{line: string(out)}
})
}

View File

@@ -1,207 +0,0 @@
package tui
import (
"os/exec"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/daemon"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/orchestrator"
"github.com/muyue/muyue/internal/preview"
"github.com/muyue/muyue/internal/proxy"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/skills"
"github.com/muyue/muyue/internal/updater"
"github.com/muyue/muyue/internal/workflow"
)
type tab int
const (
tabDashboard tab = iota
tabStudio
tabShell
tabConfig
tabCount
)
var tabNames = []string{"DASH", "STUDIO", "SHELL", "CONFIG"}
var tabIcons = []string{"[■]", "[<>]", "[>$]", "[//]"}
type aiResponseMsg struct{ content string }
type aiErrMsg struct{ err error }
type scanCompleteMsg struct{ result *scanner.ScanResult }
type installCompleteMsg struct{ results []installer.InstallResult }
type installProgressMsg struct {
tool string
current int
total int
}
type installBatchMsg struct {
result installer.InstallResult
tools []string
index int
config *config.MuyueConfig
}
type updateCheckMsg struct{ statuses []updater.UpdateStatus }
type previewReadyMsg struct{ url string }
type workflowPhaseMsg struct{ phase workflow.Phase }
type daemonLogMsg struct{ logs []string }
type lspScanMsg struct{ servers []lsp.LSPServer }
type mcpConfigMsg struct{ err error }
type skillsListMsg struct{ skills []skills.Skill }
type spinnerTickMsg struct{ time time.Time }
type termOutputMsg struct{ line string }
type termExitMsg struct{}
type animTickMsg struct{ time time.Time }
type clockTickMsg struct{ time time.Time }
type glitchDoneMsg struct{}
type scanDoneMsg struct{}
type studioPanel int
const (
panelChat studioPanel = iota
panelAgents
panelWorkflows
)
type configSection int
const (
configProfile configSection = iota
configProviders
configTerminal
configSkills
)
type transitionState int
const (
transitionNone transitionState = iota
transitionGlitch
transitionScan
transitionTypewriter
)
type Model struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
activeTab tab
prevTab tab
width int
height int
viewport viewport.Model
ready bool
chatInput string
chatLog []string
chatLoading bool
orch *orchestrator.Orchestrator
proxyMgr *proxy.Manager
updateStatus []updater.UpdateStatus
installLog []string
previewURL string
previewSrv *preview.PreviewServer
daemon *daemon.Daemon
lspServers []lsp.LSPServer
mcpConfigured bool
skillList []skills.Skill
helpModel help.Model
progressBar progress.Model
spinner spinner.Model
showingQuit bool
confirmCursor int
showingTabMenu bool
tabMenuCursor int
ctrlCCount int
lastCtrlC time.Time
installing bool
installCurrent int
installTotal int
installTool string
termCmd *exec.Cmd
termInput string
termLog []string
termRunning bool
termCwd string
studioPanel studioPanel
studioSidebarOpen bool
termAIChat []string
termAIInput string
termAILoading bool
termAIShow bool
configSection configSection
configField int
animationFrame int
transition transitionState
transitionTick int
typewriterBuf string
typewriterPos int
currentTime time.Time
}
type keyMap struct {
Tab key.Binding
Prev key.Binding
Quit key.Binding
TabMenu key.Binding
Enter key.Binding
Backspace key.Binding
}
var keys = keyMap{
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "next"),
),
Prev: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "prev"),
),
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
),
TabMenu: key.NewBinding(
key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "tabs"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "send"),
),
Backspace: key.NewBinding(
key.WithKeys("backspace"),
key.WithHelp("backspace", "delete"),
),
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.TabMenu, k.Tab, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.TabMenu, k.Tab, k.Prev},
{k.Quit},
}
}

View File

@@ -1,18 +1,33 @@
package version
import (
"fmt"
"runtime"
)
const (
Name = "muyue"
Version = "0.2.1"
Version = "0.3.5"
Author = "La Légion de Muyue"
License = "MIT"
)
var Prerelease string
var (
// BuildDate is set at build time
BuildDate = ""
)
func FullVersion() string {
v := Name + " v" + Version
if Prerelease != "" {
v += "-" + Prerelease
}
return v
return Name + " v" + Version
}
// FullInfo returns full version information.
func FullInfo() string {
info := fmt.Sprintf("%-12s %s\n", "Version:", Version)
info += fmt.Sprintf("%-12s %s\n", "Author:", Author)
info += fmt.Sprintf("%-12s %s\n", "Go:", runtime.Version())
info += fmt.Sprintf("%-12s %s\n", "Platform:", runtime.GOOS+"/"+runtime.GOARCH)
if BuildDate != "" {
info += fmt.Sprintf("%-12s %s\n", "Build:", BuildDate)
}
return info
}

View File

@@ -15,28 +15,6 @@ func TestFullVersion(t *testing.T) {
}
}
func TestFullVersionWithPrerelease(t *testing.T) {
original := Prerelease
Prerelease = "beta.1"
defer func() { Prerelease = original }()
v := FullVersion()
if !strings.Contains(v, "beta.1") {
t.Errorf("FullVersion should contain prerelease suffix, got %s", v)
}
}
func TestFullVersionWithoutPrerelease(t *testing.T) {
original := Prerelease
Prerelease = ""
defer func() { Prerelease = original }()
v := FullVersion()
if strings.Contains(v, "-") {
t.Errorf("FullVersion should not contain prerelease suffix, got %s", v)
}
}
func TestConstants(t *testing.T) {
if Name == "" {
t.Error("Name should not be empty")
@@ -47,7 +25,4 @@ func TestConstants(t *testing.T) {
if Author == "" {
t.Error("Author should not be empty")
}
if License == "" {
t.Error("License should not be empty")
}
}

362
internal/workflow/engine.go Normal file
View File

@@ -0,0 +1,362 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
)
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusDone Status = "done"
StatusFailed Status = "failed"
StatusSkipped Status = "skipped"
StatusAwaiting Status = "awaiting_approval"
)
type StepType string
const (
TypeToolCall StepType = "tool_call"
TypeCondition StepType = "condition"
TypeParallel StepType = "parallel"
TypeApproval StepType = "approval"
)
type Step struct {
ID string `json:"id"`
Name string `json:"name"`
Type StepType `json:"type"`
Tool string `json:"tool,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
Status Status `json:"status"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Condition string `json:"condition,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
ApproveRole string `json:"approve_role,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
}
type Workflow struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Steps []Step `json:"steps"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Engine struct {
mu sync.RWMutex
workflows map[string]*Workflow
agentRegistry *agent.Registry
storePath string
}
func NewEngine(registry *agent.Registry) (*Engine, error) {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
storePath := filepath.Join(dir, "workflows.json")
engine := &Engine{
workflows: make(map[string]*Workflow),
agentRegistry: registry,
storePath: storePath,
}
engine.load()
return engine, nil
}
func (e *Engine) load() {
data, err := os.ReadFile(e.storePath)
if err != nil {
return
}
var workflows []*Workflow
if err := json.Unmarshal(data, &workflows); err != nil {
return
}
for _, w := range workflows {
e.workflows[w.ID] = w
}
}
func (e *Engine) save() error {
dir := filepath.Dir(e.storePath)
os.MkdirAll(dir, 0755)
e.mu.RLock()
workflows := make([]*Workflow, 0, len(e.workflows))
for _, w := range e.workflows {
workflows = append(workflows, w)
}
e.mu.RUnlock()
data, err := json.MarshalIndent(workflows, "", " ")
if err != nil {
return err
}
return os.WriteFile(e.storePath, data, 0600)
}
func (e *Engine) Create(name, description, wfType string, steps []Step) *Workflow {
wf := &Workflow{
ID: fmt.Sprintf("wf-%d", time.Now().UnixNano()),
Name: name,
Description: description,
Type: wfType,
Steps: steps,
Status: StatusPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
for i := range wf.Steps {
if wf.Steps[i].ID == "" {
wf.Steps[i].ID = fmt.Sprintf("step-%d", i)
}
if wf.Steps[i].Status == "" {
wf.Steps[i].Status = StatusPending
}
}
e.mu.Lock()
e.workflows[wf.ID] = wf
e.mu.Unlock()
e.save()
return wf
}
func (e *Engine) Get(id string) (*Workflow, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
wf, ok := e.workflows[id]
return wf, ok
}
func (e *Engine) List() []*Workflow {
e.mu.RLock()
defer e.mu.RUnlock()
result := make([]*Workflow, 0, len(e.workflows))
for _, w := range e.workflows {
result = append(result, w)
}
return result
}
func (e *Engine) Delete(id string) error {
e.mu.Lock()
defer e.mu.Unlock()
if _, ok := e.workflows[id]; !ok {
return fmt.Errorf("workflow not found: %s", id)
}
delete(e.workflows, id)
return e.save()
}
func (e *Engine) UpdateStep(workflowID, stepID string, update func(*Step)) error {
e.mu.Lock()
defer e.mu.Unlock()
wf, ok := e.workflows[workflowID]
if !ok {
return fmt.Errorf("workflow not found: %s", workflowID)
}
for i := range wf.Steps {
if wf.Steps[i].ID == stepID {
update(&wf.Steps[i])
wf.UpdatedAt = time.Now()
e.save()
return nil
}
}
return fmt.Errorf("step not found: %s", stepID)
}
func (e *Engine) UpdateWorkflowStatus(workflowID string, status Status) error {
e.mu.Lock()
defer e.mu.Unlock()
wf, ok := e.workflows[workflowID]
if !ok {
return fmt.Errorf("workflow not found: %s", workflowID)
}
wf.Status = status
wf.UpdatedAt = time.Now()
return e.save()
}
func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(step *Step, event string)) error {
wf, ok := e.Get(workflowID)
if !ok {
return fmt.Errorf("workflow not found: %s", workflowID)
}
if err := e.UpdateWorkflowStatus(workflowID, StatusRunning); err != nil {
return err
}
stepStatuses := make(map[string]Status)
for _, step := range wf.Steps {
stepStatuses[step.ID] = StatusPending
}
resolveDeps := func(stepID string) bool {
step := wf.findStep(stepID)
if step == nil {
return false
}
for _, dep := range step.DependsOn {
if stepStatuses[dep] != StatusDone {
return false
}
}
return true
}
executeStep := func(step *Step) error {
now := time.Now()
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusRunning
s.StartedAt = &now
})
if onStep != nil {
onStep(step, "started")
}
var result string
var stepErr error
switch step.Type {
case TypeToolCall:
if step.Tool == "" {
stepErr = fmt.Errorf("tool not specified for step %s", step.ID)
} else {
call := agent.ToolCall{
ID: step.ID,
Name: step.Tool,
Arguments: step.Args,
}
resp, err := e.agentRegistry.Execute(ctx, call)
if err != nil {
stepErr = err
} else {
result = resp.Content
if resp.IsError {
stepErr = fmt.Errorf("%s", result)
}
}
}
case TypeApproval:
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusAwaiting
})
if onStep != nil {
onStep(step, "awaiting_approval")
}
return nil
case TypeCondition:
result = fmt.Sprintf("condition '%s' evaluated", step.Condition)
default:
stepErr = fmt.Errorf("unknown step type: %s", step.Type)
}
endTime := time.Now()
if stepErr != nil {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusFailed
s.Error = stepErr.Error()
s.EndedAt = &endTime
})
if onStep != nil {
onStep(step, "failed")
}
} else {
e.UpdateStep(workflowID, step.ID, func(s *Step) {
s.Status = StatusDone
s.Result = result
s.EndedAt = &endTime
})
stepStatuses[step.ID] = StatusDone
if onStep != nil {
onStep(step, "done")
}
}
return stepErr
}
hasFailures := false
for _, step := range wf.Steps {
if step.Type == TypeParallel {
continue
}
for !resolveDeps(step.ID) {
time.Sleep(100 * time.Millisecond)
}
if err := executeStep(&step); err != nil {
hasFailures = true
break
}
}
if hasFailures {
e.UpdateWorkflowStatus(workflowID, StatusFailed)
} else {
e.UpdateWorkflowStatus(workflowID, StatusDone)
}
return nil
}
func (w *Workflow) findStep(id string) *Step {
for i := range w.Steps {
if w.Steps[i].ID == id {
return &w.Steps[i]
}
}
return nil
}
func (e *Engine) ApproveStep(workflowID, stepID string) error {
return e.UpdateStep(workflowID, stepID, func(s *Step) {
s.Status = StatusDone
})
}
func (e *Engine) SkipStep(workflowID, stepID string) error {
return e.UpdateStep(workflowID, stepID, func(s *Step) {
s.Status = StatusSkipped
})
}

View File

@@ -0,0 +1,172 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/orchestrator"
)
type Planner struct {
orchestrator *orchestrator.Orchestrator
}
func NewPlanner(cfg *config.MuyueConfig) (*Planner, error) {
orb, err := orchestrator.New(cfg)
if err != nil {
return nil, err
}
orb.SetSystemPrompt(plannerSystemPrompt)
return &Planner{orchestrator: orb}, nil
}
func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error) {
prompt := buildPlanPrompt(goal)
messages := []orchestrator.Message{
{Role: "user", Content: prompt},
}
resp, err := p.orchestrator.SendWithTools(messages)
if err != nil {
return nil, err
}
if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
return nil, fmt.Errorf("no plan generated")
}
content := resp.Choices[0].Message.Content
plan, err := parsePlanResponse(content)
if err != nil {
return nil, err
}
return plan, nil
}
func buildPlanPrompt(goal string) string {
return fmt.Sprintf(`Tu es un planificateur de workflows pour Muyue. L'utilisateur veut accomplir la tâche suivante:
"%s"
Analyse cette tâche et génère un plan d'exécution en une série d'étapes. Chaque étape est un appel d'outil.
Les outils disponibles sont:
- terminal: Exécuter une commande shell
- read_file: Lire un fichier
- list_files: Lister les fichiers d'un répertoire
- search_files: Rechercher des fichiers par pattern
- grep_content: Rechercher du texte dans des fichiers
- get_config: Lire la configuration Muyue
- set_provider: Configurer un provider AI
- manage_ssh: Gérer les connexions SSH
- web_fetch: Récupérer le contenu d'une URL
Réponds UNIQUEMENT avec un JSON valide représentant un tableau d'étapes, sans texte avant ou après:
[
{"name": "Nom de l'étape", "tool": "terminal", "args": {"command": "ls -la"}},
{"name": "Lire le fichier config", "tool": "read_file", "args": {"path": "~/.muyue/config.json"}}
]
Règles:
- Chaque étape doit avoir: name, tool, args
- Les args varient selon le tool (voir les définitions)
- Sois précis dans les commandes
- Sépare en étapes logiques
- Ne génère pas plus de 10 étapes`, goal)
}
func parsePlanResponse(content string) ([]Step, error) {
content = strings.TrimSpace(content)
var jsonStr string
if strings.HasPrefix(content, "```json") {
lines := strings.Split(content, "\n")
var jsonLines []string
for _, line := range lines[1:] {
if strings.HasPrefix(line, "```") {
break
}
jsonLines = append(jsonLines, line)
}
jsonStr = strings.Join(jsonLines, "\n")
} else if strings.HasPrefix(content, "```") {
lines := strings.Split(content, "\n")
var jsonLines []string
for _, line := range lines[1:] {
if strings.HasPrefix(line, "```") {
break
}
jsonLines = append(jsonLines, line)
}
jsonStr = strings.Join(jsonLines, "\n")
} else {
jsonStr = content
}
var rawSteps []map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &rawSteps); err != nil {
return nil, fmt.Errorf("failed to parse plan JSON: %v\nContent: %s", err, content)
}
steps := make([]Step, 0, len(rawSteps))
for i, raw := range rawSteps {
step := Step{
ID: fmt.Sprintf("step-%d", i),
Status: StatusPending,
}
if name, ok := raw["name"].(string); ok {
step.Name = name
} else {
step.Name = fmt.Sprintf("Step %d", i+1)
}
if tool, ok := raw["tool"].(string); ok {
step.Tool = tool
step.Type = TypeToolCall
}
if args, ok := raw["args"].(map[string]interface{}); ok {
argsJSON, err := json.Marshal(args)
if err == nil {
step.Args = argsJSON
}
}
if tool, ok := raw["type"].(string); ok {
switch tool {
case "approval":
step.Type = TypeApproval
case "condition":
step.Type = TypeCondition
if cond, ok := raw["condition"].(string); ok {
step.Condition = cond
}
default:
step.Type = TypeToolCall
}
}
steps = append(steps, step)
}
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.
Pour générer un plan:
1. Comprends l'objectif de l'utilisateur
2. Identifie les outils nécessaires
3. Décompose en étapes logiques
4. Spécifie les paramètres de chaque outil
Réponds toujours en JSON valide, sans texte additionnel.`
var _ = plannerSystemPrompt

View File

@@ -1,280 +0,0 @@
package workflow
import (
"encoding/json"
"fmt"
"strings"
)
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseGathering Phase = "gathering"
PhasePlanning Phase = "planning"
PhaseReviewing Phase = "reviewing"
PhaseExecuting Phase = "executing"
PhaseDone Phase = "done"
PhaseError Phase = "error"
)
type Step struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Agent string `json:"agent"`
Output string `json:"output,omitempty"`
}
type Plan struct {
Goal string `json:"goal"`
Context string `json:"context"`
Questions []string `json:"questions"`
Answers []string `json:"answers"`
Steps []Step `json:"steps"`
StepIndex int `json:"current_step"`
PreviewFiles []PreviewFile `json:"preview_files,omitempty"`
}
type PreviewFile struct {
Filename string `json:"filename"`
Content string `json:"content"`
Type string `json:"type"`
}
type Workflow struct {
Phase Phase
Plan *Plan
History []string
}
func New() *Workflow {
return &Workflow{
Phase: PhaseIdle,
Plan: &Plan{},
History: []string{},
}
}
func (w *Workflow) Start(goal string) {
w.Phase = PhaseGathering
w.Plan = &Plan{
Goal: goal,
Steps: []Step{},
Answers: []string{},
}
w.History = append(w.History, fmt.Sprintf("[started] %s", goal))
}
func (w *Workflow) AddAnswer(answer string) {
w.Plan.Answers = append(w.Plan.Answers, answer)
if len(w.Plan.Answers) >= len(w.Plan.Questions) {
w.Phase = PhasePlanning
w.History = append(w.History, "[gathering complete, moving to planning]")
}
}
func (w *Workflow) SetPlan(planJSON string) error {
var steps []Step
if err := json.Unmarshal([]byte(planJSON), &steps); err != nil {
if err2 := json.Unmarshal([]byte("["+planJSON+"]"), &steps); err2 != nil {
return fmt.Errorf("parse plan: %w", err)
}
}
w.Plan.Steps = steps
w.Phase = PhaseReviewing
w.History = append(w.History, fmt.Sprintf("[plan created] %d steps", len(steps)))
return nil
}
func (w *Workflow) SetPreviewFiles(files []PreviewFile) {
w.Plan.PreviewFiles = files
}
func (w *Workflow) Approve() {
w.Phase = PhaseExecuting
w.Plan.StepIndex = 0
w.History = append(w.History, "[plan approved, starting execution]")
}
func (w *Workflow) Reject(feedback string) {
w.Phase = PhasePlanning
w.History = append(w.History, fmt.Sprintf("[plan rejected: %s]", feedback))
}
func (w *Workflow) AdvanceStep(output string) {
if w.Plan.StepIndex < len(w.Plan.Steps) {
w.Plan.Steps[w.Plan.StepIndex].Status = "done"
w.Plan.Steps[w.Plan.StepIndex].Output = output
w.Plan.StepIndex++
w.History = append(w.History, fmt.Sprintf("[step %d done]", w.Plan.StepIndex))
if w.Plan.StepIndex >= len(w.Plan.Steps) {
w.Phase = PhaseDone
w.History = append(w.History, "[all steps complete]")
}
}
}
func (w *Workflow) FailStep(errMsg string) {
if w.Plan.StepIndex < len(w.Plan.Steps) {
w.Plan.Steps[w.Plan.StepIndex].Status = "error"
w.Plan.Steps[w.Plan.StepIndex].Output = errMsg
w.Phase = PhaseError
w.History = append(w.History, fmt.Sprintf("[step %d failed: %s]", w.Plan.StepIndex+1, errMsg))
}
}
func (w *Workflow) Reset() {
w.Phase = PhaseIdle
w.Plan = &Plan{}
}
func (w *Workflow) CurrentStep() *Step {
if w.Plan.StepIndex < len(w.Plan.Steps) {
return &w.Plan.Steps[w.Plan.StepIndex]
}
return nil
}
func (w *Workflow) Progress() (done, total int) {
for _, s := range w.Plan.Steps {
if s.Status == "done" {
done++
}
total++
}
return
}
func BuildSystemPrompt(phase Phase, plan *Plan) string {
base := `You are muyue, an AI-powered development environment assistant.
You follow a structured workflow: GATHER requirements → PLAN → REVIEW → EXECUTE.
RULES:
- Always respond in the same language the user writes in.
- When in GATHERING phase, ask clarifying questions ONE AT A TIME to understand the requirement fully.
- When in PLANNING phase, create a detailed step-by-step plan as a JSON array of objects.
- When in REVIEWING phase, present the plan clearly and wait for approval.
- When in EXECUTING phase, execute one step at a time and report results.
- If the user wants a visual preview, generate 1-2 HTML files wrapped in a PREVIEW_JSON block.`
switch phase {
case PhaseGathering:
base += fmt.Sprintf(`
CURRENT PHASE: GATHERING
Goal: %s
Questions to ask: %v
Answers received: %v
Remaining questions: %d
Ask the NEXT question that hasn't been answered yet. If all questions are answered, say "GATHERING_COMPLETE".`,
plan.Goal, plan.Questions, plan.Answers,
len(plan.Questions)-len(plan.Answers))
case PhasePlanning:
qa := ""
for i, q := range plan.Questions {
a := ""
if i < len(plan.Answers) {
a = plan.Answers[i]
}
qa += fmt.Sprintf("\nQ: %s\nA: %s", q, a)
}
base += fmt.Sprintf(`
CURRENT PHASE: PLANNING
Goal: %s
%s
Create a step-by-step plan. Output ONLY a JSON array of steps:
[
{"id": "1", "title": "...", "description": "...", "agent": "crush|claude|muyue", "status": "pending"},
...
]
If the user needs a visual preview, wrap HTML in:
<<<PREVIEW_JSON>>>
[{"filename":"preview.html","content":"<html>...</html>","type":"html"}]
<<<END_PREVIEW>>>`,
plan.Goal, qa)
case PhaseReviewing:
steps, _ := json.MarshalIndent(plan.Steps, "", " ")
base += fmt.Sprintf(`
CURRENT PHASE: REVIEWING
Present the plan below clearly and ask for approval:
%s
Say "PLAN_APPROVED" if the user approves, or "PLAN_REJECTED: <reason>" if not.`,
string(steps))
case PhaseExecuting:
if plan.StepIndex < len(plan.Steps) {
step := plan.Steps[plan.StepIndex]
base += fmt.Sprintf(`
CURRENT PHASE: EXECUTING
Current step: %s — %s (agent: %s)
Execute this step and report the result.`,
step.Title, step.Description, step.Agent)
}
}
return base
}
func ParsePlanResponse(response string) ([]Step, error) {
response = strings.TrimSpace(response)
start := strings.Index(response, "[")
end := strings.LastIndex(response, "]")
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("no JSON array found in response")
}
jsonStr := response[start : end+1]
var steps []Step
if err := json.Unmarshal([]byte(jsonStr), &steps); err != nil {
return nil, fmt.Errorf("parse steps: %w", err)
}
for i := range steps {
steps[i].Status = "pending"
}
return steps, nil
}
func ParsePreviewFiles(response string) []PreviewFile {
startMarker := "<<<PREVIEW_JSON>>>"
endMarker := "<<<END_PREVIEW>>>"
start := strings.Index(response, startMarker)
end := strings.Index(response, endMarker)
if start == -1 || end == -1 {
return nil
}
jsonStr := strings.TrimSpace(response[start+len(startMarker) : end])
var files []PreviewFile
if err := json.Unmarshal([]byte(jsonStr), &files); err != nil {
return nil
}
return files
}
func ParseApproval(response string) (approved bool, feedback string) {
lower := strings.ToLower(strings.TrimSpace(response))
if strings.Contains(lower, "plan_approved") || strings.Contains(lower, "approved") || strings.Contains(lower, "yes") || strings.Contains(lower, "go ahead") || strings.Contains(lower, "oui") || strings.Contains(lower, "ok") {
return true, ""
}
if strings.Contains(lower, "plan_rejected:") {
parts := strings.SplitN(lower, "plan_rejected:", 2)
if len(parts) > 1 {
return false, strings.TrimSpace(parts[1])
}
}
return false, response
}

View File

@@ -1,255 +0,0 @@
package workflow
import (
"testing"
)
func TestNew(t *testing.T) {
wf := New()
if wf.Phase != PhaseIdle {
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
}
if wf.Plan == nil {
t.Error("Plan should not be nil")
}
}
func TestStart(t *testing.T) {
wf := New()
wf.Start("Build a REST API")
if wf.Phase != PhaseGathering {
t.Errorf("Expected PhaseGathering, got %s", wf.Phase)
}
if wf.Plan.Goal != "Build a REST API" {
t.Errorf("Expected goal 'Build a REST API', got %s", wf.Plan.Goal)
}
}
func TestAddAnswer(t *testing.T) {
wf := New()
wf.Start("test goal")
wf.Plan.Questions = []string{"Q1?", "Q2?"}
wf.AddAnswer("A1")
if wf.Phase != PhaseGathering {
t.Errorf("Should still be gathering, got %s", wf.Phase)
}
wf.AddAnswer("A2")
if wf.Phase != PhasePlanning {
t.Errorf("Should move to planning, got %s", wf.Phase)
}
}
func TestSetPlan(t *testing.T) {
wf := New()
planJSON := `[{"id":"1","title":"Step 1","description":"Do something","agent":"crush","status":"pending"}]`
err := wf.SetPlan(planJSON)
if err != nil {
t.Fatalf("SetPlan failed: %v", err)
}
if len(wf.Plan.Steps) != 1 {
t.Errorf("Expected 1 step, got %d", len(wf.Plan.Steps))
}
if wf.Phase != PhaseReviewing {
t.Errorf("Expected PhaseReviewing, got %s", wf.Phase)
}
}
func TestApprove(t *testing.T) {
wf := New()
wf.Start("test")
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1", Status: "pending"}}
wf.Phase = PhaseReviewing
wf.Approve()
if wf.Phase != PhaseExecuting {
t.Errorf("Expected PhaseExecuting, got %s", wf.Phase)
}
if wf.Plan.StepIndex != 0 {
t.Errorf("Expected step index 0, got %d", wf.Plan.StepIndex)
}
}
func TestReject(t *testing.T) {
wf := New()
wf.Phase = PhaseReviewing
wf.Reject("too complex")
if wf.Phase != PhasePlanning {
t.Errorf("Expected PhasePlanning, got %s", wf.Phase)
}
}
func TestAdvanceStep(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{
{ID: "1", Title: "Step 1", Status: "pending"},
{ID: "2", Title: "Step 2", Status: "pending"},
}
wf.Phase = PhaseExecuting
wf.AdvanceStep("output1")
if wf.Plan.Steps[0].Status != "done" {
t.Error("First step should be done")
}
if wf.Plan.StepIndex != 1 {
t.Errorf("Expected step index 1, got %d", wf.Plan.StepIndex)
}
if wf.Phase != PhaseExecuting {
t.Errorf("Should still be executing, got %s", wf.Phase)
}
wf.AdvanceStep("output2")
if wf.Phase != PhaseDone {
t.Errorf("Expected PhaseDone, got %s", wf.Phase)
}
}
func TestFailStep(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{{ID: "1", Title: "Step 1"}}
wf.Phase = PhaseExecuting
wf.FailStep("something broke")
if wf.Phase != PhaseError {
t.Errorf("Expected PhaseError, got %s", wf.Phase)
}
if wf.Plan.Steps[0].Status != "error" {
t.Error("Step should have error status")
}
}
func TestReset(t *testing.T) {
wf := New()
wf.Start("test")
wf.Phase = PhaseExecuting
wf.Reset()
if wf.Phase != PhaseIdle {
t.Errorf("Expected PhaseIdle, got %s", wf.Phase)
}
}
func TestCurrentStep(t *testing.T) {
wf := New()
if wf.CurrentStep() != nil {
t.Error("Should be nil with no steps")
}
wf.Plan.Steps = []Step{{ID: "1"}, {ID: "2"}}
wf.Plan.StepIndex = 0
step := wf.CurrentStep()
if step == nil || step.ID != "1" {
t.Error("Should return first step")
}
wf.Plan.StepIndex = 2
if wf.CurrentStep() != nil {
t.Error("Should be nil when past all steps")
}
}
func TestProgress(t *testing.T) {
wf := New()
wf.Plan.Steps = []Step{
{ID: "1", Status: "done"},
{ID: "2", Status: "pending"},
{ID: "3", Status: "done"},
}
done, total := wf.Progress()
if done != 2 || total != 3 {
t.Errorf("Expected 2/3, got %d/%d", done, total)
}
}
func TestParsePlanResponse(t *testing.T) {
resp := `Here is the plan:
[
{"id": "1", "title": "Setup", "description": "Init project", "agent": "crush"},
{"id": "2", "title": "Build", "description": "Write code", "agent": "claude"}
]`
steps, err := ParsePlanResponse(resp)
if err != nil {
t.Fatalf("ParsePlanResponse failed: %v", err)
}
if len(steps) != 2 {
t.Errorf("Expected 2 steps, got %d", len(steps))
}
if steps[0].ID != "1" {
t.Errorf("Expected step ID 1, got %s", steps[0].ID)
}
for _, s := range steps {
if s.Status != "pending" {
t.Errorf("Steps should be pending, got %s", s.Status)
}
}
}
func TestParsePlanResponseInvalid(t *testing.T) {
_, err := ParsePlanResponse("no json here")
if err == nil {
t.Error("Should fail with no JSON")
}
}
func TestParseApproval(t *testing.T) {
tests := []struct {
input string
approved bool
}{
{"plan_approved", true},
{"approved", true},
{"yes", true},
{"ok", true},
{"oui", true},
{"go ahead", true},
{"no", false},
{"plan_rejected: too complex", false},
{"I don't like it", false},
}
for _, tt := range tests {
approved, feedback := ParseApproval(tt.input)
if approved != tt.approved {
t.Errorf("ParseApproval(%q) = %v, want %v", tt.input, approved, tt.approved)
}
if !approved && tt.input == "plan_rejected: too complex" {
if feedback != "too complex" {
t.Errorf("Expected feedback 'too complex', got %s", feedback)
}
}
}
}
func TestParsePreviewFiles(t *testing.T) {
resp := `Some text
<<<PREVIEW_JSON>>>
[{"filename":"test.html","content":"<h1>Hello</h1>","type":"html"}]
<<<END_PREVIEW>>>`
files := ParsePreviewFiles(resp)
if len(files) != 1 {
t.Fatalf("Expected 1 file, got %d", len(files))
}
if files[0].Filename != "test.html" {
t.Errorf("Expected test.html, got %s", files[0].Filename)
}
}
func TestParsePreviewFilesNone(t *testing.T) {
files := ParsePreviewFiles("no preview here")
if files != nil {
t.Error("Should return nil")
}
}
func TestBuildSystemPrompt(t *testing.T) {
prompt := BuildSystemPrompt(PhaseIdle, &Plan{})
if prompt == "" {
t.Error("Prompt should not be empty")
}
if len(prompt) < 100 {
t.Error("Prompt seems too short")
}
prompt = BuildSystemPrompt(PhaseGathering, &Plan{Goal: "test"})
if prompt == "" {
t.Error("Gathering prompt should not be empty")
}
}

4
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
!dist/.gitkeep
.vite/

6
web/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed all:dist
var Assets embed.FS

14
web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0A0A0C" />
<title>muyue</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬡</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

999
web/package-lock.json generated Normal file
View File

@@ -0,0 +1,999 @@
{
"name": "muyue-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "muyue-web",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.7"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.5"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"dev": true,
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/vite": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

22
web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "muyue-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.9"
}
}

155
web/src/api/client.js Normal file
View File

@@ -0,0 +1,155 @@
const API_BASE = '/api'
async function request(path, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
}
const api = {
getInfo: () => request('/info'),
getSystem: () => request('/system'),
getTools: () => request('/tools'),
getConfig: () => request('/config'),
getProviders: () => request('/providers'),
getSkills: () => request('/skills'),
getLSP: () => request('/lsp'),
getMCP: () => request('/mcp'),
getUpdates: () => request('/updates'),
getEditors: () => request('/editors'),
runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
configureMCPForEditor: (editor) => request('/mcp/configure', { method: 'POST', body: JSON.stringify({ editor }) }),
getMCPStatus: () => request('/mcp/status'),
getMCPRegistry: () => request('/mcp/registry'),
getLSPHealth: () => request('/lsp/health'),
autoInstallLSP: (projectDir) => request('/lsp/auto-install', { method: 'POST', body: JSON.stringify({ project_dir: projectDir || '' }) }),
generateLSPConfig: (editor, names) => request('/lsp/editor-config', { method: 'POST', body: JSON.stringify({ editor, names }) }),
validateSkill: (name) => request('/skills/validate', { method: 'POST', body: JSON.stringify({ name }) }),
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 }) }),
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
getDashboardStatus: () => request('/dashboard/status'),
getProvidersQuota: () => request('/providers/quota'),
getRecentCommands: () => request('/recent-commands'),
getRunningProcesses: () => request('/running-processes'),
getSystemMetrics: () => request('/system/metrics'),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),
resetConfig: () => request('/config/reset', { method: 'POST' }),
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
getTerminalThemes: () => request('/terminal/themes'),
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
getShellChatHistory: () => request('/shell/chat/history'),
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
getShellAnalysis: () => request('/shell/analysis'),
sendChat: (message, stream = true, onChunk, signal) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
}
return new Promise((resolve, reject) => {
fetch(`${API_BASE}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }),
signal,
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
reject(new Error(err.error || res.statusText))
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let full = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.error) { reject(new Error(data.error)); return }
if (data.done) { resolve(full); return }
if (data.content) {
full += data.content
if (onChunk) onChunk(full, data)
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data)
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data)
}
} catch {}
}
}
resolve(full)
}).catch(reject)
})
},
sendShellChat: (message, context = {}, stream = true, onChunk) => {
const payload = {
message,
cwd: context.cwd || '',
platform: context.platform || '',
stream,
}
if (!stream) {
return request('/shell/chat', { method: 'POST', body: JSON.stringify(payload) })
}
return new Promise((resolve, reject) => {
fetch(`${API_BASE}/shell/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
reject(new Error(err.error || res.statusText))
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let full = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.error) { reject(new Error(data.error)); return }
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
if (data.content) {
full = data.content
if (onChunk) onChunk(full, data)
}
} catch {}
}
}
resolve({ content: full })
}).catch(reject)
})
},
}
export default api

177
web/src/components/App.jsx Normal file
View File

@@ -0,0 +1,177 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard'
import Studio from './Studio'
import Shell from './Shell'
import Config from './Config'
import OnboardingWizard from './OnboardingWizard'
export default function App() {
const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date())
const [isSudo, setIsSudo] = useState(false)
const [dashRefreshKey, setDashRefreshKey] = useState(0)
const dashRefreshRef = useRef(null)
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
const [showOnboarding, setShowOnboarding] = useState(false)
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: <LayoutDashboard size={15} /> },
{ id: 'studio', label: t('tabs.studio'), icon: <Sparkles size={15} /> },
{ id: 'shell', label: t('tabs.shell'), icon: <Terminal size={15} /> },
{ id: 'config', label: t('tabs.config'), icon: <Settings size={15} /> },
], [t])
useEffect(() => {
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 => {
setConfig(d)
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
applyTheme(getTheme(theme))
const hasProfile = d.profile?.name || d.profile?.pseudo
if (!hasProfile) setShowOnboarding(true)
}).catch(() => {
applyTheme(getTheme('cyberpunk-red'))
setShowOnboarding(true)
})
}, [])
useEffect(() => {
const id = setInterval(() => setClock(new Date()), 1000)
return () => clearInterval(id)
}, [])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.ctrlKey && !e.metaKey) return
const map = {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'config',
}
if (map[e.code]) {
e.preventDefault()
setActiveTab(map[e.code])
return
}
if (e.ctrlKey && e.code === 'KeyR') {
e.preventDefault()
if (dashRefreshRef.current) dashRefreshRef.current()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
useEffect(() => {
const handler = () => setActiveTab('shell')
window.addEventListener('navigate-to-shell', handler)
return () => window.removeEventListener('navigate-to-shell', handler)
}, [])
const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
],
shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
],
config: [],
}), [layout, t])
return (
<div className="app-layout">
<header className="header">
<div className="header-brand">
<span className="header-logo">MUYUE</span>
<span className="header-version">v{info.version || '...'}</span>
</div>
<nav className="header-nav">
{TABS.map(tab => (
<div
key={tab.id}
className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => switchTab(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
>
<span className="tab-icon">{tab.icon}</span>
{tab.label}
</div>
))}
</nav>
<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">
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span>
</header>
<main className="content">
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main>
<footer className="statusbar">
<div className="statusbar-left">
{isSudo && <span className="statusbar-sudo"> ROOT</span>}
{activeTab === 'dash' && (
<span className="statusbar-shortcut">
<kbd>{layout.keys.ctrl}+R</kbd> refresh
</span>
)}
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div>
<div className="statusbar-right">
<span style={{ fontFamily: 'var(--font-mono)' }}>
{layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
</span>
</div>
</footer>
{showOnboarding && <OnboardingWizard api={api} onComplete={() => setShowOnboarding(false)} />}
</div>
)
}
function FooterShortcuts({ shortcuts }) {
return shortcuts.map((s, i) => (
<span key={i} className="statusbar-shortcut">
<kbd>{s.keys}</kbd> {s.desc}
</span>
))
}

View File

@@ -0,0 +1,676 @@
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react'
import { useI18n } from '../i18n'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'skills', icon: Wrench },
{ id: 'system', icon: Monitor },
]
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [activePanel, setActivePanel] = useState('profile')
const [config, setConfig] = useState(null)
const [providers, setProviders] = 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 [editProvider, setEditProvider] = useState(null)
const [profileForm, setProfileForm] = useState({})
const [providerForm, setProviderForm] = useState({}) // keyed by provider name
const [toast, setToast] = useState(null)
const loadData = useCallback(() => {
api.getConfig().then(d => {
setConfig(d)
setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
}).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
}, [api])
useEffect(() => { loadData() }, [loadData])
const showToast = (msg) => {
setToast(msg)
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 = (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
}
const handleUpdateAll = () => {
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
}
const handleSaveProfile = async () => {
try {
await api.saveProfile(profileForm)
setEditProfile(false)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleSaveProvider = async (name) => {
const form = providerForm[name]
if (!form) return
try {
await api.saveProvider({ name, ...form })
setEditProvider(null)
loadData()
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const openProviderEdit = (p) => {
setProviderForm(prev => ({
...prev,
[p.name]: {
name: p.name,
api_key: p.apiKey || '',
model: p.model || '',
base_url: p.baseURL || '',
},
}))
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 (
<div className="config-window">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-tabs-bar">
{PANELS.map(p => {
const Icon = p.icon
return (
<div
key={p.id}
className={`nav-tab ${activePanel === p.id ? 'active' : ''}`}
onClick={() => setActivePanel(p.id)}
>
<span className="tab-icon"><Icon size={15} /></span>
{t(`config.panels.${p.id}`)}
</div>
)
})}
</div>
<div className="config-panel-area">
<div className="config-panel-body">
{activePanel === 'profile' && (
<PanelProfile
config={config} editProfile={editProfile}
profileForm={profileForm} setProfileForm={setProfileForm}
setEditProfile={setEditProfile} handleSaveProfile={handleSaveProfile}
t={t}
/>
)}
{activePanel === 'providers' && (
<PanelProviders
providers={providers} editProvider={editProvider}
providerForm={providerForm} setProviderForm={setProviderForm}
setEditProvider={setEditProvider} openProviderEdit={openProviderEdit}
handleSaveProvider={handleSaveProvider} api={api} loadData={loadData}
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 === 'skills' && (
<PanelSkills skillList={skillList} t={t} />
)}
{activePanel === 'system' && (
<PanelSystem api={api} t={t} />
)}
</div>
</div>
</div>
)
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
const updateField = (path, value) => {
setProfileForm(prev => {
const next = JSON.parse(JSON.stringify(prev))
const keys = path.split('.')
let target = next
for (let i = 0; i < keys.length - 1; i++) {
if (target[keys[i]] == null) target[keys[i]] = {}
target = target[keys[i]]
}
target[keys[keys.length - 1]] = value
return next
})
}
const profile = editProfile ? profileForm : config?.profile
if (!profile) {
return (
<div className="config-profile-center">
<div className="config-card">
<div className="empty-state">{t('config.loadingProfile')}</div>
</div>
</div>
)
}
const personalKeys = Object.entries(profile).filter(([k, v]) => k !== 'preferences' && typeof v !== 'object')
const personalObj = Object.fromEntries(personalKeys)
const preferences = profile.preferences || null
return (
<div className="config-profile-center">
<div className="config-card">
<div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
<RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
</div>
<div className="config-card">
<div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
{preferences ? (
<RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
) : (
<div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}></span></div>
)}
</div>
<div className="config-card">
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
{editProfile ? (
<>
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</>
) : (
<button className="primary sm" onClick={() => {
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
setEditProfile(true)
}}>{t('config.editProfile')}</button>
)}
</div>
</div>
</div>
)
}
function RenderFields({ obj, path, editing, onChange, t }) {
if (!obj || typeof obj !== 'object') return null
return Object.entries(obj).filter(([, v]) => v === null || typeof v !== 'object').map(([key, value]) => {
const fieldPath = path ? `${path}.${key}` : key
const label = getFieldLabel(key, t)
if (editing) {
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
</label>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
</div>
)
}
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
</div>
)
}
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
</div>
)
}
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
</div>
)
})
}
function getFieldLabel(key, t) {
const translated = t(`config.${key}`)
if (translated !== `config.${key}`) return translated
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [keyStatus, setKeyStatus] = useState({})
useEffect(() => {
providers.forEach(p => {
if (p.apiKey && !keyStatus[p.name]) {
validateKey(p)
} else if (!p.apiKey) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
}
})
}, [providers])
const validateKey = async (p) => {
setValidating(p.name)
try {
await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' })
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
} catch (err) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
}
setValidating(null)
}
const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name)
try {
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
} catch (err) {
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
}
setValidating(null)
}
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
return (
<div className="config-providers-list">
{displayed.map((p, i) => {
const isEditing = editProvider === p.name
const currentModel = providerForm[p.name]?.model || p.model
const status = keyStatus[p.name]
return (
<div key={i} className="config-card provider-card-v2">
<div className="provider-card-top">
<div className="provider-card-identity">
<span className="provider-card-name">{p.name.toUpperCase()}</span>
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
{status?.checked && status?.valid && <span className="badge ok"> {t('config.keyValid')}</span>}
{status?.checked && !status?.valid && <span className="badge error"> {status.error || t('config.keyInvalid')}</span>}
</div>
</div>
<div className="provider-card-form">
<div className="provider-setup-token-row">
<div className="provider-setup-token-input">
<label className="config-form-label">{t('config.apiKey')}</label>
<input
className="config-form-input"
type="password"
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => {
if (!isEditing) openProviderEdit(p)
setProviderForm(prev => ({
...prev,
[p.name]: { ...(prev[p.name] || {}), api_key: e.target.value },
}))
}}
/>
</div>
<div className="provider-setup-token-actions">
<button
className="sm primary"
disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
{isEditing && (
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)}
</div>
</div>
<div className="provider-card-model">
<span className="provider-card-model-label">{t('config.model')}</span>
<span className="provider-card-model-value">{p.model || '—'}</span>
</div>
</div>
</div>
)
})}
</div>
)
}
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
const handleInstallTool = (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
}
const missingTools = tools.filter(t => !t.installed)
return (
<>
<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>
{missingTools.length > 0 && (
<>
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
<div className="config-update-list">
{missingTools.map((tool, i) => (
<div key={`miss-${i}`} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{tool.name}</span>
<span className="config-update-versions">
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
</span>
</div>
<button
className="sm primary"
onClick={() => handleInstallTool(tool.name)}
>
{t('config.install') || 'Installer'}
</button>
</div>
))}
</div>
</>
)}
{updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div>
</div>
) : (
<div className="config-update-list">
{updates.map((u, i) => (
<div key={i} className="config-update-row">
<div className="config-update-info">
<span className="config-update-name">{u.tool}</span>
<span className="config-update-versions">
{u.needsUpdate ? (
<>{u.current} <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
) : (
<span style={{ color: 'var(--success)' }}>{u.current}</span>
)}
</span>
</div>
{u.needsUpdate && (
<button
className="sm"
onClick={() => handleUpdateTool(u.tool)}
disabled={updating === u.tool}
>
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
</button>
)}
</div>
))}
</div>
)}
</>
)
}
function PanelSkills({ skillList, t }) {
const [selected, setSelected] = useState(null)
if (skillList.length === 0) {
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
}
return (
<>
<div className="skill-tiles">
{skillList.map((s, i) => (
<div key={i} className="skill-tile" onClick={() => setSelected(s)}>
<div className="skill-tile-name">{s.name}</div>
<div className="skill-tile-desc">{s.description}</div>
<div className="skill-tile-tags">
{s.target && <span className="badge neutral">{s.target}</span>}
{s.version && <span className="badge">{s.version}</span>}
{s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
</div>
</div>
))}
</div>
{selected && (
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
<div className="skill-detail-header">
<span className="skill-detail-name">{selected.name}</span>
<button className="ghost sm" onClick={() => setSelected(null)}></button>
</div>
<div className="skill-detail-body">
<div className="skill-detail-section">
<div className="skill-detail-label">Description</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
</div>
<div className="skill-detail-section">
<div className="skill-detail-label">Métadonnées</div>
<div className="skill-detail-meta">
{selected.target && <span className="badge neutral">{selected.target}</span>}
{selected.version && <span className="badge">{selected.version}</span>}
{selected.category && <span className="badge">{selected.category}</span>}
{selected.author && <span className="badge ghost">{selected.author}</span>}
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
</div>
</div>
{selected.tags && selected.tags.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Tags</div>
<div className="chip-row">
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
</div>
</div>
)}
{selected.content && (
<div className="skill-detail-section">
<div className="skill-detail-label">Contenu</div>
<div className="skill-detail-content">{selected.content}</div>
</div>
)}
{selected.dependencies && selected.dependencies.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Dépendances</div>
<div className="skill-detail-deps">
{selected.dependencies.map((d, i) => (
<div key={i} className="skill-detail-dep">
<span className="badge">{d.type}</span>
<span>{d.name}</span>
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</>
)
}
function PanelSystem({ api, t }) {
const [showResetModal, setShowResetModal] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const handleReset = async () => {
try {
await api.resetConfig()
setShowResetModal(false)
showToast(t('config.resetDone'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
}
const handleApplyStarship = () => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
}
return (
<>
{toast && <div className="config-toast">{toast}</div>}
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
<div className="config-card">
<div className="config-card-row" style={{ marginBottom: 16 }}>
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
Vérifie l'installation de starship et configure le thème charm via l'IA.
</div>
<button className="sm primary" onClick={handleApplyStarship}>
{t('config.applyStarship')}
</button>
</div>
<div className="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>
)}
</>
)
}
function FormInput({ label, value, onChange, type = 'text' }) {
return (
<div className="config-form-field">
<label className="config-form-label">{label}</label>
<input
className="config-form-input"
type={type}
value={value}
onChange={e => onChange(e.target.value)}
/>
</div>
)
}

View File

@@ -0,0 +1,226 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useI18n } from '../i18n'
const MAX_POINTS = 30
const POLL_INTERVAL = 5000
const MAX_IDLE_POLLS = 3
function MiniGraph({ data, max, color, label, unit }) {
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
const m = max || Math.max(...data, 1)
const w = 100
const h = 32
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (v / m) * h
return `${x},${y}`
}).join(' ')
const last = data[data.length - 1]
return (
<div className="dash-graph-wrap">
<div className="dash-graph-header">
<span className="dash-graph-label">{label}</span>
<span className="dash-graph-value" style={{ color }}>{last.toFixed(1)}{unit}</span>
</div>
<svg viewBox={`0 0 ${w} ${h}`} className="dash-graph-svg" preserveAspectRatio="none">
<defs>
<linearGradient id={`fg-${color.replace('#','').replace('var(','').replace(')','')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<polygon fill={`url(#fg-${color.replace('#','').replace('var(','').replace(')','')})`} points={`${points} ${w},${h} 0,${h}`} />
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
</svg>
</div>
)
}
export default function Dashboard({ api, refreshRef }) {
const { t } = useI18n()
const [quota, setQuota] = useState(null)
const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null)
const [copiedIdx, setCopiedIdx] = useState(-1)
const cpuRef = useRef([])
const memRef = useRef([])
const netRxRef = useRef([])
const netTxRef = useRef([])
const loadData = useCallback(async () => {
try {
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
api.getProvidersQuota().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getSystemMetrics().catch(() => null),
])
setQuota(quotaData?.providers || [])
setRecentCmds(cmdData.commands || [])
setProcesses(procData.processes || [])
if (metricsData) {
setMetrics(metricsData)
cpuRef.current = [...cpuRef.current, metricsData.cpu_percent].slice(-MAX_POINTS)
memRef.current = [...memRef.current, metricsData.mem_percent].slice(-MAX_POINTS)
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
}
} catch (err) {
console.error('Dashboard load error:', err)
}
}, [api])
useEffect(() => {
loadData()
if (refreshRef) refreshRef.current = loadData
let active = true
let idleTicks = 0
const iv = setInterval(() => {
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
if (hidden) {
idleTicks++
if (idleTicks >= MAX_IDLE_POLLS) return
} else {
idleTicks = 0
}
if (active) loadData()
}, POLL_INTERVAL)
return () => { active = false; clearInterval(iv) }
}, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', '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 }))
})()
return (
<div className="dash-grid">
{/* CPU */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">CPU</span>
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
</div>
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
</div>
{/* RAM */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">RAM</span>
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div>
{/* Network */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Network</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div>
{/* API Quota */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">API Quota</span>
</div>
<div className="dash-quota-list">
{minimax && minimax.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
))}
{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 && zai.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model)}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
))}
{zai && !zai.data?.models?.length && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{zai.error || 'no data'}</span>
</div>
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div>
</div>
{/* Running Processes */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Processes</span>
<span className="dash-count">{processes.length}</span>
</div>
<div className="dash-proc-list">
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
{processes.map((p, i) => (
<div key={i} className="dash-proc-row">
<span className="dash-proc-name">{p.name}</span>
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
</div>
))}
</div>
</div>
{/* Recent Commands */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Recent Commands</span>
</div>
{topCmds.length > 0 && (
<div className="dash-cmd-top">
{topCmds.map((c, i) => (
<div key={i} className={'dash-cmd-chip' + (copiedIdx === i ? ' dash-cmd-chip-copied' : '')} onClick={() => { navigator.clipboard.writeText(c.cmd); setCopiedIdx(i); setTimeout(() => setCopiedIdx(-1), 1200); }}>
<span className="dash-cmd-chip-name">{copiedIdx === i ? '✓ Copié' : c.cmd}</span>
<span className="dash-cmd-chip-count">{c.count}×</span>
</div>
))}
</div>
)}
<div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}>
<span className="dash-cmd-shell">{c.shell}</span>
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,458 @@
import { useState, useEffect, useRef } from 'react'
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const STEPS = [
{ key: 'welcome', title: 'welcome' },
{ key: 'name', title: 'name' },
{ key: 'language', title: 'language' },
{ key: 'keyboard', title: 'keyboard' },
{ key: 'apikey', title: 'apikey' },
{ key: 'editor', title: 'editor' },
{ key: 'done', title: 'done' },
]
const BASE_EDITORS = ['vim', 'nvim', 'vscode', 'emacs', 'nano', 'helix']
export default function OnboardingWizard({ api, onComplete }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [step, setStep] = useState(0)
const [answers, setAnswers] = useState({
name: '',
language: 'fr',
keyboard: 'azerty',
apikey: '',
editor: '',
})
const [editorList, setEditorList] = useState(BASE_EDITORS)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [requiredError, setRequiredError] = useState(false)
const [validating, setValidating] = useState(false)
const [keyValid, setKeyValid] = useState(false)
const [scanning, setScanning] = useState(false)
const [scanMessage, setScanMessage] = useState('')
const scanAbortRef = useRef(null)
const current = STEPS[step]
const layouts = getLayoutList()
const goNext = () => {
if (step < STEPS.length - 1) {
if (!canProceed) { setRequiredError(true); return }
setRequiredError(false)
setStep(step + 1)
}
}
const canProceed = (() => {
switch (current.key) {
case 'welcome': return true
case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard
case 'apikey': return keyValid && !scanning
case 'editor': return true
case 'done': return true
default: return true
}
})()
const goPrev = () => {
if (step > 0) setStep(step - 1)
}
const cycleOption = (key, list, dir) => {
const idx = list.findIndex(item => item.id === answers[key])
const next = (idx + dir + list.length) % list.length
setAnswers(a => ({ ...a, [key]: list[next].id }))
}
const cycleOptionEditor = (dir) => {
const idx = editorList.findIndex(ed => ed === answers.editor)
const next = (idx + dir + editorList.length) % editorList.length
setAnswers(a => ({ ...a, editor: editorList[next] }))
}
const handleScanViaChat = async (apikey) => {
setScanning(true)
setScanMessage('Recherche des éditeurs sur votre système...')
setError(null)
try {
const detected = []
const fallback = async () => {
setScanMessage('Utilisation du scan local...')
const data = await api.getEditors()
return (data.editors || []).map(e => e.name)
}
const prompt = 'Liste tous les éditeurs de texte et IDE installés sur ce système. Exécute les commandes nécessaires pour les détecter (which, command -v, etc.). Réponds UNIQUEMENT avec les noms séparés par des virgules, sans aucune autre explication. Exemples: vim, nvim, code, emacs, nano, helix, subl, zed'
const ctrl = new AbortController()
scanAbortRef.current = ctrl
const full = await api.sendChat(prompt, true, (text, data) => {
if (data.tool_call) setScanMessage('Exécution: ' + (data.tool_call.name || '...'))
else if (data.tool_result) setScanMessage('Analyse des résultats...')
else if (data.content) setScanMessage('Réception: ' + text.slice(0, 60) + (text.length > 60 ? '...' : ''))
}, ctrl.signal)
const names = full.split(/[,\n]/).map(s => s.replace(/[^a-zA-Z0-9._-]/g, '')).filter(Boolean)
if (names.length > 0) {
detected.push(...names)
} else {
detected.push(...(await fallback()))
}
setEditorList([...new Set(detected.map(n => n.toLowerCase()))])
setScanMessage('')
} catch (err) {
try {
setScanMessage('Fallback: scan local...')
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
setEditorList([...new Set(detected)])
} catch {}
setScanMessage('')
}
setScanning(false)
}
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return }
if (current.key === 'language') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('language', LANGUAGES, 1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('language', LANGUAGES, -1); return }
}
if (current.key === 'keyboard') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('keyboard', layouts, 1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('keyboard', layouts, -1); return }
}
if (current.key === 'editor') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOptionEditor(1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOptionEditor(-1); return }
}
if (e.key === 'Tab') { e.preventDefault(); const input = document.querySelector('.onboarding-input'); if (input) input.focus(); return }
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [step, current, answers, editorList])
useEffect(() => {
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
}, [])
useEffect(() => {
if (current.key === 'done' && !saving) {
handleSave()
}
}, [step])
const handleValidateKey = async () => {
if (!answers.apikey.trim()) return
setValidating(true)
setError(null)
try {
await api.validateProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
})
setKeyValid(true)
await api.saveProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
active: true,
})
handleScanViaChat(answers.apikey)
} catch (err) {
setError(err.message || 'Clé invalide')
setKeyValid(false)
}
setValidating(false)
}
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const profile = {
name: answers.name,
pseudo: answers.name.split(' ')[0] || 'user',
editor: answers.editor,
}
if (answers.apikey.trim()) {
profile.apikey = answers.apikey
}
await api.saveProfile(profile)
await api.savePreferences({
language: answers.language,
keyboard_layout: answers.keyboard,
})
if (answers.apikey.trim()) {
await api.saveProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
active: true,
})
}
onComplete()
} catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde')
setSaving(false)
}
}
return (
<div className="onboarding-overlay">
<div className="onboarding-card">
<div className="onboarding-header">
<Sparkles size={20} style={{ color: 'var(--accent)' }} />
<span> Muyue Setup</span>
</div>
<div className="onboarding-progress">
{STEPS.filter(s => s.key !== 'done').map(s => {
const i = STEPS.indexOf(s)
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
})}
</div>
<div className="onboarding-body">
{current.key === 'welcome' && (
<div className="onboarding-step">
<div className="onboarding-title">Bienvenue ! 👋</div>
<div className="onboarding-desc">
Je suis votre assistant de configuration. Quelques questions rapides pour personnaliser votre expérience.
</div>
</div>
)}
{current.key === 'name' && (
<div className="onboarding-step">
<div className="onboarding-title">Comment vous appelez-vous ?</div>
<input
className="onboarding-input"
placeholder="Votre nom..."
value={answers.name}
onChange={e => { setAnswers(a => ({ ...a, name: e.target.value })); setRequiredError(false) }}
autoFocus
/>
{requiredError && <div className="onboarding-required">Veuillez entrer votre nom</div>}
</div>
)}
{current.key === 'language' && (
<div className="onboarding-step">
<div className="onboarding-title">Quelle langue préférez-vous ?</div>
<div className="onboarding-chips">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${answers.language === lang.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, language: lang.id }))}
>
{lang.name}
</div>
))}
</div>
</div>
)}
{current.key === 'keyboard' && (
<div className="onboarding-step">
<div className="onboarding-title">Disposition du clavier ?</div>
<div className="onboarding-chips">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${answers.keyboard === l.id ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, keyboard: l.id }))}
>
{l.name}
</div>
))}
</div>
</div>
)}
{current.key === 'apikey' && (
<div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div>
<div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
</div>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx"
type="password"
value={answers.apikey}
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
autoFocus
/>
{error && !keyValid && <div className="onboarding-required">{error}</div>}
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
{scanning && (
<div className="onboarding-scanning">
<Loader size={14} className="spin-icon" />
<span>{scanMessage}</span>
</div>
)}
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
className="sm primary"
onClick={handleValidateKey}
disabled={validating || !answers.apikey.trim()}
>
{validating ? 'Validation...' : 'Valider la clé'}
</button>
</div>
{!keyValid && !error && answers.apikey.trim() && (
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
)}
</div>
)}
{current.key === 'editor' && (
<div className="onboarding-step">
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
<div className="onboarding-desc">
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
</div>
<div className="onboarding-chips">
{editorList.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
</div>
)}
{current.key === 'done' && (
<div className="onboarding-step">
{saving ? (
<>
<div className="onboarding-title">Configuration en cours...</div>
<div className="onboarding-desc">Sauvegarde de vos préférences.</div>
</>
) : error ? (
<>
<div className="onboarding-title" style={{ color: 'var(--error)' }}>Erreur</div>
<div className="onboarding-desc" style={{ color: 'var(--error)' }}>{error}</div>
<button className="primary" style={{ alignSelf: 'flex-start', marginTop: 8 }} onClick={() => handleSave()}>Réessayer</button>
</>
) : (
<>
<div className="onboarding-title">C'est parti ! 🚀</div>
<div className="onboarding-desc">
Votre profil est configuré. Vous pouvez toujours ajuster les paramètres dans l'onglet Configuration.
</div>
</>
)}
</div>
)}
</div>
<div className="onboarding-footer">
{step > 0 && step < STEPS.length - 1 && (
<button className="ghost" onClick={goPrev}>
<ArrowLeft size={14} /> Précédent
</button>
)}
<div style={{ flex: 1 }} />
{step < STEPS.length - 1 && (
<button className="primary" onClick={goNext}>
Suivant <ArrowRight size={14} />
</button>
)}
{step === STEPS.length - 1 && !saving && !error && (
<button className="primary" onClick={handleSave}>
Commencer
</button>
)}
</div>
</div>
<style>{`
.onboarding-overlay {
position: fixed; inset: 0; z-index: 500;
background: rgba(10,10,12,0.85);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(8px);
}
.onboarding-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 480px; max-width: 90vw;
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
overflow: hidden;
}
.onboarding-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; font-size: 14px; font-weight: 700;
color: var(--accent); border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-progress {
display: flex; gap: 6px; padding: 14px 20px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.onboarding-dot {
width: 32px; height: 4px; border-radius: 2px;
background: var(--bg-input); transition: all 0.3s;
}
.onboarding-dot.active { background: var(--accent); }
.onboarding-dot.done { background: var(--accent-dim); }
.onboarding-body { padding: 28px 24px; min-height: 200px; }
.onboarding-step { display: flex; flex-direction: column; gap: 16px; }
.onboarding-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.onboarding-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; }
.onboarding-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary);
font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
}
.onboarding-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--border-accent); }
.onboarding-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.onboarding-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border);
background: var(--bg-surface);
}
.onboarding-required {
font-size: 12px; color: var(--error); margin-top: 4px;
}
.onboarding-valid {
font-size: 12px; color: var(--success); margin-top: 4px;
}
.onboarding-hint {
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
}
.onboarding-scanning {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--accent); margin-top: 4px;
}
.spin-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,973 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye } from 'lucide-react'
import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
const MAX_TABS = 7
const SHELL_MAX_TOKENS = 100000
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
function renderContent(text) {
const parts = []
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
}
const full = match[1]
const firstNewline = full.indexOf('\n')
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
parts.push({ type: 'code', lang, content: code })
lastIndex = match.index + full.length
}
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex)
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
if (openBlock) {
if (openBlock.index > 0) {
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
}
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
} else {
parts.push({ type: 'text', content: remaining })
}
}
return parts
}
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>')
html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
return html
}
const THEMES = {
default: {
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740',
blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2',
brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE',
brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB',
brightCyan: '#84FFFF', brightWhite: '#FFFFFF',
},
monokai: {
background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0',
cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74',
blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2',
brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E',
brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF',
brightCyan: '#A1EFE4', brightWhite: '#F8F8F2',
},
gruvbox: {
background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934',
cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff',
black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921',
blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2',
brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26',
brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B',
brightCyan: '#8EC07C', brightWhite: '#EBDBB2',
},
nord: {
background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9',
cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff',
black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B',
blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9',
brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C',
brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD',
brightCyan: '#8FBCBB', brightWhite: '#ECEFF4',
},
'solarized-dark': {
background: '#002B36', foreground: '#839496', cursor: '#D33682',
cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff',
black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900',
blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3',
brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75',
brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4',
brightCyan: '#93A1A1', brightWhite: '#FDF6E3',
},
dracula: {
background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2',
cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff',
black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C',
blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2',
brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94',
brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF',
brightCyan: '#A4FFFF', brightWhite: '#FFFFFF',
},
}
function getTheme(themeName) {
return THEMES[themeName] || THEMES.default
}
function createTerminal(container, settings = {}) {
const theme = getTheme(settings.theme || 'default')
const term = new XTerm({
cursorBlink: true,
fontSize: settings.fontSize || 12,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme,
allowTransparency: false,
scrollback: 5000,
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.open(container)
fitAddon.fit()
return { term, fitAddon }
}
function connectWebSocket(term, fitAddon, initPayload) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
ws.addEventListener('open', () => {
ws.send(JSON.stringify(initPayload))
const dims = fitAddon.proposeDimensions()
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
}
})
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`)
}
} catch {
term.write(event.data)
}
})
ws.addEventListener('close', () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
})
ws.addEventListener('error', () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
})
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
}
})
return ws
}
export default function Shell({ api }) {
const { t } = useI18n()
const tabsRef = useRef({})
const nextIdRef = useRef(1)
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
const savedTabs = (() => {
try {
const raw = localStorage.getItem(TABS_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map(t => ({ ...t, connected: false }))
}
}
} catch {}
return null
})()
const [tabs, setTabs] = useState(savedTabs || [
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
])
const [activeTab, setActiveTab] = useState(() => {
if (savedTabs) {
return savedTabs[0]?.id || 1
}
return 1
})
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false)
const [showSshModal, setShowSshModal] = useState(false)
const [editingTab, setEditingTab] = useState(null)
const [editName, setEditName] = useState('')
const [terminalSettings, setTerminalSettings] = useState({
fontSize: 12,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: 'default',
})
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
const [sshForm, setSshForm] = useState({
name: '', host: '', port: 22, user: '', key_path: '',
})
const [aiMessages, setAiMessages] = useState([])
const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false)
const [aiTokens, setAiTokens] = useState(0)
const [aiAtLimit, setAiAtLimit] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const [showAnalysis, setShowAnalysis] = useState(false)
const [analysisContent, setAnalysisContent] = useState('')
const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages])
useEffect(() => {
api.getShellAnalysis?.().then(d => {
if (d?.analysis) setAnalysisContent(d.analysis)
}).catch(() => {
const stored = localStorage.getItem('shell_analysis')
if (stored) setAnalysisContent(stored)
})
}, [])
useEffect(() => {
if (aiLoadedRef.current) return
aiLoadedRef.current = true
api.getShellChatHistory().then(d => {
if (d.messages && d.messages.length > 0) {
setAiMessages(d.messages)
} else {
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Système Analyste prêt. Tapez /help pour les commandes.' }])
}
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {
setAiMessages([{ role: 'assistant', content: 'Système Analyste prêt.' }])
})
}, [])
useEffect(() => {
const maxId = tabs.reduce((max, t) => Math.max(max, t.id), 0)
nextIdRef.current = maxId + 1
}, [])
useEffect(() => {
api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || [])
setSystemTerminals(d.system || [])
}).catch(() => {})
api.getConfig().then(d => {
if (d.terminal) {
setTerminalSettings({
fontSize: d.terminal.font_size || 12,
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: d.terminal.theme || 'default',
})
}
}).catch(() => {})
}, [])
const initTerminal = useCallback((tabId, tab) => {
if (tabsRef.current[tabId]) return
const container = document.getElementById(`terminal-${tabId}`)
if (!container) return
const s = settingsRef.current
const { term, fitAddon } = createTerminal(container, {
fontSize: s.fontSize,
fontFamily: s.fontFamily,
theme: s.theme,
})
let initPayload
if (tab.type === 'ssh') {
initPayload = {
type: 'ssh',
data: JSON.stringify({
host: tab.host,
port: tab.port || 22,
user: tab.user || 'root',
key_path: tab.key_path || '',
}),
}
} else {
initPayload = {
type: 'shell',
data: tab.shell || '',
}
}
const ws = connectWebSocket(term, fitAddon, initPayload)
// Restore saved terminal buffer after first output settles
const restoreBuffer = () => {
try {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
if (savedBuffers[tabId]) {
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
term.write(savedBuffers[tabId])
}
} catch {}
}
setTimeout(restoreBuffer, 300)
const saveBuffer = () => {
try {
const buf = term.buffer.active
const lines = []
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i)
if (line) lines.push(line.translateToString(true))
}
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
savedBuffers[tabId] = lines.join('\n')
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {}
}
const bufferSaveInterval = setInterval(saveBuffer, 5000)
// Detect clear command to wipe saved buffer
// We read the current line from the terminal buffer on Enter
// instead of trying to reconstruct from keystrokes (unreliable with history, ANSI, etc.)
const clearBufferOnClear = () => {
try {
const buf = term.buffer.active
const lineY = buf.baseY + buf.cursorY
const line = buf.getLine(lineY)
if (line) {
const text = line.translateToString(true).trim().toLowerCase()
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
}
}
} catch {}
}
// Hook into onData to detect Enter for clear detection
// The connectWebSocket already registered its own onData for WS forwarding,
// this one is purely for clear detection
term.onData((data) => {
if (data === '\r') {
clearBufferOnClear()
}
})
ws.onopen = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
}
ws.onclose = () => {
saveBuffer()
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
ws.onerror = () => {
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
}
const onResize = () => {
const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) {
fitAddon.fit()
}
}
const resizeObserver = new ResizeObserver(onResize)
resizeObserver.observe(container)
window.addEventListener('resize', onResize)
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer }
}, [])
useEffect(() => {
const tab = tabs.find(t => t.id === activeTab)
if (!tab) return
const tryInit = (attempt) => {
if (attempt > 20) return
const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) {
setTimeout(() => tryInit(attempt + 1), 150)
return
}
const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) {
setTimeout(() => tryInit(attempt + 1), 100)
return
}
if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab)
}
requestAnimationFrame(() => {
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}
tryInit(0)
}, [activeTab, tabs, initTerminal])
useEffect(() => {
const iv = setInterval(() => {
for (const tab of tabs) {
const entry = tabsRef.current[tab.id]
if (entry) {
const el = document.getElementById(`terminal-${tab.id}`)
if (el && el.offsetParent !== null) {
entry.fitAddon.fit()
}
}
}
}, 2000)
return () => clearInterval(iv)
}, [tabs])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
if (e.key === 'Tab' && e.shiftKey) {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
const idx = tabs.findIndex(t => t.id === activeTab)
const next = (idx + 1) % tabs.length
setActiveTab(tabs[next].id)
return
}
const num = parseInt(e.key)
if (num >= 1 && num <= tabs.length) {
e.preventDefault()
setActiveTab(tabs[num - 1].id)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [tabs])
const addLocalTab = (shell, name) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false }
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
setActiveTab(id)
setShowMenu(false)
}
const addSSHTab = (conn) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
const newTab = {
id,
name: conn.name || `${conn.user}@${conn.host}`,
type: 'ssh',
host: conn.host,
port: conn.port || 22,
user: conn.user || 'root',
key_path: conn.key_path || '',
connected: false,
}
setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next })
setActiveTab(id)
setShowMenu(false)
}
const closeTab = (tabId, e) => {
if (e) e.stopPropagation()
setTabs(prev => {
if (prev.length <= 1) return prev
const tab = prev.find(t => t.id === tabId)
if (!tab) return prev
const entry = tabsRef.current[tabId]
if (entry) {
entry.saveBuffer?.()
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
window.removeEventListener('resize', entry.onResize)
entry.resizeObserver.disconnect()
entry.ws.close()
entry.term.dispose()
delete tabsRef.current[tabId]
}
// Clean buffer from localStorage
try {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {}
const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id)
}
return next
})
}
const startRename = (tabId, e) => {
if (e) e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
setEditingTab(tabId)
setEditName(tab.name)
}
const finishRename = () => {
if (editName.trim() && editingTab) {
setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t))
}
setEditingTab(null)
setEditName('')
}
const saveSSHConnection = async () => {
if (!sshForm.name.trim() || !sshForm.host.trim()) return
try {
await api.addSSHConnection(sshForm)
setSshConnections(prev => [...prev, { ...sshForm }])
setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' })
setShowSshModal(false)
} catch (err) {
console.error(err)
}
}
const deleteSSHConnection = async (name) => {
try {
await api.deleteSSHConnection(name)
setSshConnections(prev => prev.filter(c => c.name !== name))
} catch (err) {
console.error(err)
}
}
const sendToTerminal = useCallback((code) => {
const entry = tabsRef.current[activeTab]
if (!entry) {
console.warn('sendToTerminal: no terminal initialized for tab', activeTab)
return
}
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
console.warn('sendToTerminal: WebSocket not ready for tab', activeTab)
return
}
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}, [activeTab])
const focusAiTerminal = useCallback(() => {
const entry = tabsRef.current[activeTab]
if (entry) entry.term.focus()
}, [activeTab])
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
if (!text || !text.trim() || aiLoading || aiAtLimit) return
const trimmed = text.trim()
if (!fromEvent) {
setAiInput('')
focusAiTerminal()
}
if (trimmed === '/clear') {
try {
await api.clearShellChat()
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
setAiTokens(0)
setAiAtLimit(false)
} catch {}
return
}
if (trimmed === '/help') {
setAiMessages(prev => [...prev,
{ role: 'user', content: trimmed },
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
])
return
}
setAiMessages(prev => [...prev, { role: 'user', content: trimmed }])
setAiLoading(true)
try {
let accumulated = ''
await api.sendShellChat(trimmed, {}, true, (partial) => {
accumulated = partial
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
})
})
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated }]
})
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {})
} catch (err) {
if (err.message.includes('context limit')) {
setAiAtLimit(true)
}
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
}
setAiLoading(false)
}, [api, t, aiLoading, aiAtLimit, focusAiTerminal])
const handleAiSend = () => _sendAiMessage(aiInput, false)
useEffect(() => {
const handler = (e) => {
const msg = e.detail?.message
if (!msg) return
setAiInput(msg)
setTimeout(() => _sendAiMessage(msg, true), 100)
}
window.addEventListener('ask-ai-terminal', handler)
return () => window.removeEventListener('ask-ai-terminal', handler)
}, [_sendAiMessage])
const handleAnalyze = async () => {
setAnalyzing(true)
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
try {
const d = await api.analyzeSystem()
if (d.analysis) {
setAnalysisContent(d.analysis)
localStorage.setItem('shell_analysis', d.analysis)
}
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
role: 'system',
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
}])
} catch (err) {
setAiMessages(prev => prev.filter(m => m.content !== 'Analyse du système en cours...'))
}
setAnalyzing(false)
}
return (
<div className="shell-layout">
<div className="shell-terminal-col">
<div className="shell-tabs-bar">
<div className="shell-tabs">
{tabs.map((tab, i) => (
<div
key={tab.id}
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
onDoubleClick={(e) => startRename(tab.id, e)}
>
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
{tab.type === 'ssh' && <Globe size={12} />}
{tab.type === 'local' && <Monitor size={12} />}
{editingTab === tab.id ? (
<input
className="shell-tab-rename"
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={finishRename}
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
autoFocus
onClick={e => e.stopPropagation()}
/>
) : (
<span className="shell-tab-name">{tab.name}</span>
)}
<span className="shell-tab-index">{i + 1}</span>
{tabs.length > 1 && (
<button
className="shell-tab-close"
onClick={(e) => closeTab(tab.id, e)}
title={t('shell.closeTab')}
>
<X size={12} />
</button>
)}
</div>
))}
</div>
<div className="shell-tab-actions">
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
<Plus size={16} />
<ChevronDown size={12} />
</button>
{showMenu && (
<>
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
<div className="shell-new-tab-menu">
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
{systemTerminals.map(st => (
<button
key={st.name}
className="shell-menu-item"
onClick={() => addLocalTab(st.shell, st.name)}
>
<Monitor size={14} />
<span>{st.name}</span>
<span className="shell-menu-item-sub">{st.shell}</span>
</button>
))}
<div className="shell-menu-divider" />
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
{sshConnections.length === 0 && (
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
)}
{sshConnections.map(conn => (
<div key={conn.name} className="shell-menu-item-row">
<button
className="shell-menu-item"
onClick={() => addSSHTab(conn)}
>
<Globe size={14} />
<span>{conn.name}</span>
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
</button>
<button
className="shell-menu-item-icon"
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
title={t('shell.deleteConnection')}
>
<Trash2 size={12} />
</button>
</div>
))}
<div className="shell-menu-divider" />
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
<Plus size={14} />
<span>{t('shell.addConnection')}</span>
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
<div className="shell-xterm-wrapper">
{tabs.map(tab => (
<div
key={tab.id}
id={`terminal-${tab.id}`}
className="shell-xterm-instance"
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
/>
))}
</div>
</div>
<div className="shell-ai-col">
<div className="ai-panel-header">
<span>Analyste Système</span>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="shell-analyze-btn"
onClick={() => setShowAnalysis(true)}
disabled={!analysisContent}
title="Voir l'analyse"
>
<Eye size={13} />
Analyse
</button>
<button
className="shell-analyze-btn"
onClick={handleAnalyze}
disabled={analyzing}
title="Analyser le système"
>
<Search size={13} />
{analyzing ? '...' : 'Analyser'}
</button>
</div>
</div>
<div className="shell-ai-token-bar">
<div className="shell-ai-token-track">
<div
className={`shell-ai-token-fill ${aiTokens >= SHELL_MAX_TOKENS * 0.8 ? 'warn' : ''}`}
style={{ width: `${Math.min(100, (aiTokens / SHELL_MAX_TOKENS) * 100)}%` }}
/>
</div>
<span className="shell-ai-token-text">{Math.round(aiTokens / 1000)}k/{Math.round(SHELL_MAX_TOKENS / 1000)}k</span>
</div>
<div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => (
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>
<div className="ai-panel-input">
<input
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
disabled={aiAtLimit && aiInput !== '/clear'}
/>
<button className="sm" onClick={handleAiSend} disabled={(!aiInput.trim() && !aiAtLimit) || (aiAtLimit && aiInput !== '/clear')}>{t('shell.send')}</button>
</div>
</div>
{showAnalysis && analysisContent && (
<div className="shell-modal-overlay" onClick={() => setShowAnalysis(false)}>
<div className="shell-analysis-modal" onClick={e => e.stopPropagation()}>
<div className="shell-analysis-modal-header">
<span>Analyse Système</span>
<button className="shell-tab-close" onClick={() => setShowAnalysis(false)}><X size={16} /></button>
</div>
<div className="shell-analysis-modal-body">
{renderContent(analysisContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
</div>
</div>
)}
{showSshModal && (
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
<div className="shell-modal" onClick={e => e.stopPropagation()}>
<div className="shell-modal-header">{t('shell.addConnection')}</div>
<div className="shell-modal-body">
<label className="shell-modal-label">{t('shell.connectionName')}</label>
<input
value={sshForm.name}
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
placeholder="prod-server"
/>
<label className="shell-modal-label">{t('shell.host')}</label>
<input
value={sshForm.host}
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
placeholder="192.168.1.100"
/>
<div className="shell-modal-row">
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.port')}</label>
<input
type="number"
value={sshForm.port}
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
/>
</div>
<div className="shell-modal-field">
<label className="shell-modal-label">{t('shell.user')}</label>
<input
value={sshForm.user}
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
placeholder="root"
/>
</div>
</div>
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
<input
value={sshForm.key_path}
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
placeholder="~/.ssh/id_rsa"
/>
</div>
<div className="shell-modal-footer">
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
</div>
</div>
</div>
)}
</div>
)
}
function ShellAIMessage({ msg, sendToTerminal }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const content = msg.content || ''
if (role === 'user') {
return <div className={`ai-message user`}>{content}</div>
}
if (role === 'system') {
return <div className={`ai-message system`}>{content}</div>
}
const parts = renderContent(content)
return (
<div className={`ai-message assistant`}>
{parts.map((part, i) => {
if (part.type === 'code') {
return (
<div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
<div className="shell-code-actions">
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
<Copy size={12} /> Copier
</button>
<button onClick={() => sendToTerminal(part.content)} title="Envoyer au terminal">
<Send size={12} /> Terminal
</button>
</div>
</div>
)
}
return <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
})}
</div>
)
}

View File

@@ -0,0 +1,745 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n'
const RANKS = {
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
general: { label: 'General', short: 'GEN', color: '#FF9100' },
colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' },
lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' },
soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' },
}
function getRank(role) {
if (role === 'user') return RANKS.commandant
if (role === 'system') return null
return RANKS.general
}
function RankIcon({ rank }) {
if (rank === RANKS.commandant) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
</svg>
)
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
)
}
function renderContent(text) {
const parts = []
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) })
}
const full = match[1]
const firstNewline = full.indexOf('\n')
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : ''
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3)
parts.push({ type: 'code', lang, content: code })
lastIndex = match.index + full.length
}
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex)
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
if (openBlock) {
if (openBlock.index > 0) {
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
}
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
} else {
parts.push({ type: 'text', content: remaining })
}
}
return parts
}
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>')
html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
return html
}
function ThinkingBlock({ content, done, raw }) {
return (
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
<div className="feed-thinking-header">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
<span>Reflexion</span>
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
</div>
<div className="feed-thinking-content">
{raw ? <span dangerouslySetInnerHTML={{ __html: content }} /> : content}
</div>
</div>
)
}
const TOOL_ICONS = {
terminal: '⌨',
crush_run: '⚡',
read_file: '📄',
list_files: '📁',
search_files: '🔍',
grep_content: '🔎',
get_config: '⚙',
set_provider: '🔑',
manage_ssh: '🌐',
web_fetch: '🌐',
}
const TOOL_LABELS = {
terminal: 'Terminal',
crush_run: 'Crush Agent',
read_file: 'Read File',
list_files: 'List Files',
search_files: 'Search Files',
grep_content: 'Grep',
get_config: 'Config',
set_provider: 'Set Provider',
manage_ssh: 'SSH',
web_fetch: 'Web Fetch',
}
function ToolCallBlock({ call, result }) {
const icon = TOOL_ICONS[call.name] || '🔧'
const label = TOOL_LABELS[call.name] || call.name
const isErr = result && result.is_error
let argsPreview = ''
try {
const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args
if (args.command) argsPreview = args.command
else if (args.task) argsPreview = args.task
else if (args.path) argsPreview = args.path
else if (args.pattern) argsPreview = args.pattern
else if (args.url) argsPreview = args.url
else if (args.action) argsPreview = args.action
else argsPreview = JSON.stringify(args).slice(0, 80)
} catch {
argsPreview = String(call.args).slice(0, 80)
}
const truncatedResult = result ? (result.content || '').slice(0, 2000) : null
return (
<div className={`studio-tool-block ${isErr ? 'error' : ''} ${result ? 'done' : 'running'}`}>
<div className="studio-tool-header">
<span className="studio-tool-icon">{icon}</span>
<span className="studio-tool-name">{label}</span>
{!result && <span className="studio-tool-spinner"><span/><span/><span/></span>}
{result && <span className={`studio-tool-status ${isErr ? 'error' : 'ok'}`}>{isErr ? '✗' : '✓'}</span>}
</div>
<div className="studio-tool-args" title={argsPreview}>{argsPreview}</div>
{truncatedResult && (
<div className="studio-tool-result">
<pre>{truncatedResult}</pre>
</div>
)}
</div>
)
}
function FeedItem({ msg }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
let parsedToolCalls = null
let parsedToolResults = null
let displayContent = msg.content
try {
const parsed = JSON.parse(msg.content)
if (parsed && Array.isArray(parsed.tool_calls)) {
parsedToolCalls = parsed.tool_calls
parsedToolResults = parsed.tool_results || null
displayContent = parsed.content || ''
}
} catch {}
if (isSystem) {
return (
<div className="feed-item system">
<div className="feed-system-badge" />
<div className="feed-system-text">{msg.content}</div>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)
}
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return (
<div className={`feed-item ${msg.role}`}>
<div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
<RankIcon rank={rank} />
</div>
<div className="feed-body">
<div className="feed-header">
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
: null
const result = resultData
? { content: resultData.result, is_error: resultData.is_error }
: null
return <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
})}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
)}
</div>
</div>
)
}
function StreamingItem({ content, thinking, toolCalls }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0
const renderedContent = useMemo(() => {
if (!cleanContent) return []
return renderContent(cleanContent)
}, [cleanContent])
const formattedThinking = useMemo(() => {
if (!thinking) return ''
return formatText(thinking)
}, [thinking])
return (
<div className="feed-item assistant">
<div className="feed-avatar ai-rank">
<RankIcon rank={rank} />
</div>
<div className="feed-body">
<div className="feed-header">
<span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
</div>
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
{hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))}
{!thinking && !cleanContent && !hasToolCalls && (
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
)}
{cleanContent && (
<div className="feed-content">
{renderedContent.map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</div>
)}
</div>
</div>
)
}
export default function Studio({ api }) {
const { t } = useI18n()
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null)
const abortRef = useRef(null)
useEffect(() => {
api.getChatHistory().then(data => {
if (data.messages && data.messages.length > 0) {
setMessages(data.messages)
} else {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
}
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
setLoaded(true)
}).catch(() => {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
setLoaded(true)
})
}, [])
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
const onTab = (e) => {
if (e.key !== 'Tab') return
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
const feed = document.querySelector('.studio-feed-layout')
if (!feed?.closest('.tab-hidden')) {
e.preventDefault()
textareaRef.current?.focus()
}
}
window.addEventListener('keydown', onTab)
return () => window.removeEventListener('keydown', onTab)
}, [])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
}
}, [input])
const refreshTokens = useCallback(async () => {
try {
const data = await api.getChatHistory()
setTokenInfo({
used: data.tokens || 0,
max: data.max_tokens || 100000,
summarizeAt: data.summarize_at || 80000,
})
} catch {}
}, [api])
const handleSummarize = useCallback(async () => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
setContextCollapsed('animating')
try {
const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
setTimeout(() => {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString(), compressed: true }])
setContextCollapsed(true)
setMessagesCollapsed(true)
}, 600)
} catch (err) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
setContextCollapsed(false)
}
}, [api])
const handleClear = useCallback(async () => {
try {
await api.clearChat()
setMessages([
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
])
} catch {}
}, [api, t])
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return
const text = input.trim()
setInput('')
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t)
if (text.startsWith('/') && !isSlashCommand(text)) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }])
return
}
if (text === '/clear') {
handleClear()
return
}
if (text === '/help') {
const helpMsg = [
'## Commandes Studio',
'',
'- `/clear` - Effacer la conversation',
'- `/summarize` - Résumer la conversation précédente',
'- `/help` - Afficher cette aide',
'- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown',
'- `/model` - Afficher le provider et modèle actifs',
'- `/model change` - Basculer entre MiniMax et ZAI',
'',
'## Tools disponibles',
'- Terminal - Exécuter des commandes',
'- read_file - Lire des fichiers',
'- list_files - Lister des fichiers',
'- search_files - Rechercher des fichiers',
'- grep_content - Rechercher dans le contenu',
'- get_config - Lire la configuration',
'- web_fetch - Récupérer une page web',
].join('\n')
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
return
}
if (text === '/summarize') {
handleSummarize()
return
}
if (text === '/model' || text === '/model change') {
if (text === '/model change') {
api.getProviders().then(data => {
const providers = data.providers || []
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
const zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
if (!minimax || !zai) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
return
}
const active = providers.find(p => p.active)
const activeName = active ? active.name.toUpperCase() : ''
const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX'
const target = switchTo === 'MINIMAX' ? minimax : zai
api.saveProvider({ name: target.name, active: true }).then(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', time: new Date().toISOString() }])
})
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
})
} else {
api.getProviders().then(data => {
const active = data.providers?.find(p => p.active)
const modelMsg = active ? `**${active.name}** — ${active.model}` : 'Aucun provider actif configuré'
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
})
}
return
}
if (text.startsWith('/plan ')) {
const objective = text.slice(6).trim()
if (!objective) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Usage: `/plan <objectif>`\nEx: `/plan créer un fichier de test`', time: new Date().toISOString() }])
return
}
setInput(`Crée un plan structuré en étapes numérotées pour: ${objective}. Chaque étape devrait avoir une estimation de complexité et de temps.`)
handleSend()
return
}
if (text === '/export') {
api.getChatHistory().then(data => {
let markdown = '# Conversation Export\n\n'
data.messages?.forEach((msg, i) => {
const roleLabel = msg.role === 'user' ? '👤' : (msg.role === 'assistant' ? '🤖' : '⚙️')
markdown += `## [${i + 1}] ${roleLabel} ${msg.role}\n${msg.content}\n\n---\n\n`
})
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Conversation exportée:\n```markdown\n' + markdown + '```', time: new Date().toISOString() }])
}).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible d\'exporter la conversation', time: new Date().toISOString() }])
})
return
}
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
setMessages(prev => [...prev, userMsg])
setLoading(true)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
const controller = new AbortController()
abortRef.current = controller
try {
let accumulated = ''
let thinking = ''
let toolCalls = []
await api.sendChat(text, true, (partial, event) => {
if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) {
if (event.thinking !== undefined) {
thinking += event.thinking
setStreamThinking(thinking)
}
return
}
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls])
return
}
if (event && event.tool_result) {
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
setStreamToolCalls([...toolCalls])
}
return
}
accumulated = partial
setStreaming(partial)
}, controller.signal)
const finalContent = accumulated || t('studio.noResponse')
const aiMsg = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}
if (thinking) aiMsg.thinking = thinking
if (toolCalls.length > 0) {
aiMsg.content = JSON.stringify({
content: finalContent,
tool_calls: toolCalls.map(tc => tc.call),
})
}
setMessages(prev => [...prev, aiMsg])
} catch (err) {
if (err.name === 'AbortError') {
if (streaming) {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: t('studio.cancelled'),
time: new Date().toISOString(),
}])
}
} else {
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: `${t('studio.error')}: ${err.message}`,
time: new Date().toISOString(),
}])
}
} finally {
setLoading(false)
setStreaming('')
setStreamThinking('')
setStreamToolCalls([])
abortRef.current = null
refreshTokens()
}
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize])
const handleStop = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort()
}
}, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change']
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
return
}
if (e.key === 'Tab') {
e.preventDefault()
const ta = textareaRef.current
if (!ta) return
if (document.activeElement !== ta) {
ta.focus()
return
}
const val = ta.value
const pos = ta.selectionStart
const before = val.slice(0, pos)
const afterSlash = before.match(/\/[\w ]*$/)
if (afterSlash) {
const partial = afterSlash[0]
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
if (matches.length === 1) {
const completed = matches[0] + ' '
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
setInput(newText)
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
})
}
}
}
}
const handleToggleCollapsed = useCallback(() => {
setMessagesCollapsed(prev => !prev)
}, [])
const renderMessages = () => {
if (messagesCollapsed && messages.length > 4) {
const visibleCount = 4
const hiddenCount = messages.length - visibleCount
return (
<>
{messages.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
<span className="feed-collapsed-count">clic pour développer</span>
</div>
</>
)
}
return messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))
}
if (!loaded) {
return (
<div className="studio-feed-layout">
<div className="studio-feed">
<div className="feed-loading">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
</div>
</div>
)
}
return (
<div className="studio-feed-layout">
<div className="studio-feed-scroll-wrap">
<div className="studio-feed" ref={feedRef}>
{renderMessages()}
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
)}
<div ref={messagesEnd} style={{ height: '24px' }} />
</div>
<div className="studio-scroll-btns">
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
</div>
<div className="studio-input-area">
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
<div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/>
</div>
<span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{contextCollapsed === true && ' · compressé'}
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
</span>
{contextCollapsed === true && (
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
voir plus
</button>
)}
</div>
<div className="studio-input-row">
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('studio.placeholderNew')}
disabled={loading}
rows={1}
/>
<button
className="studio-send-btn"
onClick={handleSend}
disabled={loading || !input.trim()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
{loading && (
<button className="studio-stop-btn" onClick={handleStop} title={t('studio.stop')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
)}
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change
</div>
</div>
</div>
)
}

211
web/src/i18n/en.js Normal file
View File

@@ -0,0 +1,211 @@
const en = {
tabs: {
dashboard: 'Dashboard',
studio: 'Studio',
shell: 'Shell',
config: 'Config',
},
header: {
toolsInstalled: '{count} tools installed',
updatesAvailable: 'Updates available',
upToDate: 'Up to date',
},
statusbar: {
switchWindow: 'Switch window',
sendMessage: 'Send message',
newLine: 'New line',
runCommand: 'Run command',
commandHistory: 'Command history',
},
dashboard: {
systemOverview: 'System Overview',
tools: 'Tools',
activity: 'Activity',
toolsCount: '{count} tools installed',
installed: 'Installed',
missing: 'Missing',
quickActions: 'Quick Actions',
installMissing: 'Install missing',
checkUpdates: 'Check for updates',
rescanSystem: 'Rescan system',
configureMCP: 'Configure MCP',
updates: 'Updates',
update: 'Update',
latest: 'Latest',
activityLog: 'Activity Log',
noUpdateData: 'No update data yet.',
installing: 'Installing {count} tools...',
installStarted: 'Install started. Rescanning...',
done: 'Done.',
scanComplete: 'Scan complete.',
scanFailed: 'Scan failed',
updatesCount: '{count} updates available.',
allUpToDate: 'All tools up to date.',
mcpConfigured: 'MCP configured.',
mcpConfigFailed: 'MCP configuration failed',
status: 'Status',
clearLog: 'Clear',
noActivity: 'No recent activity.',
rescanning: 'Scanning...',
install: 'Install',
installFailed: 'Install failed',
checkUpdatesFailed: 'Check failed',
configuringMCP: 'Configuring MCP...',
mcpConfigFailed: 'MCP configuration failed',
},
studio: {
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
welcomeNew: 'Welcome to Muyue Studio. I am your AI orchestrator. Describe your project and I will create a plan, propose agents, and track each step.',
configureHint: 'Configure agents and workflows from the sidebar.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Type a message... (Enter to send)',
placeholderNew: 'Describe your project or ask a question...',
send: 'Send',
commands: 'Commands',
planGoal: '/plan <goal>',
help: '/help',
activeAgents: 'Active Agents',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Stopped',
inactive: 'Inactive',
noWorkflow: 'No active workflow.',
usePlan: 'Use /plan <goal> in chat to start.',
noResponse: 'No response',
error: 'Error',
inputHint: 'Enter to send, Shift+Enter for new line',
context: 'Context',
plans: 'Plans',
activity: 'Activity',
noPlansYet: 'No plans detected. Ask the AI to create a plan.',
noAgentsYet: 'No agents mentioned.',
planDetail: 'Plan detail',
steps: 'steps',
you: 'You',
mentioned: 'mentioned',
cleared: 'Conversation cleared.',
cancelled: 'Request cancelled.',
stop: 'Stop',
},
shell: {
terminal: 'Terminal',
send: 'Send',
noResponse: 'No response',
error: 'Error',
newTab: 'New tab',
closeTab: 'Close tab',
maxTabsReached: 'Maximum 7 terminals reached',
renameTab: 'Rename',
local: 'Local',
ssh: 'SSH',
connections: 'Connections',
addConnection: 'Add SSH connection',
editConnection: 'Edit connection',
deleteConnection: 'Delete',
connectionName: 'Name',
host: 'Host',
port: 'Port',
user: 'User',
keyPath: 'SSH key path',
connect: 'Connect',
save: 'Save',
cancel: 'Cancel',
savedConnections: 'Saved connections',
noConnections: 'No saved SSH connections.',
systemTerminals: 'System terminals',
switchTerminal: 'Switch terminal',
localShell: 'Local Shell',
aiAssistant: 'AI Assistant',
aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!',
askAi: 'Ask AI assistant...',
toolLaunched: 'Tool launched',
toolResult: 'Result',
},
config: {
panels: {
profile: 'Profile',
providers: 'AI Providers',
terminal: 'Terminal',
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
system: 'System',
},
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
email: 'Email',
editor: 'Editor',
shell: 'Shell',
defaultAi: 'Default AI',
languages: 'Languages',
loadingProfile: 'Loading profile...',
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
activate: 'Activate',
keyConfigured: 'Key configured',
noKey: 'No key',
apiKey: 'API Key',
model: 'Model',
baseUrl: 'Base URL',
save: 'Save',
saved: 'Saved!',
error: 'Error',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
updates: 'Updates',
systemUpdates: 'System Updates',
checkUpdates: 'Check for updates',
updateAll: 'Update all',
updateTool: 'Update',
checking: 'Checking...',
updating: 'Updating...',
upToDate: 'Up to date',
needsUpdate: 'Update available',
current: 'Current',
latest: 'Latest',
noUpdates: 'All tools are up to date.',
version: 'Version',
installed: 'Installed',
missing: 'Missing',
editProfile: 'Edit',
profileInfo: 'Personal Info',
profilePrefs: 'Preferences',
cancel: 'Cancel',
editProvider: 'Configure',
validateKey: 'Validate',
validating: 'Validating...',
keyValid: 'Valid key',
keyInvalid: 'Invalid key',
connectionFailed: 'Connection failed',
enterToken: 'Enter your API token for {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configure your AI provider token to use the assistant.',
terminalTheme: 'Terminal Theme',
fontSize: 'Font Size',
fontFamily: 'Font Family',
preview: 'Preview',
saving: 'Saving...',
resetConfig: 'Reset all',
resetConfirm: 'Are you sure? All preferences will be erased.',
resetDone: 'Settings reset.',
applyStarship: 'Apply starship',
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
starshipError: 'Failed to apply starship theme.',
},
}
export default en

211
web/src/i18n/fr.js Normal file
View File

@@ -0,0 +1,211 @@
const fr = {
tabs: {
dashboard: 'Tableau de bord',
studio: 'Studio',
shell: 'Terminal',
config: 'Configuration',
},
header: {
toolsInstalled: '{count} outils install\u00e9s',
updatesAvailable: 'Mises \u00e0 jour disponibles',
upToDate: '\u00c0 jour',
},
statusbar: {
switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},
dashboard: {
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
tools: 'Outils',
activity: 'Activit\u00e9',
toolsCount: '{count} outils install\u00e9s',
installed: 'Install\u00e9',
missing: 'Manquant',
quickActions: 'Actions rapides',
installMissing: 'Installer les manquants',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
rescanSystem: 'Rescanner le syst\u00e8me',
configureMCP: 'Configurer MCP',
updates: 'Mises \u00e0 jour',
update: 'Mise \u00e0 jour',
latest: '\u00c0 jour',
activityLog: 'Journal d\u2019activit\u00e9',
noUpdateData: 'Aucune donn\u00e9e de mise \u00e0 jour.',
installing: 'Installation de {count} outils...',
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
done: 'Termin\u00e9.',
scanComplete: 'Scan termin\u00e9.',
scanFailed: '\u00c9chec du scan',
updatesCount: '{count} mises \u00e0 jour disponibles.',
allUpToDate: 'Tous les outils sont \u00e0 jour.',
mcpConfigured: 'MCP configur\u00e9.',
status: 'Statut',
noTools: 'Aucun outil d\u00e9tect\u00e9. Ex\u00e9cutez un scan.',
clearLog: 'Effacer',
noActivity: 'Aucune activit\u00e9 r\u00e9cente.',
rescanning: 'Scan en cours...',
install: 'Installer',
installFailed: '\u00c9chec de l\u2019installation',
checkUpdatesFailed: '\u00c9chec de la v\u00e9rification',
configuringMCP: 'Configuration MCP en cours...',
mcpConfigFailed: '\u00c9chec de la configuration MCP',
},
studio: {
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
welcomeNew: 'Bienvenue dans Muyue Studio. Je suis votre orchestrateur IA. D\u00e9crivez votre projet et je cr\u00e9erai un plan, proposerai des agents, et suivrai chaque \u00e9tape.',
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
placeholderNew: 'D\u00e9crivez votre projet ou posez une question...',
send: 'Envoyer',
commands: 'Commandes',
planGoal: '/plan <objectif>',
help: '/help',
activeAgents: 'Agents actifs',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Arr\u00eat\u00e9',
inactive: 'Inactif',
noWorkflow: 'Aucun workflow actif.',
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
inputHint: 'Entr\u00e9e pour envoyer, Shift+Entr\u00e9e pour un retour \u00e0 la ligne',
context: 'Contexte',
plans: 'Plans',
activity: 'Activit\u00e9',
noPlansYet: 'Aucun plan d\u00e9tect\u00e9. Demandez \u00e0 l\u2019IA de cr\u00e9er un plan.',
noAgentsYet: 'Aucun agent mentionn\u00e9.',
planDetail: 'D\u00e9tail du plan',
steps: '\u00e9tapes',
you: 'Vous',
mentioned: 'mentionn\u00e9',
cleared: 'Conversation effac\u00e9e.',
cancelled: 'Requ\u00eate annul\u00e9e.',
stop: 'Stop',
},
shell: {
terminal: 'Terminal',
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
newTab: 'Nouvel onglet',
closeTab: 'Fermer l\u2019onglet',
maxTabsReached: 'Maximum 7 terminaux atteint',
renameTab: 'Renommer',
local: 'Local',
ssh: 'SSH',
connections: 'Connexions',
addConnection: 'Ajouter une connexion SSH',
editConnection: 'Modifier la connexion',
deleteConnection: 'Supprimer',
connectionName: 'Nom',
host: 'H\u00f4te',
port: 'Port',
user: 'Utilisateur',
keyPath: 'Chemin cl\u00e9 SSH',
connect: 'Se connecter',
save: 'Enregistrer',
cancel: 'Annuler',
savedConnections: 'Connexions enregistr\u00e9es',
noConnections: 'Aucune connexion SSH enregistr\u00e9e.',
systemTerminals: 'Terminaux syst\u00e8me',
switchTerminal: 'Changer de terminal',
localShell: 'Shell local',
aiAssistant: 'Assistant IA',
aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !',
askAi: 'Interroger l\'assistant IA...',
toolLaunched: 'Outil lanc\u00e9',
toolResult: 'R\u00e9sultat',
},
config: {
panels: {
profile: 'Profil',
providers: 'Fournisseurs IA',
terminal: 'Terminal',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Compétences',
system: 'Syst\u00e8me',
},
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
email: 'Email',
editor: '\u00c9diteur',
shell: 'Shell',
defaultAi: 'IA par d\u00e9faut',
languages: 'Langages',
loadingProfile: 'Chargement du profil...',
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
activate: 'Activer',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
apiKey: 'Cl\u00e9 API',
model: 'Mod\u00e8le',
baseUrl: 'URL de base',
save: 'Enregistrer',
saved: 'Enregistr\u00e9 !',
error: 'Erreur',
skills: 'Compétences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
updates: 'Mises \u00e0 jour',
systemUpdates: 'Mises \u00e0 jour syst\u00e8me',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
updateAll: 'Tout mettre \u00e0 jour',
updateTool: 'Mettre \u00e0 jour',
checking: 'V\u00e9rification...',
updating: 'Mise \u00e0 jour...',
upToDate: '\u00c0 jour',
needsUpdate: 'Mise \u00e0 jour disponible',
current: 'Actuel',
latest: 'Dernier',
noUpdates: 'Tous les outils sont \u00e0 jour.',
version: 'Version',
installed: 'Install\u00e9',
missing: 'Manquant',
editProfile: 'Modifier',
profileInfo: 'Informations personnelles',
profilePrefs: 'Préférences',
editProvider: 'Configurer',
validateKey: 'Valider',
validating: 'V\u00e9rification...',
keyValid: 'Cl\u00e9 valide',
keyInvalid: 'Cl\u00e9 invalide',
connectionFailed: 'Connexion \u00e9chou\u00e9e',
enterToken: 'Entrez votre token API pour {provider}',
tokenPlaceholder: 'sk-...',
setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.',
cancel: 'Annuler',
terminalTheme: 'Th\u00e8me du terminal',
fontSize: 'Taille de police',
fontFamily: 'Police',
preview: 'Aper\u00e7u',
saving: 'Enregistrement...',
resetConfig: 'R\u00e9initialiser',
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
applyStarship: 'Appliquer starship',
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
},
}
export default fr

101
web/src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
import en from './en'
import fr from './fr'
import { getLayout, getLayoutList } from './keyboards'
import api from '../api/client'
const translations = { en, fr }
const STORAGE_KEY_LANG = 'muyue-language'
const STORAGE_KEY_KBD = 'muyue-keyboard'
const I18nContext = createContext(null)
function resolveLocale(layout) {
const l = getLayout(layout)
return l.locale
}
export function I18nProvider({ children }) {
const [language, setLanguageState] = useState(() => localStorage.getItem(STORAGE_KEY_LANG) || 'fr')
const [keyboard, setKeyboardState] = useState(() => localStorage.getItem(STORAGE_KEY_KBD) || 'azerty')
const [loaded, setLoaded] = useState(false)
const pendingSave = useRef(null)
useEffect(() => {
api.getConfig()
.then(d => {
const prefs = d.profile?.preferences
if (prefs?.language) setLanguageState(prefs.language)
if (prefs?.keyboard_layout) setKeyboardState(prefs.keyboard_layout)
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
useEffect(() => {
if (!loaded) return
if (pendingSave.current) clearTimeout(pendingSave.current)
pendingSave.current = setTimeout(() => {
api.savePreferences({ language, keyboard_layout: keyboard }).catch(() => {})
}, 500)
return () => { if (pendingSave.current) clearTimeout(pendingSave.current) }
}, [language, keyboard, loaded])
const setLanguage = useCallback((lang) => {
setLanguageState(lang)
localStorage.setItem(STORAGE_KEY_LANG, lang)
}, [])
const setKeyboard = useCallback((kbd) => {
setKeyboardState(kbd)
localStorage.setItem(STORAGE_KEY_KBD, kbd)
}, [])
const layout = useMemo(() => getLayout(keyboard), [keyboard])
const t = useCallback((key, params) => {
const dict = translations[language] || translations.fr
const keys = key.split('.')
let value = dict
for (const k of keys) {
if (value == null) return key
value = value[k]
}
if (typeof value !== 'string') return key
if (params) {
return Object.entries(params).reduce((str, [k, v]) => str.replace(`{${k}}`, v), value)
}
return value
}, [language])
const clockLocale = useMemo(() => resolveLocale(keyboard), [keyboard])
const contextValue = useMemo(() => ({
language,
keyboard,
layout,
setLanguage,
setKeyboard,
t,
clockLocale,
layouts: getLayoutList(),
}), [language, keyboard, layout, t, clockLocale])
return (
<I18nContext.Provider value={contextValue}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
export const LANGUAGES = [
{ id: 'fr', name: 'Fran\u00e7ais' },
{ id: 'en', name: 'English' },
]

Some files were not shown because too many files have changed in this diff Show More