Compare commits
140 Commits
v0.3.3-bet
...
v0.6.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d1d8d3ec3 | ||
|
|
6a7b4d8001 | ||
|
|
0753167fb9 | ||
|
|
2a6647b5cb | ||
|
|
3740454201 | ||
|
|
d98110ce8a | ||
|
|
d2bb42b212 | ||
|
|
e8a289ccf3 | ||
|
|
c9f2932147 | ||
|
|
f05181b2db | ||
|
|
95e6cdaf41 | ||
|
|
12000e523c | ||
|
|
cb3d35756a | ||
|
|
0830e64ae6 | ||
|
|
b43e3352e7 | ||
|
|
a60435d002 | ||
|
|
6b0fcfbd31 | ||
|
|
df46b5c14e | ||
|
|
7240813de6 | ||
|
|
97bfb803a6 | ||
|
|
3104179109 | ||
|
|
e21b47a27c | ||
|
|
2e98701104 | ||
|
|
f9d56de65a | ||
|
|
0e7340891c | ||
|
|
3b819be5ac | ||
|
|
c607943ca3 | ||
|
|
3312005be4 | ||
|
|
6cc86b7f89 | ||
|
|
1885616068 | ||
|
|
c8506d4dfc | ||
|
|
68acabd6a1 | ||
|
|
b80562a669 | ||
|
|
c562972da3 | ||
|
|
3651f62127 | ||
|
|
18e83479d6 | ||
|
|
6596d86db6 | ||
|
|
9fb5aa8dbf | ||
|
|
ab3641d00d | ||
|
|
5dac191d9a | ||
|
|
e6da61f460 | ||
|
|
a994749dcf | ||
|
|
b394ef9979 | ||
|
|
fca53440e6 | ||
|
|
0a3123ec17 | ||
|
|
e6447f2f5a | ||
|
|
16c5ed6dd9 | ||
|
|
e8924be182 | ||
|
|
a905f22f1a | ||
|
|
183dd27407 | ||
|
|
203f57fa31 | ||
|
|
a1046da67b | ||
|
|
02ee41c12b | ||
|
|
06810be9a3 | ||
|
|
8db3bd7c6b | ||
|
|
20237c022f | ||
|
|
9a218b1904 | ||
|
|
399b845e14 | ||
|
|
436d5c6149 | ||
|
|
5a9edc076e | ||
|
|
5bdc7a6429 | ||
|
|
5a0480bae0 | ||
|
|
80de4dd523 | ||
|
|
de52f4ebd6 | ||
|
|
98ff0dd578 | ||
|
|
9a1ff6e8dc | ||
|
|
034b9ee0e4 | ||
|
|
c1b1fc653f | ||
|
|
50ca75180c | ||
|
|
b8aa935bec | ||
|
|
5627ddd2ce | ||
|
|
d27872572a | ||
|
|
7d0f807fb0 | ||
|
|
cbf623b98b | ||
|
|
b85ebb8e54 | ||
|
|
7cc206dc20 | ||
|
|
bf8c0fd380 | ||
|
|
08dc1fd53b | ||
|
|
13e937a11b | ||
|
|
3cf701b002 | ||
|
|
3a09e0e0c2 | ||
|
|
47fa2e01bb | ||
|
|
401292ec5b | ||
|
|
199a7e409a | ||
|
|
c91931f42f | ||
|
|
cbbb224725 | ||
|
|
8d10d2182e | ||
|
|
e9696ef82b | ||
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf | ||
|
|
40ec493bae | ||
|
|
233368c954 | ||
|
|
00118f0803 | ||
|
|
167ab82978 | ||
|
|
a23c0c5b94 | ||
|
|
24b31b0b47 | ||
|
|
c39203cc4b | ||
|
|
869bf154cc | ||
|
|
7ae4017672 | ||
|
|
52a785ec9a | ||
|
|
8c540eba93 | ||
|
|
0b6d5281df | ||
|
|
1074b019d3 | ||
|
|
745e03d00a | ||
|
|
2da0cf9421 | ||
|
|
f88c7a4f3f | ||
|
|
9987a586e2 | ||
|
|
028fb364ba | ||
|
|
2827acfe96 | ||
|
|
85edea9ed9 | ||
|
|
afb6e77c7f | ||
|
|
0232bd7afe | ||
|
|
84be22661b | ||
|
|
49a0f5c8c3 | ||
|
|
f9c4cf11ff | ||
|
|
d3755028fb | ||
|
|
eda7293286 | ||
|
|
41cbee8928 | ||
|
|
b55feaed09 | ||
|
|
1d521cbf90 | ||
|
|
d9d1ec5cb7 | ||
|
|
45884ee75c | ||
|
|
6f7f588e51 | ||
|
|
328e9e6457 | ||
|
|
c81ebb4e46 | ||
|
|
b0865bc598 | ||
|
|
0d8e1b1e1a | ||
|
|
485e085bb0 | ||
|
|
61da8039bc | ||
|
|
65df15498b | ||
|
|
b6147ddb12 | ||
|
|
275a9a4cc7 | ||
|
|
e92a2f00f5 | ||
|
|
1f12b8a4fb | ||
|
|
9188231a05 | ||
|
|
28e5113733 | ||
|
|
51a599fc83 | ||
|
|
d8384cad00 | ||
|
|
5b4a70e690 |
@@ -170,7 +170,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Commit changelog
|
- name: Commit changelog
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
git config user.name "CI Bot"
|
git config user.name "CI Bot"
|
||||||
git config user.email "ci@legion-muyue.fr"
|
git config user.email "ci@legion-muyue.fr"
|
||||||
@@ -181,30 +181,45 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -ex
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
echo "Warning: GITEATOKEN not set, skipping release"
|
echo "Error: GITEA_TOKEN secret is not set"
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
BODY=$(cat /tmp/stable_changelog.md)
|
echo "Creating release ${VERSION} at ${API}"
|
||||||
RESPONSE=$(curl -s -X POST "${API}" \
|
|
||||||
|
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
|
||||||
|
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"tag_name\":\"${VERSION}\",
|
\"tag_name\":\"${VERSION}\",
|
||||||
\"target_commitish\":\"main\",
|
\"target_commitish\":\"main\",
|
||||||
\"name\":\"muyue ${VERSION}\",
|
\"name\":\"muyue ${VERSION}\",
|
||||||
\"body\":$(echo "$BODY" | jq -Rs .),
|
\"body\":${BODY},
|
||||||
\"draft\":false,
|
\"draft\":false,
|
||||||
\"prerelease\":false
|
\"prerelease\":false
|
||||||
}")
|
}")
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
echo "HTTP Status: ${HTTP_CODE}"
|
||||||
|
echo "Response: ${RESPONSE_BODY}"
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
echo "Failed to create release:"
|
echo "Failed to create release"
|
||||||
echo "$RESPONSE"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
@@ -212,8 +227,12 @@ jobs:
|
|||||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
curl -s -X POST "${UPLOAD_URL}" \
|
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-F "attachment=@${file};filename=${filename}" > /dev/null
|
-F "attachment=@${file};filename=${filename}")
|
||||||
|
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
|
||||||
|
if [ "$UPLOAD_CODE" != "201" ]; then
|
||||||
|
echo "Upload failed with status ${UPLOAD_CODE}"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
echo "Stable release ${VERSION} published!"
|
echo "Stable release ${VERSION} published!"
|
||||||
|
|||||||
467
CHANGELOG.md
467
CHANGELOG.md
@@ -4,6 +4,473 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## v0.6.0
|
||||||
|
|
||||||
|
### Audit & corrections (sécurité, concurrence, stabilité)
|
||||||
|
|
||||||
|
- fix(api): empty `resp.Choices[0]` panic in chat engine — bounded check
|
||||||
|
- fix(api): `defer release()` accumulating inside tool-call loop — release immediately after each tool call
|
||||||
|
- fix(api): race in `ConversationStoreMulti.Add` (fire-and-forget save under released lock) — synchronous save under existing lock
|
||||||
|
- fix(workflow): infinite busy-wait in `engine.Execute` when a dependency fails — propagate `StatusFailed`/`StatusSkipped` and short-circuit
|
||||||
|
- fix(workflow): UTF-8-unsafe slicing of plan goal — rune-aware truncate
|
||||||
|
- fix(security): CORS `Access-Control-Allow-Origin: *` — restricted to localhost origins
|
||||||
|
- fix(security): API key disclosure in `/api/providers` — masked as `"***"`; saving handler ignores `"***"` placeholder
|
||||||
|
- fix(security): SSH password disclosure in `/api/terminal/sessions` — masked; update handler preserves stored password if `"***"` is sent
|
||||||
|
- fix(security): sshpass `-p` + `-e` mutually-exclusive flags — use only `-e` with `SSHPASS` env var
|
||||||
|
- fix(security): unbounded chat request body — `MaxBytesReader` 50 MB
|
||||||
|
- fix(security): unbounded image upload — 10 MB cap in `saveImage`
|
||||||
|
- fix(security): font size unbounded — capped at 72
|
||||||
|
- fix(security): `LSP /auto-install` accepted arbitrary `project_dir` — restricted to user home subtree
|
||||||
|
- fix(api): silent `json.Unmarshal` errors in profile save — propagated
|
||||||
|
- fix(ui): operator-precedence bug in `Shell.jsx` resize check — parenthesized
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
|
||||||
|
- feat(ai): inject OS name (e.g. `Debian 12`, `Windows 11`, `macOS 14.5`) alongside date in Studio system prompt
|
||||||
|
- feat(agents): default timeout raised to 30 minutes for `crush_run` and `claude_run`; max also 30 min
|
||||||
|
- feat(agents): new optional params `cwd`, `wsl_distro`, `wsl_user` — agents can be launched in a specific directory, and on Windows hosts inside a specific WSL distribution under a specific user
|
||||||
|
- feat(agents): new `claude_run` tool (mirrors `crush_run` for the Claude Code CLI)
|
||||||
|
- feat(terminal): WSL distros listed individually as quick-launch entries in the new-tab menu (Windows hosts only)
|
||||||
|
- feat(studio): system prompt rewritten around the BMAD-METHOD (Analyst/PM/Architect/SM/Dev/QA personas + mandatory `[OBJECTIF]/[CONTEXTE]/[CONTRAINTES]/[LIVRABLE]/[CRITÈRE D'ACCEPTATION]` template for any agent delegation)
|
||||||
|
- feat(studio): "Réflexion avancée" toggle — when enabled, the inactive AI provider produces a preliminary report that is injected as `[RAPPORT PRÉALABLE]` context into the active provider's prompt
|
||||||
|
- feat(studio): "Historique compressé" toggle — collapses past tool calls and keeps only the last visible action per assistant message, with `Tout afficher` to expand
|
||||||
|
|
||||||
|
### Bug fix CI
|
||||||
|
|
||||||
|
- fix(test): `cleanAIResponse` → `CleanAIResponse` in `orchestrator_test.go` (was failing `go vet`)
|
||||||
|
|
||||||
|
## v0.4.0
|
||||||
|
|
||||||
|
### Changes since v0.3.5
|
||||||
|
|
||||||
|
- fix: token persistence, context windows, CSS tables/bullets/hr, image attachments (12000e5)
|
||||||
|
- feat: terminal sudo blocking, token tracking, mermaid & consumption UI (cb3d357)
|
||||||
|
- fix(shell,config): terminal font size, AI tools, provider keys (0830e64)
|
||||||
|
- chore: update CHANGELOG for v0.3.5 (b43e335)
|
||||||
|
- fix(shell): set default terminal fontSize to 6px (a60435d)
|
||||||
|
- fix(shell): default fontSize 10px and init new tabs immediately (6b0fcfb)
|
||||||
|
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (df46b5c)
|
||||||
|
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (7240813)
|
||||||
|
- fix(shell): enable allowProposedApi for Unicode11 addon (97bfb80)
|
||||||
|
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (3104179)
|
||||||
|
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (e21b47a)
|
||||||
|
- fix(shell): restore all missing imports, constants, and utility functions (2e98701)
|
||||||
|
- fix(shell): add missing Monitor import from lucide-react (f9d56de)
|
||||||
|
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (0e73408)
|
||||||
|
- fix(shell): add missing useI18n import (3b819be)
|
||||||
|
- fix(shell): remove stray 'impo' typo causing ReferenceError (c607943)
|
||||||
|
- fix(terminal): improve dimensions handling and add system theme for xterm (3312005)
|
||||||
|
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (6cc86b7)
|
||||||
|
- fix(terminal): improve dimension calculation and tab init reliability (1885616)
|
||||||
|
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (c8506d4)
|
||||||
|
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (68acabd)
|
||||||
|
- fix(terminal): use absolute positioning for content panels (b80562a)
|
||||||
|
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (c562972)
|
||||||
|
- fix(shell): prevent Enter in AI chat from leaking to terminal (3651f62)
|
||||||
|
- fix(terminal): improve terminal dimensions and fit timing (18e8347)
|
||||||
|
- fix(terminal): detect shell tab visibility via MutationObserver (6596d86)
|
||||||
|
- fix(terminal): init all tabs on load, fix excessive zoom (9fb5aa8)
|
||||||
|
- fix(terminal): improve tab visibility checks and positioning (ab3641d)
|
||||||
|
- fix(ui): adjust global CSS styles (5dac191)
|
||||||
|
- fix(terminal): use display:none instead of visibility for tab hiding (e6da61f)
|
||||||
|
- feat(ui): refactor copy state to Set and add helper functions (a994749)
|
||||||
|
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (b394ef9)
|
||||||
|
- feat(ui): redesign recent commands display and fix terminal visibility (fca5344)
|
||||||
|
- fix(shell): initialize activeTabRef with activeTab and move useEffect (0a3123e)
|
||||||
|
- fix(config): remove unused import, reorder hooks, and improve variable naming (e6447f2)
|
||||||
|
- fix(studio): add tool results serialization and improve message handling (16c5ed6)
|
||||||
|
- fix(shell): improve tab reference stability and command queueing (e8924be)
|
||||||
|
- fix(shell): add debug logging for tab tracking and WebSocket state (a905f22)
|
||||||
|
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (183dd27)
|
||||||
|
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (203f57f)
|
||||||
|
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (a1046da)
|
||||||
|
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (02ee41c)
|
||||||
|
- bump: v0.3.5 (06810be)
|
||||||
|
- fix: display all quota models, center card content vertically (8db3bd7)
|
||||||
|
- fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering (20237c0)
|
||||||
|
- fix(shell): set default terminal fontSize to 6px (9a218b1)
|
||||||
|
- fix(shell): default fontSize 10px and init new tabs immediately (399b845)
|
||||||
|
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (436d5c6)
|
||||||
|
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (5a9edc0)
|
||||||
|
- fix(shell): enable allowProposedApi for Unicode11 addon (5bdc7a6)
|
||||||
|
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (5a0480b)
|
||||||
|
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (80de4dd)
|
||||||
|
- fix(shell): restore all missing imports, constants, and utility functions (de52f4e)
|
||||||
|
- fix(shell): add missing Monitor import from lucide-react (98ff0dd)
|
||||||
|
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (9a1ff6e)
|
||||||
|
- fix(shell): add missing useI18n import (034b9ee)
|
||||||
|
- fix(shell): remove stray 'impo' typo causing ReferenceError (c1b1fc6)
|
||||||
|
- fix(terminal): improve dimensions handling and add system theme for xterm (50ca751)
|
||||||
|
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (b8aa935)
|
||||||
|
- fix(terminal): improve dimension calculation and tab init reliability (5627ddd)
|
||||||
|
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (d278725)
|
||||||
|
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (7d0f807)
|
||||||
|
- fix(terminal): use absolute positioning for content panels (cbf623b)
|
||||||
|
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (b85ebb8)
|
||||||
|
- fix(shell): prevent Enter in AI chat from leaking to terminal (7cc206d)
|
||||||
|
- fix(terminal): improve terminal dimensions and fit timing (bf8c0fd)
|
||||||
|
- fix(terminal): detect shell tab visibility via MutationObserver (08dc1fd)
|
||||||
|
- fix(terminal): init all tabs on load, fix excessive zoom (13e937a)
|
||||||
|
- fix(terminal): improve tab visibility checks and positioning (3cf701b)
|
||||||
|
- fix(ui): adjust global CSS styles (3a09e0e)
|
||||||
|
- fix(terminal): use display:none instead of visibility for tab hiding (47fa2e0)
|
||||||
|
- feat(ui): refactor copy state to Set and add helper functions (401292e)
|
||||||
|
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (199a7e4)
|
||||||
|
- feat(ui): redesign recent commands display and fix terminal visibility (c91931f)
|
||||||
|
- fix(shell): initialize activeTabRef with activeTab and move useEffect (cbbb224)
|
||||||
|
- fix(config): remove unused import, reorder hooks, and improve variable naming (8d10d21)
|
||||||
|
- fix(studio): add tool results serialization and improve message handling (e9696ef)
|
||||||
|
- fix(shell): improve tab reference stability and command queueing (1edd4f0)
|
||||||
|
- fix(shell): add debug logging for tab tracking and WebSocket state (92f943c)
|
||||||
|
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (1704b19)
|
||||||
|
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (40ec493)
|
||||||
|
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (233368c)
|
||||||
|
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (00118f0)
|
||||||
|
- chore: update CHANGELOG for v0.3.4 (c39203c)
|
||||||
|
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (328e9e6)
|
||||||
|
- feat(dashboard): add quota monitoring, process list, and command history (c81ebb4)
|
||||||
|
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (b0865bc)
|
||||||
|
- fix(studio): improve chat context, thinking tags, streaming, and tool results (0d8e1b1)
|
||||||
|
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (485e085)
|
||||||
|
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (61da803)
|
||||||
|
- feat(onboarding): add minimax api key step and AI-powered editor scan (65df154)
|
||||||
|
- fix(onboarding): require fields before advancing steps (b6147dd)
|
||||||
|
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (275a9a4)
|
||||||
|
- fix(config): per-provider form state to avoid field cross-talk (e92a2f0)
|
||||||
|
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (1f12b8a)
|
||||||
|
- feat(config): add system panel with reset and starship theme, add onboarding wizard (9188231)
|
||||||
|
- chore: update CHANGELOG for v0.3.2 (28e5113)
|
||||||
|
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||||
|
- chore: update CHANGELOG for v0.3.1 (5b4a70e)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.4.0/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.5
|
||||||
|
|
||||||
|
### Changes since v0.3.5
|
||||||
|
|
||||||
|
- fix(shell): set default terminal fontSize to 6px (a60435d)
|
||||||
|
- fix(shell): default fontSize 10px and init new tabs immediately (6b0fcfb)
|
||||||
|
- feat(shell): add Ctrl+/- zoom and display all shortcuts in footer (df46b5c)
|
||||||
|
- fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility (7240813)
|
||||||
|
- fix(shell): enable allowProposedApi for Unicode11 addon (97bfb80)
|
||||||
|
- fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution (3104179)
|
||||||
|
- feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image) (e21b47a)
|
||||||
|
- fix(shell): restore all missing imports, constants, and utility functions (2e98701)
|
||||||
|
- fix(shell): add missing Monitor import from lucide-react (f9d56de)
|
||||||
|
- fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants (0e73408)
|
||||||
|
- fix(shell): add missing useI18n import (3b819be)
|
||||||
|
- fix(shell): remove stray 'impo' typo causing ReferenceError (c607943)
|
||||||
|
- fix(terminal): improve dimensions handling and add system theme for xterm (3312005)
|
||||||
|
- fix(shell): resolve savedTabs undefined ReferenceError in activeTab init (6cc86b7)
|
||||||
|
- fix(terminal): improve dimension calculation and tab init reliability (1885616)
|
||||||
|
- fix(dashboard): show MiMo quota instead of ZAI on dashboard (c8506d4)
|
||||||
|
- feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback (68acabd)
|
||||||
|
- fix(terminal): use absolute positioning for content panels (b80562a)
|
||||||
|
- feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts (c562972)
|
||||||
|
- fix(shell): prevent Enter in AI chat from leaking to terminal (3651f62)
|
||||||
|
- fix(terminal): improve terminal dimensions and fit timing (18e8347)
|
||||||
|
- fix(terminal): detect shell tab visibility via MutationObserver (6596d86)
|
||||||
|
- fix(terminal): init all tabs on load, fix excessive zoom (9fb5aa8)
|
||||||
|
- fix(terminal): improve tab visibility checks and positioning (ab3641d)
|
||||||
|
- fix(ui): adjust global CSS styles (5dac191)
|
||||||
|
- fix(terminal): use display:none instead of visibility for tab hiding (e6da61f)
|
||||||
|
- feat(ui): refactor copy state to Set and add helper functions (a994749)
|
||||||
|
- feat(ui): add recentUnique to deduplicate recent commands in Dashboard (b394ef9)
|
||||||
|
- feat(ui): redesign recent commands display and fix terminal visibility (fca5344)
|
||||||
|
- fix(shell): initialize activeTabRef with activeTab and move useEffect (0a3123e)
|
||||||
|
- fix(config): remove unused import, reorder hooks, and improve variable naming (e6447f2)
|
||||||
|
- fix(studio): add tool results serialization and improve message handling (16c5ed6)
|
||||||
|
- fix(shell): improve tab reference stability and command queueing (e8924be)
|
||||||
|
- fix(shell): add debug logging for tab tracking and WebSocket state (a905f22)
|
||||||
|
- fix(terminal): refactor WebSocket cleanup, buffer management, and disposal (183dd27)
|
||||||
|
- fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal (203f57f)
|
||||||
|
- fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks (a1046da)
|
||||||
|
- refactor: remove locale panel, improve provider validation and terminal buffer persistence (02ee41c)
|
||||||
|
- bump: v0.3.5 (06810be)
|
||||||
|
- fix: display all quota models, center card content vertically (8db3bd7)
|
||||||
|
- fix: AI terminal init, Shift+Tab nav, code block rendering, command filtering (20237c0)
|
||||||
|
- chore: update CHANGELOG for v0.3.4 (c39203c)
|
||||||
|
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (328e9e6)
|
||||||
|
- feat(dashboard): add quota monitoring, process list, and command history (c81ebb4)
|
||||||
|
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (b0865bc)
|
||||||
|
- fix(studio): improve chat context, thinking tags, streaming, and tool results (0d8e1b1)
|
||||||
|
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (485e085)
|
||||||
|
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (61da803)
|
||||||
|
- feat(onboarding): add minimax api key step and AI-powered editor scan (65df154)
|
||||||
|
- fix(onboarding): require fields before advancing steps (b6147dd)
|
||||||
|
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (275a9a4)
|
||||||
|
- fix(config): per-provider form state to avoid field cross-talk (e92a2f0)
|
||||||
|
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (1f12b8a)
|
||||||
|
- feat(config): add system panel with reset and starship theme, add onboarding wizard (9188231)
|
||||||
|
- chore: update CHANGELOG for v0.3.2 (28e5113)
|
||||||
|
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||||
|
- chore: update CHANGELOG for v0.3.1 (5b4a70e)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.5/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.4
|
||||||
|
|
||||||
|
### Changes since v0.3.3
|
||||||
|
|
||||||
|
- fix(ci): replace jq with python3 in release step, add debug output (7ae4017)
|
||||||
|
- feat: AI terminal, Z.AI quota, /model change, formatting fixes, update redirects (8c540eb)
|
||||||
|
- feat(studio): Tab focuses textarea, autocomplete commands (1074b01)
|
||||||
|
- fix(studio): convert newlines to <br/> in AI message rendering (2da0cf9)
|
||||||
|
- fix(config): replace hardcoded model list with free text input (9987a58)
|
||||||
|
- feat(config): providers panel shows only MINIMAX/ZAI with model selector (2827acf)
|
||||||
|
- feat(dashboard): show top 5 most used commands as clickable chips (afb6e77)
|
||||||
|
- fix: tab containers height, dashboard 2-row grid, studio scroll buttons (84be226)
|
||||||
|
- feat(shell): dedicated System Analyst AI, no code execution, analyze system (f9c4cf1)
|
||||||
|
- fix: keep all tabs mounted, switch via CSS display instead of unmount (eda7293)
|
||||||
|
- refactor(config): locale panel with edit/save flow like profile (b55feae)
|
||||||
|
- feat(config): split profile into Personal Info + Preferences sections, centered (54621bd)
|
||||||
|
- feat(studio): improve context compression UI and provider display (6bad294)
|
||||||
|
- fix(config): locale panel show active language/keyboard, add save button (92eb783)
|
||||||
|
- feat(config): dynamic profile panel, generic save, tabs margin fix (8005e97)
|
||||||
|
- fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota (6e76e7d)
|
||||||
|
- feat(chat): add auto-summarization with token tracking UI (e8f6dc4)
|
||||||
|
- feat(dashboard): add background graphs to cards and improve layout (bb03c9f)
|
||||||
|
- feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator (79d0821)
|
||||||
|
- feat(dashboard): add quota monitoring, process list, and command history (7682717)
|
||||||
|
- refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection (3948a4c)
|
||||||
|
- fix(studio): improve chat context, thinking tags, streaming, and tool results (65804aa)
|
||||||
|
- feat: add Cobra CLI, LSP/MCP registries, workflow engine, and enriched dashboard (2e50366)
|
||||||
|
- feat(agent): refactor AI chat with streaming, agent registry, and tool execution (66b773f)
|
||||||
|
- feat(onboarding): add minimax api key step and AI-powered editor scan (bc5c295)
|
||||||
|
- fix(onboarding): require fields before advancing steps (e19122d)
|
||||||
|
- fix: register missing /api/config/reset and /api/starship/apply-theme routes (8b6a7e8)
|
||||||
|
- fix(config): per-provider form state to avoid field cross-talk (58f8cb0)
|
||||||
|
- fix(onboarding): auto-save on done step, keyboard nav, error feedback (b52fecc)
|
||||||
|
- feat(config): add system panel with reset and starship theme, add onboarding wizard (5bbac49)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.4/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.2
|
||||||
|
|
||||||
|
### Changes since v0.3.1
|
||||||
|
|
||||||
|
- chore: update CHANGELOG for v0.3.2-beta.1 (51a599f)
|
||||||
|
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||||
|
- chore: bump version to 3.2 (0fe82f6)
|
||||||
|
- refactor(config): remove Terminal sub-tab from Configuration page (3b6cc38)
|
||||||
|
- fix(terminal): init payload never sent due to ws.onopen being overwritten (93a22d4)
|
||||||
|
- fix(terminal): improve shell resolution with better error handling and ws proxy support (e0e1e73)
|
||||||
|
- feat(studio): parse AI thinking and tool launch messages in terminal panel (0496ca7)
|
||||||
|
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (b407ab8)
|
||||||
|
- feat(studio): add tool execution and hide AI thinking tags (12df184)
|
||||||
|
- fix(terminal): ignore invalid shell config from race condition (8af6d25)
|
||||||
|
- feat(shell): restore AI assistant panel (4fd599a)
|
||||||
|
- fix(terminal): restore terminal input and cursor visibility (bcba593)
|
||||||
|
- refactor(api): split monolithic handlers.go into focused modules (04b0fff)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.2/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## v0.3.2-beta.1 (Beta)
|
||||||
|
|
||||||
|
### Commits since v0.3.1
|
||||||
|
|
||||||
|
- fix: correct version from 3.2 to 0.3.2 (83d7a57)
|
||||||
|
|
||||||
|
> This is a **beta** release. Use at your own risk.
|
||||||
|
|
||||||
|
## v0.3.1
|
||||||
|
|
||||||
|
### Changes since v0.3.0
|
||||||
|
|
||||||
|
- refactor(config): remove Terminal sub-tab from Configuration page (95bd824)
|
||||||
|
- fix(terminal): init payload never sent due to ws.onopen being overwritten (252f178)
|
||||||
|
- fix(terminal): improve shell resolution with better error handling and ws proxy support (7dcf505)
|
||||||
|
- feat(studio): parse AI thinking and tool launch messages in terminal panel (8fb93fa)
|
||||||
|
- fix(studio): forward AI thinking chunks to frontend instead of dropping them (5ec373c)
|
||||||
|
- feat(studio): add tool execution and hide AI thinking tags (1eb5a6d)
|
||||||
|
- fix(terminal): ignore invalid shell config from race condition (cd5ebe0)
|
||||||
|
- feat(shell): restore AI assistant panel (2004c15)
|
||||||
|
- fix(terminal): restore terminal input and cursor visibility (9306152)
|
||||||
|
- refactor(api): split monolithic handlers.go into focused modules (e15a034)
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | File |
|
||||||
|
|----------|------|
|
||||||
|
| Linux x86_64 | [muyue-linux-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz) |
|
||||||
|
| Linux ARM64 | [muyue-linux-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-arm64.tar.gz) |
|
||||||
|
| macOS Intel | [muyue-darwin-amd64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-amd64.tar.gz) |
|
||||||
|
| macOS Apple Silicon | [muyue-darwin-arm64.tar.gz](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz) |
|
||||||
|
| Windows x86_64 | [muyue-windows-amd64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip) |
|
||||||
|
| Windows ARM64 | [muyue-windows-arm64.zip](https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-arm64.zip) |
|
||||||
|
|
||||||
|
The binary includes both CLI and Desktop modes.
|
||||||
|
Run `muyue` for TUI, `muyue desktop` for web UI.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
**Linux (x86_64)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-linux-amd64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-linux-amd64
|
||||||
|
sudo mv muyue-linux-amd64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS (Apple Silicon)**
|
||||||
|
```bash
|
||||||
|
curl -sL https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-darwin-arm64.tar.gz | tar xz
|
||||||
|
chmod +x muyue-darwin-arm64
|
||||||
|
sudo mv muyue-darwin-arm64 /usr/local/bin/muyue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (x86_64)**
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.legion-muyue.fr/Muyue/MuyueWorkspace/releases/download/v0.3.1/muyue-windows-amd64.zip" -OutFile "muyue.zip"
|
||||||
|
Expand-Archive -Path "muyue.zip" -DestinationPath "."
|
||||||
|
Move-Item muyue-windows-amd64.exe C:\Windows\muyue.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## v0.3.0
|
## v0.3.0
|
||||||
|
|
||||||
### Changes since v0.2.1
|
### Changes since v0.2.1
|
||||||
|
|||||||
1073
CRUSH_ARCHITECTURE_REPORT.md
Normal file
1073
CRUSH_ARCHITECTURE_REPORT.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,55 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
|
||||||
|
|
||||||
|
func stripANSI(s string) string {
|
||||||
|
return ansiRegex.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sudoCache bool
|
||||||
|
sudoCacheSet bool
|
||||||
|
sudoCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func NeedsSudoPassword() bool {
|
||||||
|
sudoCacheOnce.Do(func() {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := exec.CommandContext(ctx, "sudo", "-n", "true").Run()
|
||||||
|
sudoCacheSet = true
|
||||||
|
sudoCache = err != nil
|
||||||
|
} else {
|
||||||
|
sudoCache = true
|
||||||
|
sudoCacheSet = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return sudoCache
|
||||||
|
}
|
||||||
|
|
||||||
type TerminalParams struct {
|
type TerminalParams struct {
|
||||||
Command string `json:"command" description:"The shell command to execute"`
|
Command string `json:"command" description:"The shell command to execute"`
|
||||||
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
SudoBlocked bool `json:"sudo_blocked,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewTerminalTool() (*ToolDefinition, error) {
|
func NewTerminalTool() (*ToolDefinition, error) {
|
||||||
return NewTool("terminal",
|
return NewTool("terminal",
|
||||||
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
|
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
|
||||||
@@ -22,6 +60,39 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
return TextErrorResponse("command is required"), nil
|
return TextErrorResponse("command is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if NeedsSudoPassword() {
|
||||||
|
trimmed := strings.TrimSpace(p.Command)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
|
||||||
|
anywhereBlocked := false
|
||||||
|
blockedCmd := ""
|
||||||
|
if !prefixBlocked {
|
||||||
|
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
|
||||||
|
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
|
||||||
|
if strings.Contains(lower, pattern) {
|
||||||
|
anywhereBlocked = true
|
||||||
|
blockedCmd = kw
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anywhereBlocked {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prefixBlocked || anywhereBlocked {
|
||||||
|
elevCmd := blockedCmd
|
||||||
|
if prefixBlocked {
|
||||||
|
elevCmd = strings.Fields(trimmed)[0]
|
||||||
|
}
|
||||||
|
return ToolResponse{
|
||||||
|
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, elevCmd),
|
||||||
|
IsError: true,
|
||||||
|
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
timeout := time.Duration(p.Timeout) * time.Second
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 60 * time.Second
|
timeout = 60 * time.Second
|
||||||
@@ -39,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
result := string(output)
|
result := string(output)
|
||||||
|
result = stripANSI(result)
|
||||||
if len(result) > 10000 {
|
if len(result) > 10000 {
|
||||||
result = result[:10000] + "\n... [truncated]"
|
result = result[:10000] + "\n... [truncated]"
|
||||||
}
|
}
|
||||||
@@ -52,21 +124,35 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CrushRunParams struct {
|
type CrushRunParams struct {
|
||||||
Task string `json:"task" description:"The task description for Crush to execute"`
|
Task string `json:"task" description:"The task description for Crush to execute"`
|
||||||
|
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
|
||||||
|
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
|
||||||
|
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
|
||||||
|
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCrushRunTool() (*ToolDefinition, error) {
|
func NewCrushRunTool() (*ToolDefinition, error) {
|
||||||
return NewTool("crush_run",
|
return NewTool("crush_run",
|
||||||
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Returns the agent's final output.",
|
"Delegate a complex coding task to the Crush AI agent. Crush has access to file editing, code search, bash execution, and other development tools. Use this for multi-step coding tasks like refactoring, debugging, implementing features, or code review. Optionally pass cwd to run in a specific directory, or wsl_distro/wsl_user to launch inside a WSL distribution under a specific user (Windows hosts only). Returns the agent's final output.",
|
||||||
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
|
func(ctx context.Context, p CrushRunParams) (ToolResponse, error) {
|
||||||
if p.Task == "" {
|
if p.Task == "" {
|
||||||
return TextErrorResponse("task is required"), nil
|
return TextErrorResponse("task is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 1800*time.Second {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
|
cmd, prepErr := buildAgentCommand(ctx, "crush", []string{"run", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
|
||||||
|
if prepErr != nil {
|
||||||
|
return TextErrorResponse(prepErr.Error()), nil
|
||||||
|
}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
result := string(output)
|
result := string(output)
|
||||||
@@ -75,7 +161,66 @@ func NewCrushRunTool() (*ToolDefinition, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
|
errMsg := fmt.Sprintf("Crush error: %v", err)
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
errMsg += "\n\n" + result
|
||||||
|
}
|
||||||
|
return TextErrorResponse(errMsg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextResponse(result), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaudeRunParams struct {
|
||||||
|
Task string `json:"task" description:"The task description for Claude Code to execute"`
|
||||||
|
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 1800, max 1800)"`
|
||||||
|
Cwd string `json:"cwd,omitempty" description:"Working directory in which to launch the agent (absolute path; falls back to user home)"`
|
||||||
|
WSLDistro string `json:"wsl_distro,omitempty" description:"On Windows host: WSL distribution to launch the agent in (e.g. 'Ubuntu')"`
|
||||||
|
WSLUser string `json:"wsl_user,omitempty" description:"On Windows host: WSL user to run the agent as"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaudeRunTool() (*ToolDefinition, error) {
|
||||||
|
return NewTool("claude_run",
|
||||||
|
"Delegate a complex coding task to the Claude Code CLI agent. Claude has access to file editing, code search, bash execution. Use for multi-step coding tasks. Same cwd/wsl_distro/wsl_user options as crush_run.",
|
||||||
|
func(ctx context.Context, p ClaudeRunParams) (ToolResponse, error) {
|
||||||
|
if p.Task == "" {
|
||||||
|
return TextErrorResponse("task is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(p.Timeout) * time.Second
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
if timeout > 1800*time.Second {
|
||||||
|
timeout = 1800 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd, prepErr := buildAgentCommand(ctx, "claude", []string{"-p", p.Task}, p.Cwd, p.WSLDistro, p.WSLUser)
|
||||||
|
if prepErr != nil {
|
||||||
|
return TextErrorResponse(prepErr.Error()), nil
|
||||||
|
}
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
result := string(output)
|
||||||
|
if len(result) > 15000 {
|
||||||
|
result = result[:15000] + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("Claude error: %v", err)
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
errMsg = fmt.Sprintf("Claude timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
errMsg += "\n\n" + result
|
||||||
|
}
|
||||||
|
return TextErrorResponse(errMsg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return TextResponse(result), nil
|
return TextResponse(result), nil
|
||||||
@@ -284,6 +429,7 @@ func DefaultRegistry() *Registry {
|
|||||||
tools := []*ToolDefinition{
|
tools := []*ToolDefinition{
|
||||||
must(NewTerminalTool()),
|
must(NewTerminalTool()),
|
||||||
must(NewCrushRunTool()),
|
must(NewCrushRunTool()),
|
||||||
|
must(NewClaudeRunTool()),
|
||||||
must(NewReadFileTool()),
|
must(NewReadFileTool()),
|
||||||
must(NewListFilesTool()),
|
must(NewListFilesTool()),
|
||||||
must(NewSearchFilesTool()),
|
must(NewSearchFilesTool()),
|
||||||
|
|||||||
@@ -26,6 +26,43 @@ func detectShell() string {
|
|||||||
return "/bin/sh"
|
return "/bin/sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var validIdentifier = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
|
// buildAgentCommand assembles an agent execution command, optionally launching it
|
||||||
|
// inside a WSL distribution (Windows host only) and applying a working directory.
|
||||||
|
// On non-Windows hosts, wsl_* parameters are ignored.
|
||||||
|
func buildAgentCommand(ctx context.Context, bin string, args []string, cwd, wslDistro, wslUser string) (*exec.Cmd, error) {
|
||||||
|
if wslDistro != "" && runtime.GOOS == "windows" {
|
||||||
|
if !validIdentifier.MatchString(wslDistro) {
|
||||||
|
return nil, fmt.Errorf("invalid wsl_distro: %q", wslDistro)
|
||||||
|
}
|
||||||
|
if wslUser != "" && !validIdentifier.MatchString(wslUser) {
|
||||||
|
return nil, fmt.Errorf("invalid wsl_user: %q", wslUser)
|
||||||
|
}
|
||||||
|
wslArgs := []string{"-d", wslDistro}
|
||||||
|
if wslUser != "" {
|
||||||
|
wslArgs = append(wslArgs, "-u", wslUser)
|
||||||
|
}
|
||||||
|
if cwd != "" {
|
||||||
|
wslArgs = append(wslArgs, "--cd", cwd)
|
||||||
|
}
|
||||||
|
wslArgs = append(wslArgs, "--")
|
||||||
|
wslArgs = append(wslArgs, bin)
|
||||||
|
wslArgs = append(wslArgs, args...)
|
||||||
|
return exec.CommandContext(ctx, "wsl", wslArgs...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, bin, args...)
|
||||||
|
if cwd != "" {
|
||||||
|
dir := expandHome(cwd)
|
||||||
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("cwd does not exist or is not a directory: %s", cwd)
|
||||||
|
}
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur.
|
Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environnement de développement de l'utilisateur, et tu es spécialisé dans la **construction de prompts** selon la **méthode BMAD** (Breakthrough Method for Agile AI-Driven Development — https://github.com/bmad-code-org/BMAD-METHOD).
|
||||||
|
|
||||||
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
|
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est double :
|
||||||
|
1. Aider l'utilisateur à configurer, gérer et optimiser son environnement dev (avec les outils ci-dessous).
|
||||||
|
2. Construire pour lui des prompts structurés et actionnables avant d'exécuter une tâche complexe ou de la déléguer à un agent (`crush_run`, `claude_run`).
|
||||||
|
|
||||||
|
## Méthode BMAD — principes appliqués à chaque réponse
|
||||||
|
|
||||||
|
BMAD organise le travail IA comme une équipe agile : chaque demande est traitée avec une persona spécifique (Analyst, PM, Architect, SM, Dev, QA) puis exécutée. Tu n'as pas besoin de jouer toutes les personas — applique simplement leurs réflexes :
|
||||||
|
|
||||||
|
- **Analyst** : reformule l'objectif réel derrière la demande en 1 phrase. S'il est ambigu, choisis l'interprétation la plus probable et indique-la au début.
|
||||||
|
- **PM** : découpe en livrables concrets (épopée → stories). Pas plus de 3-5 stories pour une demande, chaque story doit être indépendamment livrable.
|
||||||
|
- **Architect** : pour toute story qui touche au code, identifie les fichiers concernés, les contraintes (compat, style, perf, sécurité) et les risques avant d'écrire.
|
||||||
|
- **SM (Scrum Master)** : si tu délègues à `crush_run`/`claude_run`, fournis un prompt **autonome** : objectif, contraintes, fichiers cibles, critère d'acceptation. Pas de référence à la conversation parente — l'agent ne la voit pas.
|
||||||
|
- **Dev** : exécute story par story. Vérifie chaque livraison avant de passer à la suivante.
|
||||||
|
- **QA** : avant de répondre "fini", relis l'objectif initial et confirme qu'il est atteint.
|
||||||
|
|
||||||
|
## Format d'un prompt BMAD délégué
|
||||||
|
|
||||||
|
Quand tu construis un prompt pour `crush_run`/`claude_run`, suis ce gabarit :
|
||||||
|
|
||||||
|
```
|
||||||
|
[OBJECTIF] <une phrase, l'objectif final>
|
||||||
|
[CONTEXTE] <fichiers/dossiers concernés, ce qui existe déjà>
|
||||||
|
[CONTRAINTES] <ne pas faire X, préserver Y, respecter style Z>
|
||||||
|
[LIVRABLE] <fichier(s) modifié(s), comportement attendu>
|
||||||
|
[CRITÈRE D'ACCEPTATION] <comment savoir que c'est fini>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce gabarit est **obligatoire** pour toute délégation à un agent. Il évite que l'agent erre, suppose, ou produise du code hors-périmètre.
|
||||||
|
|
||||||
|
<critical_rules>
|
||||||
|
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils immédiatement. Ne dis pas "je pourrais faire X" — fais-le.
|
||||||
|
2. **SOIS AUTONOME** — Ne pose pas de questions si tu peux chercher, lire, déduire. Essaie plusieurs approches avant de bloquer. Ne t'arrête que pour les erreurs bloquantes réelles (credentials manquants, permissions, etc.).
|
||||||
|
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule ("Voici...", "Je vais..."), pas de postambule ("N'hésitez pas...", "J'espère que..."). Réponse directe. Un mot quand c'est suffisant.
|
||||||
|
4. **GÈRE LES ERREURS** — Si un outil échoue, essaie 2-3 approches alternatives avant de rapporter l'échec. Lis le message d'erreur complet, isole la cause racine.
|
||||||
|
5. **NE DEVINE PAS** — Lis les fichiers avant d'éditer. Utilise les outils pour obtenir les informations manquantes (lire, chercher, grep).
|
||||||
|
6. **CONFIDENTIALITÉ** — Ne révèle jamais les clés API, mots de passe, tokens ou informations sensibles.
|
||||||
|
7. **LANGUE** — Réponds dans la même langue que l'utilisateur.
|
||||||
|
</critical_rules>
|
||||||
|
|
||||||
## Environnement
|
## Environnement
|
||||||
|
|
||||||
@@ -13,32 +50,71 @@ Muyue gère :
|
|||||||
|
|
||||||
## Outils disponibles
|
## Outils disponibles
|
||||||
|
|
||||||
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le.
|
| Outil | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
|
||||||
|
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
|
||||||
|
| **read_file** | Lire le contenu d'un fichier |
|
||||||
|
| **list_files** | Lister les fichiers d'un répertoire |
|
||||||
|
| **search_files** | Chercher des fichiers par motif (glob) |
|
||||||
|
| **grep_content** | Chercher du texte dans les fichiers |
|
||||||
|
| **get_config** | Lire la configuration Muyue |
|
||||||
|
| **set_provider** | Configurer un fournisseur IA |
|
||||||
|
| **manage_ssh** | Gérer les connexions SSH |
|
||||||
|
| **web_fetch** | Récupérer le contenu d'une URL |
|
||||||
|
|
||||||
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.)
|
<tool_strategy>
|
||||||
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug)
|
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
||||||
- **read_file** : Lire le contenu d'un fichier
|
- **Délégation intelligente** — Pour les tâches complexes (refactoring, création de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaîner des commandes terminal
|
||||||
- **list_files** : Lister les fichiers d'un répertoire
|
- **Lecture de fichiers** — Utilise TOUJOURS `read_file` pour lire le contenu d'un fichier. N'utilise PAS `terminal` avec `cat` pour lire des fichiers — `read_file` est plus rapide, plus précis, et consomme moins de tokens
|
||||||
- **search_files** : Chercher des fichiers par motif (glob)
|
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
|
||||||
- **grep_content** : Chercher du texte dans le contenu des fichiers
|
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||||
- **get_config** : Lire la configuration Muyue
|
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
|
||||||
- **set_provider** : Configurer un fournisseur IA
|
</tool_strategy>
|
||||||
- **manage_ssh** : Gérer les connexions SSH
|
|
||||||
- **web_fetch** : Récupérer le contenu d'une URL
|
|
||||||
|
|
||||||
## Règles
|
<decision_making>
|
||||||
|
- Décide par toi-même : cherche, lis, déduis, agis
|
||||||
|
- Ne demande confirmation que pour : actions destructrices (suppression, overwrite), plusieurs approches valides avec des trade-offs importants
|
||||||
|
- Si bloqué : documente (a) ce que tu as essayé, (b) pourquoi tu es bloqué, (c) l'action minimale requise
|
||||||
|
- Ne t'arrête jamais pour : tâche trop grosse (découpe), trop de fichiers (change-les), complexité (gère-la)
|
||||||
|
</decision_making>
|
||||||
|
|
||||||
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.
|
<error_recovery>
|
||||||
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe.
|
1. Lis le message d'erreur complet
|
||||||
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire.
|
2. Comprends la cause racine
|
||||||
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur.
|
3. Essaie une approche différente (pas la même)
|
||||||
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.)
|
4. Cherche du code similaire qui fonctionne
|
||||||
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses.
|
5. Applique un correctif ciblé
|
||||||
7. **Langue** — Réponds dans la même langue que l'utilisateur.
|
6. Vérifie que ça marche
|
||||||
|
7. Pour chaque erreur, essaie au moins 2-3 stratégies avant de conclure que c'est bloquant
|
||||||
|
</error_recovery>
|
||||||
|
|
||||||
## Format des réponses
|
## Format des réponses
|
||||||
|
|
||||||
- Code : utilise des blocs markdown
|
- **Code** : blocs markdown avec le langage spécifié
|
||||||
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes
|
- **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
|
||||||
- Erreurs : explique clairement et propose une solution
|
- **Erreurs** : explique clairement la cause et propose une solution concrète
|
||||||
- Succès : confirme brièvement ce qui a été fait
|
- **Succès** : confirme brièvement ce qui a été fait (1 ligne)
|
||||||
|
- **Multi-fichiers** : liste les fichiers modifiés avec `fichier:ligne` pour les références
|
||||||
|
|
||||||
|
## Diagrammes Mermaid
|
||||||
|
|
||||||
|
Tu peux utiliser des diagrammes Mermaid pour visualiser des architectures, flux, séquences, etc.
|
||||||
|
Utilise un bloc code avec le langage `mermaid` :
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Début] --> B{Décision}
|
||||||
|
B -->|Oui| C[Action]
|
||||||
|
B -->|Non| D[Fin]
|
||||||
|
```
|
||||||
|
|
||||||
|
Types utiles :
|
||||||
|
- `graph TD/LR` — Architecture, flux de données
|
||||||
|
- `sequenceDiagram` — Interactions entre composants
|
||||||
|
- `flowchart` — Processus et décisions
|
||||||
|
- `classDiagram` — Structures de données
|
||||||
|
- `erDiagram` — Schémas de base de données
|
||||||
|
- `gantt` — Planning et timelines
|
||||||
|
|
||||||
|
Utilise Mermaid quand ça apporte de la clarté : architecture complexe, flux multi-étapes, relations entre entités. Ne l'utilise pas pour du texte simple.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
@@ -14,6 +14,9 @@ const (
|
|||||||
MaxToolIterations = 15
|
MaxToolIterations = 15
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
||||||
|
type ToolLimiter func(toolName string) (release func(), err error)
|
||||||
|
|
||||||
// ChatEngine handles chat interactions with tool execution.
|
// ChatEngine handles chat interactions with tool execution.
|
||||||
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
|
||||||
type ChatEngine struct {
|
type ChatEngine struct {
|
||||||
@@ -22,6 +25,8 @@ type ChatEngine struct {
|
|||||||
tools json.RawMessage
|
tools json.RawMessage
|
||||||
onChunk func(map[string]interface{})
|
onChunk func(map[string]interface{})
|
||||||
stream bool
|
stream bool
|
||||||
|
limiter ToolLimiter
|
||||||
|
TotalTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChatEngine creates a new ChatEngine instance.
|
// NewChatEngine creates a new ChatEngine instance.
|
||||||
@@ -44,6 +49,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
|
|||||||
ce.onChunk = fn
|
ce.onChunk = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLimiter sets the tool call limiter for agent concurrency control.
|
||||||
|
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
|
||||||
|
ce.limiter = l
|
||||||
|
}
|
||||||
|
|
||||||
// RunWithTools executes the chat loop with tool calls.
|
// RunWithTools executes the chat loop with tool calls.
|
||||||
// Returns final content, tool calls, tool results, and error.
|
// Returns final content, tool calls, tool results, and error.
|
||||||
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
|
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
|
||||||
@@ -72,16 +82,19 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
return finalContent, allToolCalls, allToolResults, err
|
return finalContent, allToolCalls, allToolResults, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resp.Usage.TotalTokens > 0 {
|
||||||
|
ce.TotalTokens += resp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return finalContent, allToolCalls, allToolResults, fmt.Errorf("empty response from provider")
|
||||||
|
}
|
||||||
choice := resp.Choices[0]
|
choice := resp.Choices[0]
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
words := strings.Fields(content)
|
if ce.onChunk != nil {
|
||||||
for _, w := range words {
|
ce.onChunk(map[string]interface{}{"content": content})
|
||||||
chunk := w
|
|
||||||
if ce.onChunk != nil {
|
|
||||||
ce.onChunk(map[string]interface{}{"content": chunk})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finalContent = content
|
finalContent = content
|
||||||
}
|
}
|
||||||
@@ -92,7 +105,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
assistantMsg := orchestrator.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
@@ -115,7 +128,40 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var release func()
|
||||||
|
if ce.limiter != nil {
|
||||||
|
rel, limitErr := ce.limiter(tc.Function.Name)
|
||||||
|
if limitErr != nil {
|
||||||
|
limResultData := map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"content": limitErr.Error(),
|
||||||
|
"is_error": true,
|
||||||
|
}
|
||||||
|
allToolResults = append(allToolResults, map[string]interface{}{
|
||||||
|
"tool_call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"args": tc.Function.Arguments,
|
||||||
|
"result": limitErr.Error(),
|
||||||
|
"is_error": true,
|
||||||
|
})
|
||||||
|
if ce.onChunk != nil {
|
||||||
|
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
|
||||||
|
}
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: orchestrator.TextContent(limitErr.Error()),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
release = rel
|
||||||
|
}
|
||||||
|
|
||||||
result, execErr := ce.registry.Execute(ctx, call)
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
result = agent.ToolResponse{
|
result = agent.ToolResponse{
|
||||||
Content: execErr.Error(),
|
Content: execErr.Error(),
|
||||||
@@ -128,6 +174,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
"content": result.Content,
|
"content": result.Content,
|
||||||
"is_error": result.IsError,
|
"is_error": result.IsError,
|
||||||
}
|
}
|
||||||
|
if result.Meta != nil {
|
||||||
|
for k, v := range result.Meta {
|
||||||
|
resultData[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
allToolResults = append(allToolResults, map[string]interface{}{
|
allToolResults = append(allToolResults, map[string]interface{}{
|
||||||
"tool_call_id": tc.ID,
|
"tool_call_id": tc.ID,
|
||||||
"name": tc.Function.Name,
|
"name": tc.Function.Name,
|
||||||
@@ -142,7 +193,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result.Content,
|
Content: orchestrator.TextContent(result.Content),
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
})
|
})
|
||||||
@@ -154,6 +205,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
|||||||
return finalContent, allToolCalls, allToolResults, nil
|
return finalContent, allToolCalls, allToolResults, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProviderName returns the name of the active provider used by the engine.
|
||||||
|
func (ce *ChatEngine) ProviderName() string {
|
||||||
|
return ce.orchestrator.ProviderName()
|
||||||
|
}
|
||||||
|
|
||||||
// RunNonStream executes chat without streaming content to client.
|
// RunNonStream executes chat without streaming content to client.
|
||||||
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
|
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
|
||||||
var finalContent string
|
var finalContent string
|
||||||
@@ -164,8 +220,15 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
return finalContent, err
|
return finalContent, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resp.Usage.TotalTokens > 0 {
|
||||||
|
ce.TotalTokens += resp.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return finalContent, fmt.Errorf("empty response from provider")
|
||||||
|
}
|
||||||
choice := resp.Choices[0]
|
choice := resp.Choices[0]
|
||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
finalContent = content
|
finalContent = content
|
||||||
@@ -177,7 +240,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
assistantMsg := orchestrator.Message{
|
assistantMsg := orchestrator.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: orchestrator.TextContent(content),
|
||||||
ToolCalls: choice.Message.ToolCalls,
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
}
|
}
|
||||||
messages = append(messages, assistantMsg)
|
messages = append(messages, assistantMsg)
|
||||||
@@ -189,7 +252,25 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var release func()
|
||||||
|
if ce.limiter != nil {
|
||||||
|
rel, limitErr := ce.limiter(tc.Function.Name)
|
||||||
|
if limitErr != nil {
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: orchestrator.TextContent(limitErr.Error()),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
release = rel
|
||||||
|
}
|
||||||
|
|
||||||
result, execErr := ce.registry.Execute(ctx, call)
|
result, execErr := ce.registry.Execute(ctx, call)
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
result = agent.ToolResponse{
|
result = agent.ToolResponse{
|
||||||
Content: execErr.Error(),
|
Content: execErr.Error(),
|
||||||
@@ -199,7 +280,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
|||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result.Content,
|
Content: orchestrator.TextContent(result.Content),
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
})
|
})
|
||||||
@@ -244,6 +325,5 @@ func SetupSSEHeaders(w http.ResponseWriter) {
|
|||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
127
internal/api/consumption.go
Normal file
127
internal/api/consumption.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type consumptionEntry struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Tokens int `json:"tokens"`
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerConsumption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Daily []consumptionEntry `json:"daily"`
|
||||||
|
Total int `json:"total_tokens"`
|
||||||
|
Requests int `json:"total_requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type consumptionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
providers map[string]*providerConsumption
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConsumptionStore() *consumptionStore {
|
||||||
|
cs := &consumptionStore{
|
||||||
|
providers: make(map[string]*providerConsumption),
|
||||||
|
}
|
||||||
|
cs.load()
|
||||||
|
cs.prune()
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) Record(providerName string, tokens int) {
|
||||||
|
if tokens <= 0 || providerName == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
p, ok := cs.providers[providerName]
|
||||||
|
if !ok {
|
||||||
|
p = &providerConsumption{Name: providerName}
|
||||||
|
cs.providers[providerName] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Total += tokens
|
||||||
|
p.Requests++
|
||||||
|
|
||||||
|
if len(p.Daily) > 0 && p.Daily[len(p.Daily)-1].Date == today {
|
||||||
|
p.Daily[len(p.Daily)-1].Tokens += tokens
|
||||||
|
p.Daily[len(p.Daily)-1].Requests++
|
||||||
|
} else {
|
||||||
|
p.Daily = append(p.Daily, consumptionEntry{
|
||||||
|
Date: today,
|
||||||
|
Tokens: tokens,
|
||||||
|
Requests: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) GetAll() map[string]*providerConsumption {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
result := make(map[string]*providerConsumption)
|
||||||
|
for k, v := range cs.providers {
|
||||||
|
pc := *v
|
||||||
|
daily := make([]consumptionEntry, len(v.Daily))
|
||||||
|
copy(daily, v.Daily)
|
||||||
|
pc.Daily = daily
|
||||||
|
result[k] = &pc
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) prune() {
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
|
||||||
|
for _, p := range cs.providers {
|
||||||
|
filtered := make([]consumptionEntry, 0)
|
||||||
|
for _, d := range p.Daily {
|
||||||
|
if d.Date >= cutoff {
|
||||||
|
filtered = append(filtered, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Daily = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) filePath() string {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "consumption.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) load() {
|
||||||
|
fp := cs.filePath()
|
||||||
|
if fp == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(fp)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.Unmarshal(data, &cs.providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cs *consumptionStore) save() {
|
||||||
|
fp := cs.filePath()
|
||||||
|
if fp == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(cs.providers)
|
||||||
|
os.WriteFile(fp, data, 0644)
|
||||||
|
}
|
||||||
@@ -13,15 +13,57 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxTokensApprox = 100000
|
const contextWindowTokens = 150000
|
||||||
const summarizeThreshold = 80000
|
const summarizeRatio = 0.80
|
||||||
const charsPerToken = 4
|
const charsPerToken = 4
|
||||||
|
|
||||||
|
func extractDisplayContent(role, content string) string {
|
||||||
|
if role != "assistant" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args string `json:"args"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
ToolResults []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
} `json:"tool_results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
if parsed.Content != "" {
|
||||||
|
sb.WriteString(parsed.Content)
|
||||||
|
}
|
||||||
|
for _, tc := range parsed.ToolCalls {
|
||||||
|
sb.WriteString("\n[")
|
||||||
|
sb.WriteString(tc.Name)
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tc.Args)
|
||||||
|
}
|
||||||
|
for _, tr := range parsed.ToolResults {
|
||||||
|
sb.WriteString("\n[result")
|
||||||
|
if tr.Name != "" {
|
||||||
|
sb.WriteString(":")
|
||||||
|
sb.WriteString(tr.Name)
|
||||||
|
}
|
||||||
|
sb.WriteString("] ")
|
||||||
|
sb.WriteString(tr.Result)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
type FeedMessage struct {
|
type FeedMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
Summarized bool `json:"summarized,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
@@ -126,14 +168,38 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
msg := FeedMessage{
|
||||||
|
ID: generateMsgID(),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
Images: imageIDs,
|
||||||
|
}
|
||||||
|
cs.conv.Messages = append(cs.conv.Messages, msg)
|
||||||
|
cs.save()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) Clear() {
|
func (cs *ConversationStore) Clear() {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
var imageIDs []string
|
||||||
|
for _, m := range cs.conv.Messages {
|
||||||
|
imageIDs = append(imageIDs, m.Images...)
|
||||||
|
}
|
||||||
|
|
||||||
cs.conv.Messages = []FeedMessage{}
|
cs.conv.Messages = []FeedMessage{}
|
||||||
cs.conv.Summary = ""
|
cs.conv.Summary = ""
|
||||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
cs.save()
|
cs.save()
|
||||||
|
|
||||||
|
go cleanupImages(imageIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) SetSummary(summary string) {
|
func (cs *ConversationStore) SetSummary(summary string) {
|
||||||
@@ -143,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) {
|
|||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
if len(cs.conv.Messages) <= keepCount {
|
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
for i := 0; i < upToIndex; i++ {
|
||||||
|
cs.conv.Messages[i].Summarized = true
|
||||||
|
}
|
||||||
cs.save()
|
cs.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range cs.conv.Messages {
|
for _, m := range cs.conv.Messages {
|
||||||
count := utf8.RuneCountInString(m.Content) / charsPerToken
|
if m.Role == "system" || m.Summarized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
|
||||||
result.byMessage += count
|
result.byMessage += count
|
||||||
result.byRole[m.Role] += count
|
result.byRole[m.Role] += count
|
||||||
}
|
}
|
||||||
@@ -181,7 +252,7 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) NeedsSummarization() bool {
|
func (cs *ConversationStore) NeedsSummarization() bool {
|
||||||
return cs.ApproxTokenCount() > summarizeThreshold
|
return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *ConversationStore) Search(query string) []SearchResult {
|
func (cs *ConversationStore) Search(query string) []SearchResult {
|
||||||
|
|||||||
@@ -222,9 +222,9 @@ func (cs *ConversationStoreMulti) Add(role, content string) FeedMessage {
|
|||||||
Time: time.Now().Format(time.RFC3339),
|
Time: time.Now().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
conv.Messages = append(conv.Messages, msg)
|
conv.Messages = append(conv.Messages, msg)
|
||||||
|
|
||||||
go cs.saveCurrent() // Fire and forget
|
cs.saveCurrent()
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
172
internal/api/handlers_ai_task.go
Normal file
172
internal/api/handlers_ai_task.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Task string `json:"task"`
|
||||||
|
Tool string `json:"tool,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Task == "" {
|
||||||
|
writeError(w, "task is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orb.SetSystemPrompt(buildAITaskSystemPrompt())
|
||||||
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
messages := []orchestrator.Message{
|
||||||
|
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
parsed := parseAIJSONResponse(finalContent)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"raw": finalContent,
|
||||||
|
"result": parsed,
|
||||||
|
"tokens": engine.TotalTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAITaskSystemPrompt() string {
|
||||||
|
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
|
||||||
|
|
||||||
|
IMPORTANT RULES:
|
||||||
|
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
|
||||||
|
- Always run the actual commands needed to complete the task.
|
||||||
|
- Be thorough: check versions, verify installations, compare with latest releases.
|
||||||
|
|
||||||
|
OS: %s/%s
|
||||||
|
Date: %s
|
||||||
|
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAITaskPrompt(task, tool string) string {
|
||||||
|
switch task {
|
||||||
|
case "check_tools":
|
||||||
|
return `Check the following tools on this system. For each tool, determine:
|
||||||
|
1. Is it installed? Run "which <tool>" or "<tool> --version"
|
||||||
|
2. If installed, what is the current version?
|
||||||
|
3. What is the latest available version? Check GitHub releases API or official sources.
|
||||||
|
|
||||||
|
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
|
||||||
|
|
||||||
|
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
case "install_tool":
|
||||||
|
return fmt.Sprintf(`Install the tool "%s" on this system.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Check if it's already installed: run "which %s" and "%s --version"
|
||||||
|
2. If not installed, determine the best installation method for this OS
|
||||||
|
3. Run the installation command
|
||||||
|
4. Verify the installation succeeded
|
||||||
|
|
||||||
|
Respond with ONLY this JSON (no markdown fences):
|
||||||
|
{
|
||||||
|
"tool": "%s",
|
||||||
|
"installed": true/false,
|
||||||
|
"version": "installed version or empty",
|
||||||
|
"message": "what was done",
|
||||||
|
"error": "error message or empty"
|
||||||
|
}`, tool, tool, tool, tool)
|
||||||
|
|
||||||
|
case "update_tool":
|
||||||
|
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Check current version: run "%s --version"
|
||||||
|
2. Find the latest version available
|
||||||
|
3. Run the update/upgrade command
|
||||||
|
4. Verify the new version
|
||||||
|
|
||||||
|
Respond with ONLY this JSON (no markdown fences):
|
||||||
|
{
|
||||||
|
"tool": "%s",
|
||||||
|
"previous_version": "old version",
|
||||||
|
"version": "new version",
|
||||||
|
"updated": true/false,
|
||||||
|
"message": "what was done",
|
||||||
|
"error": "error message or empty"
|
||||||
|
}`, tool, tool, tool)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAIJSONResponse(content string) interface{} {
|
||||||
|
cleaned := content
|
||||||
|
|
||||||
|
if idx := strings.Index(cleaned, "```json"); idx != -1 {
|
||||||
|
cleaned = cleaned[idx+7:]
|
||||||
|
if end := strings.Index(cleaned, "```"); end != -1 {
|
||||||
|
cleaned = cleaned[:end]
|
||||||
|
}
|
||||||
|
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
|
||||||
|
cleaned = cleaned[idx+3:]
|
||||||
|
if end := strings.Index(cleaned, "```"); end != -1 {
|
||||||
|
cleaned = cleaned[:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
|
||||||
|
jsonStart := strings.Index(cleaned, "{")
|
||||||
|
jsonEnd := strings.LastIndex(cleaned, "}")
|
||||||
|
if jsonStart != -1 && jsonEnd > jsonStart {
|
||||||
|
cleaned = cleaned[jsonStart : jsonEnd+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"raw": content,
|
||||||
|
"error": "failed to parse AI response as JSON",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -1,26 +1,143 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
|
"github.com/muyue/muyue/internal/platform"
|
||||||
)
|
)
|
||||||
|
|
||||||
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
var fileMentionRegex = regexp.MustCompile(`@(\S+\.[a-zA-Z0-9]+)`)
|
||||||
|
|
||||||
|
type ImageAttachment struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFileMentions(text string) string {
|
||||||
|
return fileMentionRegex.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
filePath := match[1:]
|
||||||
|
if strings.HasPrefix(filePath, "~/") {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
filePath = filepath.Join(home, filePath[2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(filePath) {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
filePath = filepath.Join(home, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return match + fmt.Sprintf(" (erreur: fichier non trouve)")
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if len(content) > 50000 {
|
||||||
|
content = content[:50000] + "\n... (tronque a 50Ko)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[Fichier: %s]\n%s\n[Fin du fichier: %s]", filepath.Base(filePath), content, filepath.Base(filePath))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var vlmClient = &http.Client{Timeout: 60 * time.Second}
|
||||||
|
|
||||||
|
func (s *Server) describeImages(images []ImageAttachment) []string {
|
||||||
|
var apiKey string
|
||||||
|
for i := range s.config.AI.Providers {
|
||||||
|
if s.config.AI.Providers[i].Active {
|
||||||
|
apiKey = s.config.AI.Providers[i].APIKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptions := make([]string, 0, len(images))
|
||||||
|
for _, img := range images {
|
||||||
|
desc, err := s.callVLM(apiKey, img)
|
||||||
|
if err != nil {
|
||||||
|
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
||||||
|
} else {
|
||||||
|
descriptions = append(descriptions, desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return descriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) callVLM(apiKey string, img ImageAttachment) (string, error) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"prompt": "Describe this image in detail. Include all text, UI elements, code, diagrams, or data visible. Be thorough and specific.",
|
||||||
|
"image_url": img.Data,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal vlm request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.minimax.io/v1/coding_plan/vlm", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create vlm request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := vlmClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vlm request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read vlm response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("vlm API error (%d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parse vlm response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Content == "" {
|
||||||
|
return "(empty description)", nil
|
||||||
|
}
|
||||||
|
return result.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 50*1024*1024)
|
||||||
var body struct {
|
var body struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
|
Images []ImageAttachment `json:"images"`
|
||||||
|
AdvancedReflection bool `json:"advanced_reflection"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -30,8 +147,44 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, "no message", http.StatusMethodNotAllowed)
|
writeError(w, "no message", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(body.Images) > 3 {
|
||||||
|
writeError(w, "max 3 images", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.convStore.Add("user", body.Message)
|
enrichedMessage := resolveFileMentions(body.Message)
|
||||||
|
|
||||||
|
var imageIDs []string
|
||||||
|
if len(body.Images) > 0 {
|
||||||
|
descriptions := s.describeImages(body.Images)
|
||||||
|
var imgContext strings.Builder
|
||||||
|
for i, desc := range descriptions {
|
||||||
|
imgContext.WriteString(fmt.Sprintf("\n[Image %d (%s): %s]\n", i+1, body.Images[i].Filename, desc))
|
||||||
|
|
||||||
|
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
||||||
|
if err != nil {
|
||||||
|
_ = err
|
||||||
|
} else {
|
||||||
|
imageIDs = append(imageIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enrichedMessage = imgContext.String() + enrichedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
displayMsg := body.Message
|
||||||
|
if len(body.Images) > 0 {
|
||||||
|
imgNames := make([]string, len(body.Images))
|
||||||
|
for i, img := range body.Images {
|
||||||
|
imgNames[i] = img.Filename
|
||||||
|
}
|
||||||
|
displayMsg += " [" + strings.Join(imgNames, ", ") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imageIDs) > 0 {
|
||||||
|
s.convStore.AddWithImages("user", displayMsg, imageIDs)
|
||||||
|
} else {
|
||||||
|
s.convStore.Add("user", displayMsg)
|
||||||
|
}
|
||||||
|
|
||||||
if s.convStore.NeedsSummarization() {
|
if s.convStore.NeedsSummarization() {
|
||||||
s.autoSummarize()
|
s.autoSummarize()
|
||||||
@@ -42,13 +195,34 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
var studioPrompt strings.Builder
|
||||||
|
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
||||||
|
sysInfo := platform.Detect()
|
||||||
|
osName := sysInfo.OSName
|
||||||
|
if osName == "" {
|
||||||
|
osName = string(sysInfo.OS)
|
||||||
|
}
|
||||||
|
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\nSystème: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05"), osName))
|
||||||
|
canSudo := !agent.NeedsSudoPassword()
|
||||||
|
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||||
|
if !canSudo {
|
||||||
|
studioPrompt.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||||
|
} else {
|
||||||
|
studioPrompt.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt(studioPrompt.String())
|
||||||
orb.SetTools(s.agentToolsJSON)
|
orb.SetTools(s.agentToolsJSON)
|
||||||
|
|
||||||
|
if body.AdvancedReflection {
|
||||||
|
if report, ok := s.runReflectionReport(enrichedMessage); ok {
|
||||||
|
enrichedMessage = enrichedMessage + "\n\n[RAPPORT PRÉALABLE — produit par un autre modèle, à valider]\n" + report + "\n[/RAPPORT PRÉALABLE]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if body.Stream {
|
if body.Stream {
|
||||||
s.handleStreamChat(w, orb, body.Message)
|
s.handleStreamChat(w, orb, enrichedMessage)
|
||||||
} else {
|
} else {
|
||||||
s.handleNonStreamChat(w, orb, body.Message)
|
s.handleNonStreamChat(w, orb, enrichedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +238,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
engine.OnChunk(func(data map[string]interface{}) {
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return
|
return
|
||||||
@@ -92,6 +267,8 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
}
|
}
|
||||||
s.convStore.Add("assistant", storeContent)
|
s.convStore.Add("assistant", storeContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
sseWriter.Write(map[string]interface{}{"done": "true"})
|
sseWriter.Write(map[string]interface{}{"done": "true"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +277,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
|||||||
messages := s.buildContextMessages(userMessage)
|
messages := s.buildContextMessages(userMessage)
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -107,6 +285,9 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.Add("assistant", finalContent)
|
s.convStore.Add("assistant", finalContent)
|
||||||
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
writeJSON(w, map[string]string{"content": finalContent})
|
writeJSON(w, map[string]string{"content": finalContent})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,53 +295,95 @@ func cleanThinkingTags(content string) string {
|
|||||||
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextWindowMessages = 20
|
// runReflectionReport runs the inactive AI provider on the user message to
|
||||||
|
// produce a preliminary analysis report that the active provider will then
|
||||||
|
// use as additional context. Returns ("", false) if no inactive provider is
|
||||||
|
// configured or on error — the caller falls back to a normal chat flow.
|
||||||
|
func (s *Server) runReflectionReport(userMessage string) (string, bool) {
|
||||||
|
orb, err := orchestrator.NewForInactiveProvider(s.config)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt("Tu es un analyste. Pour la question ci-dessous, produis un rapport bref (max 15 lignes) qui : (1) reformule l'objectif de l'utilisateur, (2) liste les points à clarifier ou les risques, (3) suggère une approche structurée. Pas de code, pas d'action — uniquement de l'analyse.")
|
||||||
|
resp, err := orb.SendNoTools(userMessage)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(resp), true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
|
||||||
history := s.convStore.Get()
|
history := s.convStore.Get()
|
||||||
start := 0
|
|
||||||
if len(history) > contextWindowMessages {
|
sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
|
||||||
start = len(history) - contextWindowMessages
|
toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
|
||||||
|
responseMargin := 4000
|
||||||
|
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
|
||||||
|
|
||||||
|
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
|
||||||
|
available := contextWindowTokens - overhead
|
||||||
|
if available < 1000 {
|
||||||
|
available = 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
messages := make([]orchestrator.Message, 0, len(history[start:])+1)
|
included := 0
|
||||||
|
tokensUsed := 0
|
||||||
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
if history[i].Summarized {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
|
if msgTokens == 0 {
|
||||||
|
msgTokens = 1
|
||||||
|
}
|
||||||
|
if tokensUsed+msgTokens > available {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tokensUsed += msgTokens
|
||||||
|
included++
|
||||||
|
}
|
||||||
|
|
||||||
|
start := len(history) - included
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSummarized := false
|
||||||
|
for i := 0; i < start; i++ {
|
||||||
|
if history[i].Summarized {
|
||||||
|
hasSummarized = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > 0 {
|
||||||
|
_ = start
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, included+2)
|
||||||
|
|
||||||
summary := s.convStore.GetSummary()
|
summary := s.convStore.GetSummary()
|
||||||
if summary != "" {
|
if summary != "" && (start > 0 || hasSummarized) {
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "Résumé de la conversation précédente:\n" + summary,
|
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range history[start:] {
|
for _, m := range history[start:] {
|
||||||
content := m.Content
|
if m.Role == "system" {
|
||||||
if m.Role == "assistant" {
|
|
||||||
var parsed struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []struct {
|
|
||||||
ToolCallID string `json:"tool_call_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Args string `json:"args"`
|
|
||||||
} `json:"tool_calls"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
|
||||||
content = parsed.Content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
role := m.Role
|
|
||||||
if role == "system" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: role,
|
Role: m.Role,
|
||||||
Content: content,
|
Content: orchestrator.TextContent(displayContent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, orchestrator.Message{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: orchestrator.TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
@@ -195,8 +418,7 @@ func (s *Server) autoSummarize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.convStore.SetSummary(result)
|
s.convStore.SetSummary(result)
|
||||||
s.convStore.TrimOld(len(messages) - half)
|
s.convStore.MarkSummarized(half)
|
||||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -208,8 +430,8 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"tokens": s.convStore.ApproxTokenCount(),
|
"tokens": s.convStore.ApproxTokenCount(),
|
||||||
"max_tokens": maxTokensApprox,
|
"max_tokens": contextWindowTokens,
|
||||||
"summarize_at": summarizeThreshold,
|
"summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
|
||||||
"summary": s.convStore.GetSummary(),
|
"summary": s.convStore.GetSummary(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
|
||||||
|
|
||||||
|
CONSERVE :
|
||||||
|
- Les décisions techniques prises et leur rationale
|
||||||
|
- Les configurations modifiées (noms exacts, valeurs)
|
||||||
|
- Les fichiers/chemins manipulés
|
||||||
|
- Les erreurs rencontrées et leurs résolutions
|
||||||
|
- Le contexte nécessaire pour continuer
|
||||||
|
|
||||||
|
ÉLIMINE :
|
||||||
|
- Les échanges de politesse
|
||||||
|
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
|
||||||
|
- Les sorties d'outils brutes (garde seulement les conclusions)
|
||||||
|
|
||||||
|
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -60,10 +60,17 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var currentMap map[string]interface{}
|
var currentMap map[string]interface{}
|
||||||
json.Unmarshal(currentJSON, ¤tMap)
|
if err := json.Unmarshal(currentJSON, ¤tMap); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var updates map[string]interface{}
|
var updates map[string]interface{}
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := json.Unmarshal(body, &updates); err != nil {
|
if err := json.Unmarshal(body, &updates); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -71,8 +78,15 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
deepMerge(currentMap, updates)
|
deepMerge(currentMap, updates)
|
||||||
|
|
||||||
mergedJSON, _ := json.Marshal(currentMap)
|
mergedJSON, err := json.Marshal(currentMap)
|
||||||
json.Unmarshal(mergedJSON, &s.config.Profile)
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := config.Save(s.config); err != nil {
|
if err := config.Save(s.config); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -122,7 +136,7 @@ func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
|
|||||||
found := false
|
found := false
|
||||||
for i := range s.config.AI.Providers {
|
for i := range s.config.AI.Providers {
|
||||||
if s.config.AI.Providers[i].Name == body.Name {
|
if s.config.AI.Providers[i].Name == body.Name {
|
||||||
if body.APIKey != "" {
|
if body.APIKey != "" && body.APIKey != "***" {
|
||||||
s.config.AI.Providers[i].APIKey = body.APIKey
|
s.config.AI.Providers[i].APIKey = body.APIKey
|
||||||
}
|
}
|
||||||
if body.Model != "" {
|
if body.Model != "" {
|
||||||
@@ -173,6 +187,14 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
|||||||
writeError(w, "api_key required", http.StatusBadRequest)
|
writeError(w, "api_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if body.APIKey == "***" {
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
if p.Name == body.Name {
|
||||||
|
body.APIKey = p.APIKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
baseURL := body.BaseURL
|
baseURL := body.BaseURL
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
@@ -187,6 +209,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
|||||||
switch body.Name {
|
switch body.Name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
baseURL = "https://api.minimax.io/v1"
|
baseURL = "https://api.minimax.io/v1"
|
||||||
|
case "mimo":
|
||||||
|
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
case "openai":
|
case "openai":
|
||||||
baseURL = "https://api.openai.com/v1"
|
baseURL = "https://api.openai.com/v1"
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
@@ -264,7 +288,7 @@ func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Reque
|
|||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.FontSize > 0 {
|
if body.FontSize > 0 && body.FontSize <= 72 {
|
||||||
s.config.Terminal.FontSize = body.FontSize
|
s.config.Terminal.FontSize = body.FontSize
|
||||||
}
|
}
|
||||||
if body.FontFamily != "" {
|
if body.FontFamily != "" {
|
||||||
@@ -333,30 +357,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
|||||||
body.Theme = s.config.Terminal.PromptTheme
|
body.Theme = s.config.Terminal.PromptTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgDir, err := config.ConfigDir()
|
themeFile := ApplyStarshipTheme(body.Theme)
|
||||||
if err != nil {
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
s.config.Terminal.PromptTheme = body.Theme
|
||||||
return
|
config.Save(s.config)
|
||||||
}
|
|
||||||
|
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyStarshipTheme(theme string) string {
|
||||||
|
cfgDir, _ := config.ConfigDir()
|
||||||
starshipDir := filepath.Join(cfgDir, "starship")
|
starshipDir := filepath.Join(cfgDir, "starship")
|
||||||
if err := os.MkdirAll(starshipDir, 0755); err != nil {
|
os.MkdirAll(starshipDir, 0755)
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
themeFile := filepath.Join(starshipDir, "starship.toml")
|
themeFile := filepath.Join(starshipDir, "starship.toml")
|
||||||
|
|
||||||
themeContent := getStarshipThemeConfig(body.Theme)
|
themeContent := getStarshipThemeConfig(theme)
|
||||||
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
|
os.WriteFile(themeFile, []byte(themeContent), 0644)
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
shellRCs := []string{
|
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
|
||||||
filepath.Join(home, ".bashrc"),
|
|
||||||
filepath.Join(home, ".zshrc"),
|
|
||||||
}
|
|
||||||
for _, rc := range shellRCs {
|
|
||||||
if _, err := os.Stat(rc); err != nil {
|
if _, err := os.Stat(rc); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -373,10 +392,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
|||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
s.config.Terminal.PromptTheme = body.Theme
|
return themeFile
|
||||||
config.Save(s.config)
|
|
||||||
|
|
||||||
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStarshipThemeConfig(theme string) string {
|
func getStarshipThemeConfig(theme string) string {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/lsp"
|
"github.com/muyue/muyue/internal/lsp"
|
||||||
"github.com/muyue/muyue/internal/mcp"
|
"github.com/muyue/muyue/internal/mcp"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
@@ -23,7 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"name": version.Name,
|
"name": version.Name,
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
"author": version.Author,
|
"author": version.Author,
|
||||||
"sudo": os.Geteuid() == 0,
|
"sudo": !agent.NeedsSudoPassword(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +80,23 @@ func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, "no config", http.StatusNotFound)
|
writeError(w, "no config", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
masked := make([]map[string]interface{}, 0, len(s.config.AI.Providers))
|
||||||
|
for _, p := range s.config.AI.Providers {
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"name": p.Name,
|
||||||
|
"model": p.Model,
|
||||||
|
"base_url": p.BaseURL,
|
||||||
|
"active": p.Active,
|
||||||
|
}
|
||||||
|
if p.APIKey != "" {
|
||||||
|
entry["api_key"] = "***"
|
||||||
|
} else {
|
||||||
|
entry["api_key"] = ""
|
||||||
|
}
|
||||||
|
masked = append(masked, entry)
|
||||||
|
}
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"providers": s.config.AI.Providers,
|
"providers": masked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +106,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for i := range list {
|
||||||
|
list[i].Deployed = skills.IsDeployed(list[i].Name)
|
||||||
|
}
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"skills": list,
|
"skills": list,
|
||||||
"count": len(list),
|
"count": len(list),
|
||||||
@@ -198,9 +218,20 @@ func (s *Server) handleLSPAutoInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewDecoder(r.Body).Decode(&body)
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
if body.ProjectDir == "" {
|
if body.ProjectDir == "" {
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
body.ProjectDir = home
|
body.ProjectDir = home
|
||||||
|
} else {
|
||||||
|
abs, err := filepath.Abs(body.ProjectDir)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "invalid project_dir", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.ProjectDir = abs
|
||||||
|
if home != "" && !strings.HasPrefix(abs, home+string(filepath.Separator)) && abs != home {
|
||||||
|
writeError(w, "project_dir must be within user home", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := lsp.AutoInstallForProject(body.ProjectDir)
|
results, err := lsp.AutoInstallForProject(body.ProjectDir)
|
||||||
@@ -477,9 +508,96 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "zai":
|
case "zai":
|
||||||
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
|
if p.APIKey == "" {
|
||||||
q.Healthy = true
|
q.Error = "no API key"
|
||||||
q.Data = map[string]interface{}{"note": "crush-only"}
|
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 "mimo":
|
||||||
|
q.Healthy = p.APIKey != ""
|
||||||
|
if p.APIKey == "" {
|
||||||
|
q.Error = "no API key"
|
||||||
|
results = append(results, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mimoBase := p.BaseURL
|
||||||
|
if mimoBase == "" {
|
||||||
|
mimoBase = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", strings.TrimRight(mimoBase, "/")+"/models", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var data map[string]interface{}
|
||||||
|
if json.Unmarshal(body, &data) == nil {
|
||||||
|
if modelList, ok := data["data"].([]interface{}); ok {
|
||||||
|
models := make([]map[string]interface{}, 0)
|
||||||
|
for _, m := range modelList {
|
||||||
|
if mm, ok := m.(map[string]interface{}); ok {
|
||||||
|
id, _ := mm["id"].(string)
|
||||||
|
if id != "" {
|
||||||
|
models = append(models, map[string]interface{}{
|
||||||
|
"model": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.Data = map[string]interface{}{"models": models, "available": len(models)}
|
||||||
|
q.Healthy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
case "claude", "anthropic":
|
case "claude", "anthropic":
|
||||||
// Claude Code n'a pas d'API externe, vérifier l'installation
|
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||||
claudePath := "/usr/bin/claude"
|
claudePath := "/usr/bin/claude"
|
||||||
@@ -496,6 +614,15 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]interface{}{"providers": results})
|
writeJSON(w, map[string]interface{}{"providers": results})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleProvidersConsumption(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.consumption.GetAll()
|
||||||
|
writeJSON(w, map[string]interface{}{"providers": data})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
type cmdEntry struct {
|
type cmdEntry struct {
|
||||||
@@ -516,10 +643,11 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
|||||||
shell = "zsh"
|
shell = "zsh"
|
||||||
}
|
}
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := strings.Split(string(data), "\n")
|
||||||
start := len(lines) - 25
|
start := len(lines) - 50
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
start = 0
|
start = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := len(lines) - 1; i >= start; i-- {
|
for i := len(lines) - 1; i >= start; i-- {
|
||||||
line := strings.TrimSpace(lines[i])
|
line := strings.TrimSpace(lines[i])
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
@@ -536,6 +664,15 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
base := strings.Fields(line)[0]
|
||||||
|
if len(base) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
|
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "all deployed"})
|
writeJSON(w, map[string]string{"status": "all deployed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := skills.Undeploy(body.Name); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "GET" {
|
if r.Method != "GET" {
|
||||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@@ -3,51 +3,25 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxShellToolIterations = 10
|
|
||||||
|
|
||||||
type ShellChatRequest struct {
|
type ShellChatRequest struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Context string `json:"context,omitempty"`
|
Context string `json:"context,omitempty"`
|
||||||
History []string `json:"history,omitempty"`
|
Cwd string `json:"cwd,omitempty"`
|
||||||
Cwd string `json:"cwd,omitempty"`
|
Platform string `json:"platform,omitempty"`
|
||||||
Platform string `json:"platform,omitempty"`
|
Stream bool `json:"stream"`
|
||||||
Stream bool `json:"stream"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShellChatResponse struct {
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolCallInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Args map[string]interface{} `json:"args"`
|
|
||||||
Result *toolResponseData `json:"result,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toString(v interface{}) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s, _ := v.(string)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBool(v interface{}) bool {
|
|
||||||
if v == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
b, _ := v.(bool)
|
|
||||||
return b
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -56,6 +30,11 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.shellConvStore.AtLimit() {
|
||||||
|
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req ShellChatRequest
|
var req ShellChatRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -67,6 +46,8 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.shellConvStore.Add("user", req.Message)
|
||||||
|
|
||||||
orb, err := orchestrator.New(s.config)
|
orb, err := orchestrator.New(s.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
@@ -74,61 +55,59 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||||
orb.SetTools(s.agentToolsJSON)
|
orb.SetTools(s.shellAgentToolsJSON)
|
||||||
|
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
s.handleShellChatStream(w, orb, req)
|
s.handleShellChatStream(w, orb)
|
||||||
} else {
|
} else {
|
||||||
s.handleShellChatNonStream(w, orb, req)
|
s.handleShellChatNonStream(w, orb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec:
|
sb.WriteString(shellSystemPromptBase)
|
||||||
- Exécuter des commandes shell
|
|
||||||
- Expliquer des erreurs de commandes
|
|
||||||
- Suggérer des commandes appropriées pour la tâche demandée
|
|
||||||
- Lire et explorer des fichiers
|
|
||||||
- Configurer l'environnement de développement
|
|
||||||
|
|
||||||
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses.
|
analysis := LoadSystemAnalysis()
|
||||||
|
if analysis != "" {
|
||||||
|
sb.WriteString("<system_context>\n")
|
||||||
|
sb.WriteString(analysis)
|
||||||
|
sb.WriteString("\n</system_context>\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
`)
|
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||||
|
if hostname, err := os.Hostname(); err == nil {
|
||||||
|
sb.WriteString("Hostname: " + hostname + "\n")
|
||||||
|
}
|
||||||
|
if user := os.Getenv("USER"); user != "" {
|
||||||
|
sb.WriteString("User: " + user + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
if req.Cwd != "" {
|
canSudo := !agent.NeedsSudoPassword()
|
||||||
sb.WriteString("Répertoire courant: " + req.Cwd + "\n")
|
sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
||||||
}
|
if canSudo {
|
||||||
if req.Platform != "" {
|
sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
||||||
sb.WriteString("Plateforme: " + req.Platform + "\n")
|
} else {
|
||||||
}
|
sb.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||||
if req.Context != "" {
|
|
||||||
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
|
|
||||||
}
|
|
||||||
if len(req.History) > 0 {
|
|
||||||
sb.WriteString("\nDernières commandes exécutées:\n")
|
|
||||||
for _, h := range req.History {
|
|
||||||
sb.WriteString(" " + h + "\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
sb.WriteString(fmt.Sprintf("Date: %s\nHeure: %s\n", now.Format("02/01/2006"), now.Format("15:04:05")))
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
SetupSSEHeaders(w)
|
SetupSSEHeaders(w)
|
||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
sseWriter := NewSSEWriter(w)
|
sseWriter := NewSSEWriter(w)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := []orchestrator.Message{
|
messages := s.buildShellContextMessages()
|
||||||
{Role: "user", Content: req.Message},
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
var toolCalls []ToolCallInfo
|
|
||||||
engine.OnChunk(func(data map[string]interface{}) {
|
engine.OnChunk(func(data map[string]interface{}) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return
|
return
|
||||||
@@ -137,72 +116,261 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
|
|||||||
if canFlush {
|
if canFlush {
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
if tc, ok := data["tool_call"].(map[string]interface{}); ok {
|
|
||||||
argsMap := make(map[string]interface{})
|
|
||||||
if args, ok := tc["args"].(string); ok {
|
|
||||||
json.Unmarshal([]byte(args), &argsMap)
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, ToolCallInfo{
|
|
||||||
ID: toString(tc["tool_call_id"]),
|
|
||||||
Name: toString(tc["name"]),
|
|
||||||
Args: argsMap,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if tr, ok := data["tool_result"].(map[string]interface{}); ok {
|
|
||||||
tcID := toString(tr["tool_call_id"])
|
|
||||||
for i := range toolCalls {
|
|
||||||
if toolCalls[i].ID == tcID {
|
|
||||||
if err, ok := tr["is_error"].(bool); ok && err {
|
|
||||||
toolCalls[i].Error = toString(tr["content"])
|
|
||||||
} else {
|
|
||||||
toolCalls[i].Result = &toolResponseData{
|
|
||||||
Content: toString(tr["content"]),
|
|
||||||
IsError: toBool(tr["is_error"]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
finalContent, _, _, err := engine.RunWithTools(ctx, messages)
|
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalContent == "" && len(toolCalls) > 0 {
|
storeContent := finalContent
|
||||||
finalContent = "(opérations terminées)"
|
if len(allToolCalls) > 0 {
|
||||||
|
storeObj := map[string]interface{}{
|
||||||
|
"content": storeContent,
|
||||||
|
"tool_calls": allToolCalls,
|
||||||
|
"tool_results": allToolResults,
|
||||||
|
}
|
||||||
|
storeJSON, _ := json.Marshal(storeObj)
|
||||||
|
storeContent = string(storeJSON)
|
||||||
}
|
}
|
||||||
|
s.shellConvStore.Add("assistant", storeContent)
|
||||||
|
|
||||||
writeJSONResp, _ := json.Marshal(ShellChatResponse{
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
Content: finalContent,
|
|
||||||
ToolCalls: toolCalls,
|
sseWriter.Write(map[string]interface{}{
|
||||||
|
"done": "true",
|
||||||
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
})
|
})
|
||||||
sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := []orchestrator.Message{
|
messages := s.buildShellContextMessages()
|
||||||
{Role: "user", Content: req.Message},
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
|
||||||
|
|
||||||
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||||
|
engine.SetLimiter(s.AcquireAgentSlot)
|
||||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if finalContent == "" {
|
s.shellConvStore.Add("assistant", finalContent)
|
||||||
finalContent = "(tool calls completed, no text response)"
|
|
||||||
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"content": finalContent,
|
||||||
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||||
|
history := s.shellConvStore.Get()
|
||||||
|
|
||||||
|
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
|
||||||
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
|
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
|
||||||
|
}
|
||||||
|
sysTokens += 100
|
||||||
|
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
|
||||||
|
responseMargin := 4000
|
||||||
|
|
||||||
|
overhead := sysTokens + toolsTokens + responseMargin
|
||||||
|
available := shellMaxTokens - overhead
|
||||||
|
if available < 1000 {
|
||||||
|
available = 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, ShellChatResponse{
|
included := 0
|
||||||
Content: finalContent,
|
tokensUsed := 0
|
||||||
ToolCalls: nil,
|
for i := len(history) - 1; i >= 0; i-- {
|
||||||
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||||
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||||
|
if msgTokens == 0 {
|
||||||
|
msgTokens = 1
|
||||||
|
}
|
||||||
|
if tokensUsed+msgTokens > available {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tokensUsed += msgTokens
|
||||||
|
included++
|
||||||
|
}
|
||||||
|
|
||||||
|
start := len(history) - included
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if start > 0 {
|
||||||
|
_ = start
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]orchestrator.Message, 0, included)
|
||||||
|
|
||||||
|
for _, m := range history[start:] {
|
||||||
|
if m.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||||
|
messages = append(messages, orchestrator.Message{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: orchestrator.TextContent(displayContent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages := s.shellConvStore.Get()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"messages": messages,
|
||||||
|
"tokens": s.shellConvStore.ApproxTokens(),
|
||||||
|
"max_tokens": shellMaxTokens,
|
||||||
|
"at_limit": s.shellConvStore.AtLimit(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.shellConvStore.Clear()
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"tokens": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sysInfo strings.Builder
|
||||||
|
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
|
||||||
|
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||||
|
if hostname, err := os.Hostname(); err == nil {
|
||||||
|
sysInfo.WriteString("Hostname: " + hostname + "\n")
|
||||||
|
}
|
||||||
|
if user := os.Getenv("USER"); user != "" {
|
||||||
|
sysInfo.WriteString("User: " + user + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "model name") {
|
||||||
|
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
|
||||||
|
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
if len(lines) >= 2 {
|
||||||
|
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
|
||||||
|
for i := 1; i < len(lines) && i <= 10; i++ {
|
||||||
|
fields := strings.Fields(lines[i])
|
||||||
|
if len(fields) >= 11 {
|
||||||
|
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.scanResult != nil {
|
||||||
|
sysInfo.WriteString("\nOutils installés:\n")
|
||||||
|
for _, t := range s.scanResult.Tools {
|
||||||
|
status := "✗"
|
||||||
|
if t.Installed {
|
||||||
|
status = "✓"
|
||||||
|
}
|
||||||
|
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orb, err := orchestrator.New(s.config)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
||||||
|
|
||||||
|
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
|
||||||
|
|
||||||
|
STRUCTURE REQUISE :
|
||||||
|
|
||||||
|
## État du système
|
||||||
|
- Résumé en 2-3 phrases de l'état général (OK/Attention/Critique)
|
||||||
|
|
||||||
|
## Points d'attention
|
||||||
|
Liste les problèmes détectés par priorité :
|
||||||
|
- **CRITIQUE** : problèmes de sécurité, espace disque < 10%, mémoire < 10%
|
||||||
|
- **ATTENTION** : CPU élevé, services en échec, config non-optimale
|
||||||
|
- **INFO** : améliorations possibles, mises à jour disponibles
|
||||||
|
|
||||||
|
## Recommandations
|
||||||
|
Pour chaque point d'attention, donne UNE commande ou action corrective concrète.
|
||||||
|
|
||||||
|
## Outils manquants
|
||||||
|
Liste les outils utiles non installés avec la commande d'installation.
|
||||||
|
|
||||||
|
## Réseau
|
||||||
|
- Interfaces actives, ports en écoute, connectivité
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
- Pas de blabla générique — sois spécifique à CE système
|
||||||
|
- Inclus les valeurs numériques réelles (%, Go, MHz)
|
||||||
|
- Max 1500 mots
|
||||||
|
- Le rapport sert de contexte persistant pour un assistant terminal
|
||||||
|
|
||||||
|
` + sysInfo.String()
|
||||||
|
|
||||||
|
result, err := orb.Send(analysisPrompt)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SaveSystemAnalysis(result)
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"analysis": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
analysis := LoadSystemAnalysis()
|
||||||
|
if analysis == "" {
|
||||||
|
writeJSON(w, map[string]interface{}{"analysis": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"analysis": analysis})
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ func (s *Server) handleWorkflowPlan(w http.ResponseWriter, r *http.Request) {
|
|||||||
engine, _ = workflow.NewEngine(s.agentRegistry)
|
engine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
}
|
}
|
||||||
|
|
||||||
wf := engine.Create("Plan: "+body.Goal[:min(len(body.Goal), 30)], body.Goal, "plan_execute", steps)
|
wf := engine.Create("Plan: "+truncateString(body.Goal, 30), body.Goal, "plan_execute", steps)
|
||||||
writeJSON(w, wf)
|
writeJSON(w, wf)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,6 @@ func (s *Server) handleWorkflowExecuteStream(w http.ResponseWriter, engine *work
|
|||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
flusher, canFlush := w.(http.Flusher)
|
flusher, canFlush := w.(http.Flusher)
|
||||||
|
|
||||||
@@ -250,9 +249,10 @@ func (s *Server) handleWorkflowApprove(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "approved"})
|
writeJSON(w, map[string]string{"status": "approved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func truncateString(s string, max int) string {
|
||||||
if a < b {
|
runes := []rune(s)
|
||||||
return a
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
return b
|
return string(runes[:max])
|
||||||
}
|
}
|
||||||
106
internal/api/image_cache.go
Normal file
106
internal/api/image_cache.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var imageDir string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
imageDir = filepath.Join(dir, "images")
|
||||||
|
os.MkdirAll(imageDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageCounter uint64
|
||||||
|
|
||||||
|
func saveImage(dataURI, filename, mimeType string) (string, error) {
|
||||||
|
parts := strings.SplitN(dataURI, ",", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid data URI")
|
||||||
|
}
|
||||||
|
encoded := parts[1]
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("base64 decode: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) > 10*1024*1024 {
|
||||||
|
return "", fmt.Errorf("image too large (max 10MB)")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
|
||||||
|
ext := ".png"
|
||||||
|
switch mimeType {
|
||||||
|
case "image/jpeg":
|
||||||
|
ext = ".jpg"
|
||||||
|
case "image/webp":
|
||||||
|
ext = ".webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(imageDir, id+ext)
|
||||||
|
if err := os.WriteFile(filePath, decoded, 0600); err != nil {
|
||||||
|
return "", fmt.Errorf("write image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id + ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePath(id string) string {
|
||||||
|
return filepath.Join(imageDir, filepath.Base(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupImages(ids []string) {
|
||||||
|
for _, id := range ids {
|
||||||
|
p := imagePath(id)
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" {
|
||||||
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/images/")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, "image id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := imagePath(id)
|
||||||
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
|
writeError(w, "image not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(id))
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
case ".png":
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
case ".webp":
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
}
|
||||||
@@ -2,24 +2,33 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/config"
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
"github.com/muyue/muyue/internal/installer"
|
||||||
"github.com/muyue/muyue/internal/scanner"
|
"github.com/muyue/muyue/internal/scanner"
|
||||||
"github.com/muyue/muyue/internal/workflow"
|
"github.com/muyue/muyue/internal/workflow"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.MuyueConfig
|
config *config.MuyueConfig
|
||||||
scanResult *scanner.ScanResult
|
scanResult *scanner.ScanResult
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
convStore *ConversationStore
|
convStore *ConversationStore
|
||||||
agentRegistry *agent.Registry
|
shellConvStore *ShellConvStore
|
||||||
agentToolsJSON json.RawMessage
|
consumption *consumptionStore
|
||||||
workflowEngine *workflow.Engine
|
agentRegistry *agent.Registry
|
||||||
|
agentToolsJSON json.RawMessage
|
||||||
|
shellAgentRegistry *agent.Registry
|
||||||
|
shellAgentToolsJSON json.RawMessage
|
||||||
|
workflowEngine *workflow.Engine
|
||||||
|
activeCrushAgents atomic.Int32
|
||||||
|
activeClaudeAgents atomic.Int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||||
@@ -39,18 +48,29 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
|||||||
}
|
}
|
||||||
// Save initial config to establish the file for first-time usage
|
// Save initial config to establish the file for first-time usage
|
||||||
if err := config.Save(defaultCfg); err != nil {
|
if err := config.Save(defaultCfg); err != nil {
|
||||||
log.Printf("config: initial save failed: %v", err)
|
_ = err
|
||||||
}
|
}
|
||||||
cfg = defaultCfg
|
cfg = defaultCfg
|
||||||
}
|
}
|
||||||
s.config = cfg
|
s.config = cfg
|
||||||
s.scanResult = scanner.ScanSystem()
|
s.scanResult = scanner.ScanSystem()
|
||||||
s.convStore = NewConversationStore()
|
s.convStore = NewConversationStore()
|
||||||
|
s.shellConvStore = NewShellConvStore()
|
||||||
|
s.consumption = newConsumptionStore()
|
||||||
s.agentRegistry = agent.DefaultRegistry()
|
s.agentRegistry = agent.DefaultRegistry()
|
||||||
tools := s.agentRegistry.OpenAITools()
|
tools := s.agentRegistry.OpenAITools()
|
||||||
toolsJSON, _ := json.Marshal(tools)
|
toolsJSON, _ := json.Marshal(tools)
|
||||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||||
|
|
||||||
|
s.shellAgentRegistry = agent.NewRegistry()
|
||||||
|
terminalTool, _ := agent.NewTerminalTool()
|
||||||
|
s.shellAgentRegistry.Register(terminalTool)
|
||||||
|
shellTools := s.shellAgentRegistry.OpenAITools()
|
||||||
|
shellToolsJSON, _ := json.Marshal(shellTools)
|
||||||
|
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
||||||
|
|
||||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||||
|
s.initStarship()
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -82,6 +102,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
|
||||||
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
|
||||||
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
|
||||||
|
s.mux.HandleFunc("/api/images/", s.handleServeImage)
|
||||||
s.mux.HandleFunc("/api/chat", s.handleChat)
|
s.mux.HandleFunc("/api/chat", s.handleChat)
|
||||||
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
|
||||||
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
|
||||||
@@ -89,6 +110,10 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
|
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
|
||||||
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
||||||
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
||||||
|
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
|
||||||
|
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
|
||||||
|
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
|
||||||
|
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
|
||||||
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
||||||
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
||||||
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||||
@@ -101,6 +126,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
||||||
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
||||||
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
|
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
|
||||||
|
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
|
||||||
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
|
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
|
||||||
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
||||||
|
|
||||||
@@ -114,20 +140,25 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
|
||||||
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||||
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
||||||
|
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
|
||||||
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
||||||
|
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
|
||||||
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
||||||
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
||||||
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/ws/") {
|
if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
if origin := r.Header.Get("Origin"); isAllowedOrigin(origin) {
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -135,3 +166,53 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAllowedOrigin(origin string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(origin, "http://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "http://localhost"),
|
||||||
|
strings.HasPrefix(origin, "http://[::1]"),
|
||||||
|
strings.HasPrefix(origin, "https://127.0.0.1"),
|
||||||
|
strings.HasPrefix(origin, "https://localhost"),
|
||||||
|
strings.HasPrefix(origin, "https://[::1]"):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCrushAgents = 2
|
||||||
|
const maxClaudeAgents = 2
|
||||||
|
|
||||||
|
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
|
||||||
|
var counter *atomic.Int32
|
||||||
|
var max int32
|
||||||
|
switch toolName {
|
||||||
|
case "crush_run":
|
||||||
|
counter = &s.activeCrushAgents
|
||||||
|
max = maxCrushAgents
|
||||||
|
case "claude_run":
|
||||||
|
counter = &s.activeClaudeAgents
|
||||||
|
max = maxClaudeAgents
|
||||||
|
default:
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
current := counter.Add(1)
|
||||||
|
if current > max {
|
||||||
|
counter.Add(-1)
|
||||||
|
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
|
||||||
|
}
|
||||||
|
return func() { counter.Add(-1) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) initStarship() {
|
||||||
|
if _, err := exec.LookPath("starship"); err != nil {
|
||||||
|
inst := installer.New(s.config)
|
||||||
|
if result := inst.InstallTool("starship"); !result.Success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
|
||||||
|
}
|
||||||
|
|||||||
185
internal/api/shell_conversation.go
Normal file
185
internal/api/shell_conversation.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/muyue/muyue/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const shellMaxTokens = 100000
|
||||||
|
const shellCharsPerToken = 4
|
||||||
|
|
||||||
|
const shellSystemPromptBase = `Tu es l'**Analyste Système** de Muyue. Tu es un expert en administration système, DevOps et développement.
|
||||||
|
|
||||||
|
<critical_rules>
|
||||||
|
1. **AGIS, ne décris pas** — Utilise l'outil terminal pour exécuter, ne te contente pas de proposer des commandes.
|
||||||
|
2. **SOIS AUTONOME** — Cherche les infos manquantes via des commandes avant de demander à l'utilisateur. Essaie plusieurs approches avant de bloquer.
|
||||||
|
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule. Réponse directe et technique.
|
||||||
|
4. **GÈRE LES ERREURS** — Si une commande échoue, lis l'erreur, comprends la cause, essaie une approche alternative. 2-3 tentatives avant de rapporter.
|
||||||
|
5. **SÉCURITÉ** — Ne révèle jamais de credentials. Demande confirmation avant les commandes destructrices (rm -rf, format, etc.).
|
||||||
|
6. **LANGUE** — Réponds dans la même langue que l'utilisateur.
|
||||||
|
</critical_rules>
|
||||||
|
|
||||||
|
<tool_usage>
|
||||||
|
Outil disponible : **terminal** — Exécute des commandes shell sur le système local.
|
||||||
|
|
||||||
|
Stratégies :
|
||||||
|
- **Diagnostique** — Enchaîne les commandes de diagnostic (ps, df, free, top, journalctl, dmesg, netstat, ss, etc.)
|
||||||
|
- **Parallélisme** — Combine les commandes avec && ou ; quand elles sont indépendantes
|
||||||
|
- **Filtrage** — Utilise grep, awk, sort, head pour extraire l'essentiel des sorties volumineuses
|
||||||
|
- **Non-interactif** — Préfère les commandes non-interactives (apt install -y, non pas apt install)
|
||||||
|
- **Troncature** — Si le résultat dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||||
|
</tool_usage>
|
||||||
|
|
||||||
|
<decision_making>
|
||||||
|
- Décide par toi-même : exécute des commandes pour comprendre l'état du système
|
||||||
|
- Ne demande confirmation que pour les actions destructrices
|
||||||
|
- Si tu ne connais pas la commande exacte, exécute la commande avec --help pour la trouver
|
||||||
|
- Si bloqué : documente ce que tu as essayé, pourquoi, et l'action minimale requise
|
||||||
|
- Ne t'arrête jamais pour une tâche complexe — découpe en étapes et exécute-les
|
||||||
|
</decision_making>
|
||||||
|
|
||||||
|
<error_recovery>
|
||||||
|
1. Lis le message d'erreur complet (stderr + stdout)
|
||||||
|
2. Identifie la cause racine (permissions, paquet manquant, config, service)
|
||||||
|
3. Essaie : vérifier le service, vérifier les logs, chercher le paquet, tester la connexion
|
||||||
|
4. Propose une solution concrète, pas générique
|
||||||
|
</error_recovery>
|
||||||
|
|
||||||
|
<response_format>
|
||||||
|
- **Commandes** : blocs markdown avec le langage (bash, sh, etc.)
|
||||||
|
- **Résultats** : résume les métriques clés, pas de dump complet
|
||||||
|
- **Erreurs** : cause + solution en 1-2 lignes
|
||||||
|
- **Succès** : confirmation en 1 ligne
|
||||||
|
- **Analyses** : markdown structuré avec sections si nécessaire
|
||||||
|
</response_format>
|
||||||
|
|
||||||
|
<mermaid>
|
||||||
|
Tu peux utiliser des diagrammes Mermaid pour visualiser :
|
||||||
|
- Architecture système (graph TD/LR)
|
||||||
|
- Flux réseau (sequenceDiagram)
|
||||||
|
- Processus (flowchart)
|
||||||
|
- Timeline (gantt)
|
||||||
|
|
||||||
|
Utilise un bloc de code avec le langage mermaid quand ça clarifie l'explication. Pas pour du texte simple.
|
||||||
|
</mermaid>
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
type ShellMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShellConvStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
msgs []ShellMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewShellConvStore() *ShellConvStore {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
dir = "/tmp/muyue"
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "shell_conversation.json")
|
||||||
|
s := &ShellConvStore{path: path}
|
||||||
|
s.load()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) load() {
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if err != nil {
|
||||||
|
s.msgs = []ShellMessage{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.Unmarshal(data, &s.msgs)
|
||||||
|
if s.msgs == nil {
|
||||||
|
s.msgs = []ShellMessage{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) save() {
|
||||||
|
data, _ := json.MarshalIndent(s.msgs, "", " ")
|
||||||
|
os.MkdirAll(filepath.Dir(s.path), 0755)
|
||||||
|
os.WriteFile(s.path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) Get() []ShellMessage {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]ShellMessage, len(s.msgs))
|
||||||
|
copy(out, s.msgs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) Add(role, content string) ShellMessage {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
msg := ShellMessage{
|
||||||
|
ID: time.Now().Format("20060102150405.000"),
|
||||||
|
Role: role,
|
||||||
|
Content: content,
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
s.msgs = append(s.msgs, msg)
|
||||||
|
s.save()
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) Clear() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.msgs = []ShellMessage{}
|
||||||
|
s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) ApproxTokens() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
total := 0
|
||||||
|
for _, m := range s.msgs {
|
||||||
|
if m.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
|
||||||
|
}
|
||||||
|
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
||||||
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||||
|
total += utf8.RuneCountInString(analysis) / shellCharsPerToken
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShellConvStore) AtLimit() bool {
|
||||||
|
return s.ApproxTokens() >= shellMaxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadSystemAnalysis() string {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveSystemAnalysis(content string) error {
|
||||||
|
dir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -48,7 +48,6 @@ type wsMessage struct {
|
|||||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ws upgrade: %v", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
@@ -56,26 +55,23 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
var initMsg wsMessage
|
var initMsg wsMessage
|
||||||
_, raw, err := conn.ReadMessage()
|
_, raw, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("terminal: read init message failed: %v", err)
|
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: init message received: %s", string(raw))
|
|
||||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
||||||
log.Printf("terminal: unmarshal init message failed: %v", err)
|
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
if initMsg.Type == "ssh" && initMsg.Data != "" {
|
||||||
var sshConf struct {
|
var sshConf struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
|
||||||
@@ -98,61 +94,71 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
|
||||||
|
|
||||||
cmd = exec.Command("ssh", sshArgs...)
|
if sshConf.Password != "" {
|
||||||
|
sshpassPath, err := exec.LookPath("sshpass")
|
||||||
|
if err == nil {
|
||||||
|
args := append([]string{"-e"}, "ssh")
|
||||||
|
args = append(args, sshArgs...)
|
||||||
|
cmd = exec.Command(sshpassPath, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ssh", sshArgs...)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
shell := strings.TrimSpace(initMsg.Data)
|
shell := strings.TrimSpace(initMsg.Data)
|
||||||
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
shell = detectShell()
|
shell = detectShell()
|
||||||
log.Printf("terminal: auto-detected shell=%q", shell)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
|
||||||
shell = "/bin/sh"
|
shell = "/bin/sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
if path, err := exec.LookPath(shell); err == nil {
|
// Support "wsl -d <distro>" shell strings sent from the UI quick-access.
|
||||||
shell = path
|
if extra, ok := parseWSLShell(shell); ok {
|
||||||
log.Printf("terminal: resolved shell path=%q", shell)
|
wslPath, err := exec.LookPath("wsl")
|
||||||
}
|
if err != nil {
|
||||||
|
conn.WriteJSON(wsMessage{Type: "error", Data: "wsl not found on this host"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd = exec.Command(wslPath, extra...)
|
||||||
|
} else {
|
||||||
|
if path, err := exec.LookPath(shell); err == nil {
|
||||||
|
shell = path
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(shell); err != nil {
|
if _, err := os.Stat(shell); err != nil {
|
||||||
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
|
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
shellName := filepath.Base(shell)
|
shellName := filepath.Base(shell)
|
||||||
switch shellName {
|
switch shellName {
|
||||||
case "wsl":
|
case "wsl":
|
||||||
cmd = exec.Command(shell, "--shell-type", "login")
|
cmd = exec.Command(shell, "--shell-type", "login")
|
||||||
case "powershell", "pwsh":
|
case "powershell", "pwsh":
|
||||||
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
|
||||||
case "fish":
|
case "fish":
|
||||||
cmd = exec.Command(shell, "--login")
|
cmd = exec.Command(shell, "--login")
|
||||||
default:
|
default:
|
||||||
cmd = exec.Command(shell)
|
cmd = exec.Command(shell)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
if cmd.Env == nil {
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
}
|
||||||
|
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||||
|
|
||||||
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
|
|
||||||
ptmx, err := pty.Start(cmd)
|
ptmx, err := pty.Start(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("terminal: pty start failed: %v", err)
|
|
||||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("terminal: pty started successfully")
|
|
||||||
defer func() {
|
|
||||||
ptmx.Close()
|
|
||||||
if cmd.Process != nil {
|
|
||||||
cmd.Process.Kill()
|
|
||||||
cmd.Wait()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
@@ -164,6 +170,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 4096)
|
buf := make([]byte, 4096)
|
||||||
@@ -171,8 +178,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
n, err := ptmx.Read(buf)
|
n, err := ptmx.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
conn.WriteMessage(websocket.CloseMessage,
|
|
||||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := conn.WriteJSON(wsMessage{
|
if err := conn.WriteJSON(wsMessage{
|
||||||
@@ -219,8 +224,15 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
|
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
|
||||||
|
for i, c := range s.config.Terminal.SSH {
|
||||||
|
masked[i] = c
|
||||||
|
if masked[i].Password != "" {
|
||||||
|
masked[i].Password = "***"
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"ssh": s.config.Terminal.SSH,
|
"ssh": masked,
|
||||||
"system": detectSystemTerminals(),
|
"system": detectSystemTerminals(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -234,8 +246,8 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
|
||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusBadRequest)
|
writeError(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -249,12 +261,36 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
|||||||
body.Port = 22
|
body.Port = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i, c := range s.config.Terminal.SSH {
|
||||||
|
if c.Name == body.Name {
|
||||||
|
password := body.Password
|
||||||
|
if password == "***" {
|
||||||
|
password = c.Password
|
||||||
|
}
|
||||||
|
s.config.Terminal.SSH[i] = config.SSHConnection{
|
||||||
|
Name: body.Name,
|
||||||
|
Host: body.Host,
|
||||||
|
Port: body.Port,
|
||||||
|
User: body.User,
|
||||||
|
KeyPath: body.KeyPath,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
if err := config.Save(s.config); err != nil {
|
||||||
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn := config.SSHConnection{
|
conn := config.SSHConnection{
|
||||||
Name: body.Name,
|
Name: body.Name,
|
||||||
Host: body.Host,
|
Host: body.Host,
|
||||||
Port: body.Port,
|
Port: body.Port,
|
||||||
User: body.User,
|
User: body.User,
|
||||||
KeyPath: body.KeyPath,
|
KeyPath: body.KeyPath,
|
||||||
|
Password: body.Password,
|
||||||
}
|
}
|
||||||
if s.config.Terminal.SSH == nil {
|
if s.config.Terminal.SSH == nil {
|
||||||
s.config.Terminal.SSH = []config.SSHConnection{}
|
s.config.Terminal.SSH = []config.SSHConnection{}
|
||||||
@@ -306,6 +342,87 @@ func detectShell() string {
|
|||||||
return "/bin/sh"
|
return "/bin/sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listWSLDistros returns the list of installed WSL distribution names.
|
||||||
|
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
|
||||||
|
func listWSLDistros() []string {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := exec.Command("wsl", "--list", "--quiet").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
|
||||||
|
raw := stripUTF16ToASCII(out)
|
||||||
|
var distros []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
name := strings.TrimSpace(line)
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip default-marker arrows or annotations.
|
||||||
|
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
|
||||||
|
if name == "" || !validWSLName.MatchString(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
distros = append(distros, name)
|
||||||
|
}
|
||||||
|
return distros
|
||||||
|
}
|
||||||
|
|
||||||
|
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||||
|
|
||||||
|
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
|
||||||
|
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
|
||||||
|
// to pass to the wsl binary. Returns ok=false otherwise.
|
||||||
|
func parseWSLShell(shell string) ([]string, bool) {
|
||||||
|
parts := strings.Fields(shell)
|
||||||
|
if len(parts) < 3 || parts[0] != "wsl" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args := []string{}
|
||||||
|
i := 1
|
||||||
|
for i < len(parts) {
|
||||||
|
switch parts[i] {
|
||||||
|
case "-d", "--distribution":
|
||||||
|
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args = append(args, "-d", parts[i+1])
|
||||||
|
i += 2
|
||||||
|
case "-u", "--user":
|
||||||
|
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
args = append(args, "-u", parts[i+1])
|
||||||
|
i += 2
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return args, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripUTF16ToASCII(b []byte) string {
|
||||||
|
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
|
||||||
|
var out []byte
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
c := b[i]
|
||||||
|
if c == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
func detectSystemTerminals() []map[string]string {
|
func detectSystemTerminals() []map[string]string {
|
||||||
var terminals []map[string]string
|
var terminals []map[string]string
|
||||||
|
|
||||||
@@ -318,10 +435,17 @@ func detectSystemTerminals() []map[string]string {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if _, err := exec.LookPath("wsl"); err == nil {
|
if _, err := exec.LookPath("wsl"); err == nil {
|
||||||
terminals = append(terminals, map[string]string{
|
terminals = append(terminals, map[string]string{
|
||||||
"type": "local",
|
"type": "local",
|
||||||
"name": "WSL",
|
"name": "WSL (default)",
|
||||||
"shell": "wsl",
|
"shell": "wsl",
|
||||||
})
|
})
|
||||||
|
for _, distro := range listWSLDistros() {
|
||||||
|
terminals = append(terminals, map[string]string{
|
||||||
|
"type": "local",
|
||||||
|
"name": "WSL: " + distro,
|
||||||
|
"shell": "wsl -d " + distro,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath("powershell"); err == nil {
|
if _, err := exec.LookPath("powershell"); err == nil {
|
||||||
terminals = append(terminals, map[string]string{
|
terminals = append(terminals, map[string]string{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -128,6 +127,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateProviders(cfg *MuyueConfig) {
|
||||||
|
defaults := Default().AI.Providers
|
||||||
|
for _, dp := range defaults {
|
||||||
|
found := false
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Name == dp.Name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cfg.AI.Providers = append(cfg.AI.Providers, dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetTerminalTheme(name string) TerminalTheme {
|
func GetTerminalTheme(name string) TerminalTheme {
|
||||||
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||||
return theme
|
return theme
|
||||||
@@ -146,7 +161,7 @@ func ConfigDir() (string, error) {
|
|||||||
if _, err := os.Stat(legacyDir); err == nil {
|
if _, err := os.Stat(legacyDir); err == nil {
|
||||||
if _, err := os.Stat(dir); err != nil {
|
if _, err := os.Stat(dir); err != nil {
|
||||||
if err := os.Rename(legacyDir, dir); err != nil {
|
if err := os.Rename(legacyDir, dir); err != nil {
|
||||||
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
|
_ = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,6 +221,8 @@ func Load() (*MuyueConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateProviders(&cfg)
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +286,12 @@ func Default() *MuyueConfig {
|
|||||||
BaseURL: "https://api.minimax.io/v1",
|
BaseURL: "https://api.minimax.io/v1",
|
||||||
Active: true,
|
Active: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "mimo",
|
||||||
|
Model: "mimo-v2.5-pro",
|
||||||
|
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||||
|
Active: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "zai",
|
Name: "zai",
|
||||||
Model: "glm",
|
Model: "glm",
|
||||||
@@ -297,6 +320,7 @@ func Default() *MuyueConfig {
|
|||||||
|
|
||||||
cfg.Terminal.CustomPrompt = true
|
cfg.Terminal.CustomPrompt = true
|
||||||
cfg.Terminal.PromptTheme = "zerotwo"
|
cfg.Terminal.PromptTheme = "zerotwo"
|
||||||
|
cfg.Terminal.FontSize = 14
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,17 +16,53 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
|
||||||
|
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
|
||||||
|
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
|
||||||
|
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
|
||||||
|
|
||||||
|
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
|
||||||
|
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
|
||||||
|
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
|
||||||
|
|
||||||
const maxHistorySize = 100
|
const maxHistorySize = 100
|
||||||
|
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TextContent(s string) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func PartsContent(parts []ContentPart) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(parts)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Message) ContentString() string {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(m.Content, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
type ToolCallMsg struct {
|
type ToolCallMsg struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -107,6 +142,37 @@ func New(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewForProvider builds an orchestrator using a specific (non-active) provider,
|
||||||
|
// for the Advanced Reflection feature where the inactive provider produces a
|
||||||
|
// preliminary report before the active provider answers. Excludes the currently
|
||||||
|
// active provider from selection — picks the first other configured provider
|
||||||
|
// with a non-empty API key.
|
||||||
|
func NewForInactiveProvider(cfg *config.MuyueConfig) (*Orchestrator, error) {
|
||||||
|
var activeName string
|
||||||
|
for _, p := range cfg.AI.Providers {
|
||||||
|
if p.Active {
|
||||||
|
activeName = p.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range cfg.AI.Providers {
|
||||||
|
p := &cfg.AI.Providers[i]
|
||||||
|
if p.Name == activeName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.APIKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &Orchestrator{
|
||||||
|
config: cfg,
|
||||||
|
provider: p,
|
||||||
|
client: sharedHTTPClient,
|
||||||
|
history: []Message{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no inactive provider with API key configured")
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
func (o *Orchestrator) SetSystemPrompt(prompt string) {
|
||||||
o.systemPrompt = prompt
|
o.systemPrompt = prompt
|
||||||
}
|
}
|
||||||
@@ -139,11 +205,38 @@ func (o *Orchestrator) GetHistory() []Message {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendNoTools issues a one-shot, history-less request to this orchestrator's
|
||||||
|
// provider. Used by the Advanced Reflection feature so the inactive provider
|
||||||
|
// can produce a preliminary report without contaminating the active
|
||||||
|
// orchestrator's history or invoking tools.
|
||||||
|
func (o *Orchestrator) SendNoTools(userMessage string) (string, error) {
|
||||||
|
messages := make([]Message, 0, 2)
|
||||||
|
if o.systemPrompt != "" {
|
||||||
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
|
}
|
||||||
|
messages = append(messages, Message{Role: "user", Content: TextContent(userMessage)})
|
||||||
|
|
||||||
|
reqBody := ChatRequest{
|
||||||
|
Model: o.provider.Model,
|
||||||
|
Messages: messages,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
chatResp, _, err := o.sendWithFallback(reqBody, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("empty response from provider")
|
||||||
|
}
|
||||||
|
return CleanAIResponse(chatResp.Choices[0].Message.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(o.history) > maxHistorySize {
|
if len(o.history) > maxHistorySize {
|
||||||
@@ -152,7 +245,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
|
|
||||||
messages := make([]Message, 0, len(o.history)+1)
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
messages = append(messages, o.history...)
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
@@ -169,11 +262,11 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: TextContent(content),
|
||||||
})
|
})
|
||||||
_ = providerName
|
_ = providerName
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
@@ -185,7 +278,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: userMessage,
|
Content: TextContent(userMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(o.history) > maxHistorySize {
|
if len(o.history) > maxHistorySize {
|
||||||
@@ -194,7 +287,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
|
|
||||||
messages := make([]Message, 0, len(o.history)+1)
|
messages := make([]Message, 0, len(o.history)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
messages = append(messages, Message{Role: "system", Content: o.systemPrompt})
|
messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
messages = append(messages, o.history...)
|
messages = append(messages, o.history...)
|
||||||
|
|
||||||
@@ -269,11 +362,11 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content := cleanAIResponse(fullContent.String())
|
content := CleanAIResponse(fullContent.String())
|
||||||
o.histMu.Lock()
|
o.histMu.Lock()
|
||||||
o.history = append(o.history, Message{
|
o.history = append(o.history, Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: content,
|
Content: TextContent(content),
|
||||||
})
|
})
|
||||||
o.histMu.Unlock()
|
o.histMu.Unlock()
|
||||||
|
|
||||||
@@ -283,7 +376,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
|||||||
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
|
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
|
||||||
fullMessages := make([]Message, 0, len(messages)+1)
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
|
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
fullMessages = append(fullMessages, messages...)
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
@@ -314,7 +407,7 @@ type ChunkCallback func(content string, toolCalls []ToolCallMsg)
|
|||||||
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
|
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
|
||||||
fullMessages := make([]Message, 0, len(messages)+1)
|
fullMessages := make([]Message, 0, len(messages)+1)
|
||||||
if o.systemPrompt != "" {
|
if o.systemPrompt != "" {
|
||||||
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt})
|
fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
|
||||||
}
|
}
|
||||||
fullMessages = append(fullMessages, messages...)
|
fullMessages = append(fullMessages, messages...)
|
||||||
|
|
||||||
@@ -360,6 +453,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
var fullContent strings.Builder
|
var fullContent strings.Builder
|
||||||
var accumulatedToolCalls []ToolCallMsg
|
var accumulatedToolCalls []ToolCallMsg
|
||||||
var totalTokens int
|
var totalTokens int
|
||||||
|
var insideToolBlock bool
|
||||||
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
@@ -383,7 +477,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
chunk := chatResp.Choices[0].Delta.Content
|
chunk := chatResp.Choices[0].Delta.Content
|
||||||
if chunk != "" {
|
if chunk != "" {
|
||||||
fullContent.WriteString(chunk)
|
fullContent.WriteString(chunk)
|
||||||
onChunk(chunk, nil)
|
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
|
||||||
|
if cleanedChunk != "" {
|
||||||
|
onChunk(cleanedChunk, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle delta tool calls
|
// Handle delta tool calls
|
||||||
@@ -435,15 +532,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
|||||||
}{},
|
}{},
|
||||||
}
|
}
|
||||||
|
|
||||||
finalContent := cleanAIResponse(fullContent.String())
|
finalContent := CleanAIResponse(fullContent.String())
|
||||||
finalResp.Choices[0].Message.Content = finalContent
|
finalResp.Choices[0].Message.Content = finalContent
|
||||||
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
||||||
|
|
||||||
return finalResp, nil
|
return finalResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanAIResponse(content string) string {
|
func CleanAIResponse(content string) string {
|
||||||
content = thinkRegex.ReplaceAllString(content, "")
|
content = thinkRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerToolBlockRegex.ReplaceAllString(content, "")
|
||||||
|
content = providerTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = xmlToolTagRegex.ReplaceAllString(content, "")
|
||||||
|
content = bracketToolCallRegex.ReplaceAllString(content, "")
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var clean []string
|
var clean []string
|
||||||
inBlock := false
|
inBlock := false
|
||||||
@@ -466,6 +567,35 @@ func cleanAIResponse(content string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
|
||||||
|
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
|
||||||
|
func CleanStreamChunk(chunk string, insideBlock *bool) string {
|
||||||
|
if *insideBlock {
|
||||||
|
// Check for closing tag
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for opening tool_call block
|
||||||
|
if streamBlockStartRegex.MatchString(chunk) {
|
||||||
|
*insideBlock = true
|
||||||
|
// If closing tag also in same chunk, emit nothing
|
||||||
|
if strings.Contains(chunk, ":tool_call>") {
|
||||||
|
*insideBlock = false
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean individual tags and bracket calls
|
||||||
|
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
|
||||||
|
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
|
||||||
|
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
func getProviderBaseURL(name string) string {
|
func getProviderBaseURL(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
case "minimax":
|
case "minimax":
|
||||||
@@ -476,6 +606,8 @@ func getProviderBaseURL(name string) string {
|
|||||||
return "https://api.openai.com/v1"
|
return "https://api.openai.com/v1"
|
||||||
case "zai":
|
case "zai":
|
||||||
return "https://api.z.ai/v1"
|
return "https://api.z.ai/v1"
|
||||||
|
case "mimo":
|
||||||
|
return "https://token-plan-ams.xiaomimimo.com/v1"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -503,11 +635,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
if o.provider != nil {
|
if o.provider != nil {
|
||||||
providerOrder = append(providerOrder, o.provider)
|
providerOrder = append(providerOrder, o.provider)
|
||||||
}
|
}
|
||||||
|
var zaiProvider *config.AIProvider
|
||||||
for _, p := range providers {
|
for _, p := range providers {
|
||||||
if o.provider == nil || p.Name != o.provider.Name {
|
if o.provider == nil || p.Name != o.provider.Name {
|
||||||
providerOrder = append(providerOrder, p)
|
if p.Name == "zai" {
|
||||||
|
zaiProvider = p
|
||||||
|
} else {
|
||||||
|
providerOrder = append(providerOrder, p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if zaiProvider != nil {
|
||||||
|
providerOrder = append(providerOrder, zaiProvider)
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var triedProviders []string
|
var triedProviders []string
|
||||||
@@ -578,6 +718,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
|||||||
return &chatResp, prov.Name, nil
|
return &chatResp, prov.Name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
|
|
||||||
return nil, "", lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := cleanAIResponse(tt.input)
|
result := CleanAIResponse(tt.input)
|
||||||
result = strings.TrimSpace(result)
|
result = strings.TrimSpace(result)
|
||||||
expected := strings.TrimSpace(tt.expected)
|
expected := strings.TrimSpace(tt.expected)
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("cleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
|
t.Errorf("CleanAIResponse(%q) = %q, want %q", tt.input, result, expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -77,34 +77,34 @@ func TestCleanAIResponse(t *testing.T) {
|
|||||||
|
|
||||||
func TestCleanAIResponseThinkRegex(t *testing.T) {
|
func TestCleanAIResponseThinkRegex(t *testing.T) {
|
||||||
input2 := "<Think>some reasoning</Think>actual response"
|
input2 := "<Think>some reasoning</Think>actual response"
|
||||||
result2 := cleanAIResponse(input2)
|
result2 := CleanAIResponse(input2)
|
||||||
if result2 != "actual response" {
|
if result2 != "actual response" {
|
||||||
t.Errorf("Valid Think tags should be removed: %q", result2)
|
t.Errorf("Valid Think tags should be removed: %q", result2)
|
||||||
}
|
}
|
||||||
|
|
||||||
input3 := "<think\nmultiline\nreasoning</think visible"
|
input3 := "<think\nmultiline\nreasoning</think visible"
|
||||||
result3 := cleanAIResponse(input3)
|
result3 := CleanAIResponse(input3)
|
||||||
// No closing > on opening tag, so won't match regex
|
// No closing > on opening tag, so won't match regex
|
||||||
if result3 != "<think\nmultiline\nreasoning</think visible" {
|
if result3 != "<think\nmultiline\nreasoning</think visible" {
|
||||||
t.Errorf("Malformed think should not be removed: %q", result3)
|
t.Errorf("Malformed think should not be removed: %q", result3)
|
||||||
}
|
}
|
||||||
|
|
||||||
input4 := "<think type=re>reasoning</think visible"
|
input4 := "<think type=re>reasoning</think visible"
|
||||||
result4 := cleanAIResponse(input4)
|
result4 := CleanAIResponse(input4)
|
||||||
// </think followed by space, not >, so won't match
|
// </think followed by space, not >, so won't match
|
||||||
if result4 != "<think type=re>reasoning</think visible" {
|
if result4 != "<think type=re>reasoning</think visible" {
|
||||||
t.Errorf("Malformed closing should not be removed: %q", result4)
|
t.Errorf("Malformed closing should not be removed: %q", result4)
|
||||||
}
|
}
|
||||||
|
|
||||||
input_real := "prefix<think reasoning here</think suffix"
|
input_real := "prefix<think reasoning here</think suffix"
|
||||||
result_real := cleanAIResponse(input_real)
|
result_real := CleanAIResponse(input_real)
|
||||||
// The closing </think has no > after it, so won't match
|
// The closing </think has no > after it, so won't match
|
||||||
if result_real != "prefix<think reasoning here</think suffix" {
|
if result_real != "prefix<think reasoning here</think suffix" {
|
||||||
t.Errorf("Malformed tags should pass through: %q", result_real)
|
t.Errorf("Malformed tags should pass through: %q", result_real)
|
||||||
}
|
}
|
||||||
|
|
||||||
input_valid := "<Think>reasoning</Think>result"
|
input_valid := "<Think>reasoning</Think>result"
|
||||||
result_valid := cleanAIResponse(input_valid)
|
result_valid := CleanAIResponse(input_valid)
|
||||||
if result_valid != "result" {
|
if result_valid != "result" {
|
||||||
t.Errorf("Valid tags should be removed: %q", result_valid)
|
t.Errorf("Valid tags should be removed: %q", result_valid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,62 @@ func fileContains(path, substr string) bool {
|
|||||||
func execLookPath(name string) (string, error) {
|
func execLookPath(name string) (string, error) {
|
||||||
return exec.LookPath(name)
|
return exec.LookPath(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readOSReleaseName() string {
|
||||||
|
data, err := os.ReadFile("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var pretty, name, version string
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
key, val, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val = strings.Trim(val, `"'`)
|
||||||
|
switch key {
|
||||||
|
case "PRETTY_NAME":
|
||||||
|
pretty = val
|
||||||
|
case "NAME":
|
||||||
|
name = val
|
||||||
|
case "VERSION_ID":
|
||||||
|
version = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pretty != "" {
|
||||||
|
return pretty
|
||||||
|
}
|
||||||
|
if name != "" && version != "" {
|
||||||
|
return name + " " + version
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMacOSVersion() string {
|
||||||
|
out, err := exec.Command("sw_vers", "-productVersion").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWindowsVersion() string {
|
||||||
|
if v := os.Getenv("OS"); v != "" && strings.Contains(strings.ToLower(v), "windows") {
|
||||||
|
// Try to detect Windows 11 vs 10 via build number
|
||||||
|
if build := os.Getenv("MUYUE_WIN_BUILD"); build != "" {
|
||||||
|
return "Windows " + build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := exec.Command("cmd", "/c", "ver").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(out))
|
||||||
|
if strings.Contains(s, "10.0.22") || strings.Contains(s, "10.0.23") {
|
||||||
|
return "Windows 11"
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "10.0.") {
|
||||||
|
return "Windows 10"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
|
|
||||||
type SystemInfo struct {
|
type SystemInfo struct {
|
||||||
OS OS `json:"os"`
|
OS OS `json:"os"`
|
||||||
|
OSName string `json:"os_name"`
|
||||||
Arch Arch `json:"arch"`
|
Arch Arch `json:"arch"`
|
||||||
IsWSL bool `json:"is_wsl"`
|
IsWSL bool `json:"is_wsl"`
|
||||||
Shell string `json:"shell"`
|
Shell string `json:"shell"`
|
||||||
@@ -39,6 +40,7 @@ func Detect() SystemInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info.IsWSL = detectWSL()
|
info.IsWSL = detectWSL()
|
||||||
|
info.OSName = detectOSName(info.OS, info.IsWSL)
|
||||||
info.Shell = detectShell()
|
info.Shell = detectShell()
|
||||||
info.Terminal = detectTerminal()
|
info.Terminal = detectTerminal()
|
||||||
info.PackageManager = detectPackageManager(info.OS)
|
info.PackageManager = detectPackageManager(info.OS)
|
||||||
@@ -46,6 +48,33 @@ func Detect() SystemInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectOSName(os OS, isWSL bool) string {
|
||||||
|
switch os {
|
||||||
|
case Linux:
|
||||||
|
if name := readOSReleaseName(); name != "" {
|
||||||
|
if isWSL {
|
||||||
|
return name + " (WSL)"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if isWSL {
|
||||||
|
return "Linux (WSL)"
|
||||||
|
}
|
||||||
|
return "Linux"
|
||||||
|
case MacOS:
|
||||||
|
if v := readMacOSVersion(); v != "" {
|
||||||
|
return "macOS " + v
|
||||||
|
}
|
||||||
|
return "macOS"
|
||||||
|
case Windows:
|
||||||
|
if v := readWindowsVersion(); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "Windows"
|
||||||
|
}
|
||||||
|
return string(os)
|
||||||
|
}
|
||||||
|
|
||||||
func detectWSL() bool {
|
func detectWSL() bool {
|
||||||
return fileContains("/proc/version", "microsoft") ||
|
return fileContains("/proc/version", "microsoft") ||
|
||||||
fileContains("/proc/version", "WSL")
|
fileContains("/proc/version", "WSL")
|
||||||
@@ -95,8 +124,11 @@ func detectPackageManager(os OS) string {
|
|||||||
func (s SystemInfo) String() string {
|
func (s SystemInfo) String() string {
|
||||||
parts := []string{
|
parts := []string{
|
||||||
"OS: " + string(s.OS),
|
"OS: " + string(s.OS),
|
||||||
"Arch: " + string(s.Arch),
|
|
||||||
}
|
}
|
||||||
|
if s.OSName != "" {
|
||||||
|
parts = append(parts, "Name: "+s.OSName)
|
||||||
|
}
|
||||||
|
parts = append(parts, "Arch: "+string(s.Arch))
|
||||||
if s.IsWSL {
|
if s.IsWSL {
|
||||||
parts = append(parts, "WSL: yes")
|
parts = append(parts, "WSL: yes")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Skill struct {
|
|||||||
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
||||||
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
||||||
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
||||||
|
Deployed bool `yaml:"-" json:"deployed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
@@ -155,6 +156,27 @@ func Delete(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsDeployed(name string) bool {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
|
||||||
|
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
|
||||||
|
_, crushErr := os.Stat(crushPath)
|
||||||
|
_, claudeErr := os.Stat(claudePath)
|
||||||
|
return crushErr == nil || claudeErr == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Undeploy(name string) error {
|
||||||
|
skill, err := Get(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
undeployFromTargets(skill.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func Update(skill *Skill) error {
|
func Update(skill *Skill) error {
|
||||||
if errs := Validate(skill); len(errs) > 0 {
|
if errs := Validate(skill); len(errs) > 0 {
|
||||||
return fmt.Errorf("validation failed: %v", errs)
|
return fmt.Errorf("validation failed: %v", errs)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.3.3"
|
Version = "0.6.0"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -225,17 +225,21 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
|
|||||||
stepStatuses[step.ID] = StatusPending
|
stepStatuses[step.ID] = StatusPending
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveDeps := func(stepID string) bool {
|
resolveDeps := func(stepID string) (ready bool, blocked bool) {
|
||||||
step := wf.findStep(stepID)
|
step := wf.findStep(stepID)
|
||||||
if step == nil {
|
if step == nil {
|
||||||
return false
|
return false, true
|
||||||
}
|
}
|
||||||
for _, dep := range step.DependsOn {
|
for _, dep := range step.DependsOn {
|
||||||
if stepStatuses[dep] != StatusDone {
|
depStatus := stepStatuses[dep]
|
||||||
return false
|
if depStatus == StatusFailed || depStatus == StatusSkipped {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
if depStatus != StatusDone {
|
||||||
|
return false, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
executeStep := func(step *Step) error {
|
executeStep := func(step *Step) error {
|
||||||
@@ -296,6 +300,7 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
|
|||||||
s.Error = stepErr.Error()
|
s.Error = stepErr.Error()
|
||||||
s.EndedAt = &endTime
|
s.EndedAt = &endTime
|
||||||
})
|
})
|
||||||
|
stepStatuses[step.ID] = StatusFailed
|
||||||
if onStep != nil {
|
if onStep != nil {
|
||||||
onStep(step, "failed")
|
onStep(step, "failed")
|
||||||
}
|
}
|
||||||
@@ -321,8 +326,27 @@ func (e *Engine) Execute(ctx context.Context, workflowID string, onStep func(ste
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for !resolveDeps(step.ID) {
|
ready, blocked := resolveDeps(step.ID)
|
||||||
time.Sleep(100 * time.Millisecond)
|
if blocked {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusSkipped
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusSkipped
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(&step, "skipped")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
e.UpdateStep(workflowID, step.ID, func(s *Step) {
|
||||||
|
s.Status = StatusSkipped
|
||||||
|
s.Error = "dependency not satisfied at execution time"
|
||||||
|
})
|
||||||
|
stepStatuses[step.ID] = StatusSkipped
|
||||||
|
if onStep != nil {
|
||||||
|
onStep(&step, "skipped")
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := executeStep(&step); err != nil {
|
if err := executeStep(&step); err != nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error)
|
|||||||
prompt := buildPlanPrompt(goal)
|
prompt := buildPlanPrompt(goal)
|
||||||
|
|
||||||
messages := []orchestrator.Message{
|
messages := []orchestrator.Message{
|
||||||
{Role: "user", Content: prompt},
|
{Role: "user", Content: orchestrator.TextContent(prompt)},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := p.orchestrator.SendWithTools(messages)
|
resp, err := p.orchestrator.SendWithTools(messages)
|
||||||
@@ -159,14 +159,18 @@ func parsePlanResponse(content string) ([]Step, error) {
|
|||||||
return steps, nil
|
return steps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu génères des plans d'exécution sous forme de JSON. Chaque plan est une séquence d'étapes (steps) représentant des appels d'outils.
|
const plannerSystemPrompt = `Tu es un planificateur de workflows pour Muyue. Tu génères des plans d'exécution sous forme de tableaux JSON.
|
||||||
|
|
||||||
Pour générer un plan:
|
RÈGLES :
|
||||||
1. Comprends l'objectif de l'utilisateur
|
1. Analyse l'objectif → identifie les outils → décompose en étapes
|
||||||
2. Identifie les outils nécessaires
|
2. Chaque étape : {"name": string, "tool": string, "args": object}
|
||||||
3. Décompose en étapes logiques
|
3. Max 10 étapes par plan
|
||||||
4. Spécifie les paramètres de chaque outil
|
4. Ordonne par dépendances (les lectures avant les écritures)
|
||||||
|
5. Préfère les commandes non-interactives
|
||||||
|
6. Utilise crush_run pour les tâches complexes multi-fichiers
|
||||||
|
|
||||||
Réponds toujours en JSON valide, sans texte additionnel.`
|
Outils : terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch
|
||||||
|
|
||||||
var _ = plannerSystemPrompt
|
Réponds UNIQUEMENT en JSON valide, sans texte avant/après.`
|
||||||
|
|
||||||
|
const _ = plannerSystemPrompt
|
||||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
1301
web/package-lock.json
generated
1301
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||||
|
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||||
|
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.203",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"mermaid": "^11.14.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,8 +36,11 @@ const api = {
|
|||||||
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
|
testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }),
|
||||||
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||||
|
deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
getDashboardStatus: () => request('/dashboard/status'),
|
getDashboardStatus: () => request('/dashboard/status'),
|
||||||
getProvidersQuota: () => request('/providers/quota'),
|
getProvidersQuota: () => request('/providers/quota'),
|
||||||
|
getProvidersConsumption: () => request('/providers/consumption'),
|
||||||
getRecentCommands: () => request('/recent-commands'),
|
getRecentCommands: () => request('/recent-commands'),
|
||||||
getRunningProcesses: () => request('/running-processes'),
|
getRunningProcesses: () => request('/running-processes'),
|
||||||
getSystemMetrics: () => request('/system/metrics'),
|
getSystemMetrics: () => request('/system/metrics'),
|
||||||
@@ -48,6 +51,7 @@ const api = {
|
|||||||
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
|
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
|
||||||
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
|
||||||
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
|
||||||
|
aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }),
|
||||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||||
getTerminalSessions: () => request('/terminal/sessions'),
|
getTerminalSessions: () => request('/terminal/sessions'),
|
||||||
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
|
||||||
@@ -57,15 +61,19 @@ const api = {
|
|||||||
getChatHistory: () => request('/chat/history'),
|
getChatHistory: () => request('/chat/history'),
|
||||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||||
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
|
summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
|
||||||
sendChat: (message, stream = true, onChunk, signal) => {
|
getShellChatHistory: () => request('/shell/chat/history'),
|
||||||
|
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||||
|
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||||
|
getShellAnalysis: () => request('/shell/analysis'),
|
||||||
|
sendChat: (message, stream = true, onChunk, signal, images = [], advancedReflection = false) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false, images, advanced_reflection: advancedReflection }) })
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
fetch(`${API_BASE}/chat`, {
|
fetch(`${API_BASE}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, stream: true }),
|
body: JSON.stringify({ message, stream: true, images, advanced_reflection: advancedReflection }),
|
||||||
signal,
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -101,11 +109,9 @@ const api = {
|
|||||||
}).catch(reject)
|
}).catch(reject)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
message,
|
message,
|
||||||
context: context.context || '',
|
|
||||||
history: context.history || [],
|
|
||||||
cwd: context.cwd || '',
|
cwd: context.cwd || '',
|
||||||
platform: context.platform || '',
|
platform: context.platform || '',
|
||||||
stream,
|
stream,
|
||||||
@@ -118,6 +124,7 @@ const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
@@ -127,7 +134,6 @@ const api = {
|
|||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let full = ''
|
let full = ''
|
||||||
let toolCalls = []
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
@@ -137,27 +143,19 @@ const api = {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.slice(6))
|
const data = JSON.parse(line.slice(6))
|
||||||
if (data.error) { reject(new Error(data.error)); return }
|
if (data.error) { reject(new Error(data.error)); return }
|
||||||
if (data.done) {
|
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
||||||
resolve({ content: full, tool_calls: toolCalls })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
full += data.content
|
full += data.content
|
||||||
if (onChunk) onChunk(full, data)
|
if (onChunk) onChunk(full, data)
|
||||||
} else if (data.tool_call) {
|
} else if (data.tool_call || data.tool_result) {
|
||||||
toolCalls.push(data.tool_call)
|
if (onChunk) onChunk(full, data)
|
||||||
if (onChunk) onChunk(full, data, toolCalls)
|
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||||
} else if (data.tool_result) {
|
if (onChunk) onChunk(full, data)
|
||||||
const idx = toolCalls.findIndex(tc => tc.tool_call_id === data.tool_result.id)
|
|
||||||
if (idx >= 0) {
|
|
||||||
toolCalls[idx].result = data.tool_result
|
|
||||||
}
|
|
||||||
if (onChunk) onChunk(full, data, toolCalls)
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve({ content: full, tool_calls: toolCalls })
|
resolve({ content: full })
|
||||||
}).catch(reject)
|
}).catch(reject)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export default function App() {
|
|||||||
const [isSudo, setIsSudo] = useState(false)
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
const [dashRefreshKey, setDashRefreshKey] = useState(0)
|
||||||
const dashRefreshRef = useRef(null)
|
const dashRefreshRef = useRef(null)
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
const { t, layout } = useI18n()
|
const { t, layout } = useI18n()
|
||||||
@@ -31,8 +29,6 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
setConfig(d)
|
setConfig(d)
|
||||||
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
const theme = d.profile?.preferences?.theme || 'cyberpunk-red'
|
||||||
@@ -76,8 +72,11 @@ export default function App() {
|
|||||||
|
|
||||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||||
|
|
||||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
useEffect(() => {
|
||||||
const installed = tools.filter(tool => tool.installed).length
|
const handler = () => setActiveTab('shell')
|
||||||
|
window.addEventListener('navigate-to-shell', handler)
|
||||||
|
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const WINDOW_SHORTCUTS = useMemo(() => ({
|
const WINDOW_SHORTCUTS = useMemo(() => ({
|
||||||
dash: [],
|
dash: [],
|
||||||
@@ -86,22 +85,16 @@ export default function App() {
|
|||||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||||
],
|
],
|
||||||
shell: [
|
shell: [
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
||||||
|
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+F`, desc: t('statusbar.search') },
|
||||||
|
{ keys: `${layout.keys.ctrl}++/${layout.keys.ctrl}+−`, desc: t('statusbar.zoom') },
|
||||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||||
],
|
],
|
||||||
config: [],
|
config: [],
|
||||||
}), [layout, t])
|
}), [layout, t])
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'dash': return <Dashboard api={api} refreshRef={dashRefreshRef} />
|
|
||||||
case 'studio': return <Studio api={api} />
|
|
||||||
case 'shell': return <Shell api={api} />
|
|
||||||
case 'config': return <Config api={api} />
|
|
||||||
default: return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
@@ -127,29 +120,21 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="header-spacer" />
|
<div className="header-spacer" />
|
||||||
|
|
||||||
<div className="header-indicators">
|
|
||||||
<span
|
|
||||||
className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
|
|
||||||
title={t('header.toolsInstalled', { count: installed })}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
|
|
||||||
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="header-clock">
|
<span className="header-clock">
|
||||||
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
{clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
|
<main className="content">
|
||||||
{renderContent()}
|
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
|
||||||
|
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
|
||||||
|
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
|
||||||
|
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="statusbar">
|
<footer className="statusbar">
|
||||||
<div className="statusbar-left">
|
<div className="statusbar-left">
|
||||||
{isSudo && <span className="statusbar-sudo">⚡ ROOT</span>}
|
{isSudo && <span className="statusbar-sudo">⚡ SUDO</span>}
|
||||||
{activeTab === 'dash' && (
|
{activeTab === 'dash' && (
|
||||||
<span className="statusbar-shortcut">
|
<span className="statusbar-shortcut">
|
||||||
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
<kbd>{layout.keys.ctrl}+R</kbd> refresh
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } from 'lucide-react'
|
||||||
import { useI18n, LANGUAGES } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
import { getLayoutList } from '../i18n/keyboards'
|
|
||||||
|
|
||||||
const PANELS = [
|
const PANELS = [
|
||||||
{ id: 'profile', icon: User },
|
{ id: 'profile', icon: User },
|
||||||
{ id: 'providers', icon: Brain },
|
{ id: 'providers', icon: Brain },
|
||||||
{ id: 'updates', icon: RefreshCw },
|
|
||||||
{ id: 'locale', icon: Globe },
|
|
||||||
{ id: 'skills', icon: Wrench },
|
{ id: 'skills', icon: Wrench },
|
||||||
{ id: 'system', icon: Monitor },
|
{ id: 'system', icon: Monitor },
|
||||||
]
|
]
|
||||||
@@ -18,10 +15,7 @@ export default function Config({ api }) {
|
|||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
const [skillList, setSkillList] = useState([])
|
const [skillList, setSkillList] = useState([])
|
||||||
const [updates, setUpdates] = useState([])
|
|
||||||
const [tools, setTools] = useState([])
|
|
||||||
const [checking, setChecking] = useState(false)
|
|
||||||
const [updating, setUpdating] = useState(null)
|
|
||||||
const [editProfile, setEditProfile] = useState(false)
|
const [editProfile, setEditProfile] = useState(false)
|
||||||
const [editProvider, setEditProvider] = useState(null)
|
const [editProvider, setEditProvider] = useState(null)
|
||||||
const [profileForm, setProfileForm] = useState({})
|
const [profileForm, setProfileForm] = useState({})
|
||||||
@@ -29,8 +23,6 @@ export default function Config({ api }) {
|
|||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
|
|
||||||
const layouts = getLayoutList()
|
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
api.getConfig().then(d => {
|
api.getConfig().then(d => {
|
||||||
setConfig(d)
|
setConfig(d)
|
||||||
@@ -38,8 +30,6 @@ export default function Config({ api }) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
|
||||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
|
||||||
|
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
@@ -50,44 +40,6 @@ export default function Config({ api }) {
|
|||||||
setTimeout(() => setToast(null), 2500)
|
setTimeout(() => setToast(null), 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckUpdates = async () => {
|
|
||||||
setChecking(true)
|
|
||||||
try {
|
|
||||||
await api.runScan()
|
|
||||||
const d = await api.getUpdates()
|
|
||||||
setUpdates(d.updates || [])
|
|
||||||
const td = await api.getTools()
|
|
||||||
setTools(td.tools || [])
|
|
||||||
showToast(t('config.upToDate'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setChecking(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateTool = async (tool) => {
|
|
||||||
setUpdating(tool)
|
|
||||||
try {
|
|
||||||
await api.runUpdate(tool)
|
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(`${tool} ✓`)
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
|
||||||
setUpdating('__all__')
|
|
||||||
try {
|
|
||||||
await api.runUpdate('')
|
|
||||||
await handleCheckUpdates()
|
|
||||||
showToast(t('config.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setUpdating(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -118,17 +70,15 @@ export default function Config({ api }) {
|
|||||||
...prev,
|
...prev,
|
||||||
[p.name]: {
|
[p.name]: {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
api_key: p.apiKey || '',
|
api_key: p.api_key || '',
|
||||||
model: p.model || '',
|
model: p.model || '',
|
||||||
base_url: p.baseURL || '',
|
base_url: p.base_url || '',
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
setEditProvider(p.name)
|
setEditProvider(p.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdateCount = updates.filter(u => u.needsUpdate).length
|
|
||||||
const installedCount = tools.filter(tool => tool.installed).length
|
|
||||||
const missingCount = tools.filter(tool => !tool.installed).length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-window">
|
<div className="config-window">
|
||||||
@@ -169,27 +119,8 @@ export default function Config({ api }) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activePanel === 'updates' && (
|
|
||||||
<PanelUpdates
|
|
||||||
updates={updates} tools={tools}
|
|
||||||
checking={checking} updating={updating}
|
|
||||||
needsUpdateCount={needsUpdateCount}
|
|
||||||
installedCount={installedCount} missingCount={missingCount}
|
|
||||||
handleCheckUpdates={handleCheckUpdates}
|
|
||||||
handleUpdateTool={handleUpdateTool}
|
|
||||||
handleUpdateAll={handleUpdateAll}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activePanel === 'locale' && (
|
|
||||||
<PanelLocale
|
|
||||||
language={language} keyboard={keyboard} layouts={layouts}
|
|
||||||
api={api}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activePanel === 'skills' && (
|
{activePanel === 'skills' && (
|
||||||
<PanelSkills skillList={skillList} t={t} />
|
<PanelSkills skillList={skillList} api={api} loadData={loadData} t={t} />
|
||||||
)}
|
)}
|
||||||
{activePanel === 'system' && (
|
{activePanel === 'system' && (
|
||||||
<PanelSystem api={api} t={t} />
|
<PanelSystem api={api} t={t} />
|
||||||
@@ -333,39 +264,57 @@ function getFieldLabel(key, t) {
|
|||||||
|
|
||||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||||
const [validating, setValidating] = useState(null)
|
const [validating, setValidating] = useState(null)
|
||||||
const [validationStatus, setValidationStatus] = useState(null)
|
const [keyStatus, setKeyStatus] = useState({})
|
||||||
|
|
||||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
const validateKey = async (p) => {
|
||||||
setValidating(name)
|
setValidating(p.name)
|
||||||
setValidationStatus(null)
|
|
||||||
try {
|
try {
|
||||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
|
||||||
setValidationStatus({ provider: name, valid: true })
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || ''
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
if (msg.includes('invalid_api_key')) {
|
|
||||||
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
|
|
||||||
} else {
|
|
||||||
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setValidating(null)
|
setValidating(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
providers.forEach(p => {
|
||||||
|
if (p.api_key && !keyStatus[p.name]) {
|
||||||
|
validateKey(p)
|
||||||
|
} else if (!p.api_key) {
|
||||||
|
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [providers])
|
||||||
|
|
||||||
|
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||||
|
setValidating(name)
|
||||||
|
try {
|
||||||
|
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||||
|
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
|
||||||
|
} catch (err) {
|
||||||
|
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||||
|
}
|
||||||
|
setValidating(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-providers-list">
|
<div className="config-providers-list">
|
||||||
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
{displayed.map((p, i) => {
|
||||||
{providers.map((p, i) => {
|
|
||||||
const isEditing = editProvider === p.name
|
const isEditing = editProvider === p.name
|
||||||
const isValidationTarget = validationStatus?.provider === p.name
|
const currentModel = providerForm[p.name]?.model || p.model
|
||||||
|
const status = keyStatus[p.name]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="config-card provider-card-v2">
|
<div key={i} className="config-card provider-card-v2">
|
||||||
<div className="provider-card-top">
|
<div className="provider-card-top">
|
||||||
<div className="provider-card-identity">
|
<div className="provider-card-identity">
|
||||||
<span className="provider-card-name">{p.name}</span>
|
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||||
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
{status?.checked && status?.valid && <span className="badge ok">✓ {t('config.keyValid')}</span>}
|
||||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
{status?.checked && !status?.valid && <span className="badge error">✗ {status.error || t('config.keyInvalid')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,7 +325,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<input
|
<input
|
||||||
className="config-form-input"
|
className="config-form-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t('config.tokenPlaceholder')}
|
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
|
||||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!isEditing) openProviderEdit(p)
|
if (!isEditing) openProviderEdit(p)
|
||||||
@@ -391,18 +340,18 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
<button
|
<button
|
||||||
className="sm primary"
|
className="sm primary"
|
||||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
|
||||||
>
|
>
|
||||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||||
</button>
|
</button>
|
||||||
{isValidationTarget && validationStatus?.valid && (
|
{isEditing && (
|
||||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
<div className="provider-card-model">
|
||||||
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>}
|
<span className="provider-card-model-label">{t('config.model')}</span>
|
||||||
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>}
|
<span className="provider-card-model-value">{p.model || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,155 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
function PanelSkills({ skillList, api, loadData, t }) {
|
||||||
return (
|
const [deploying, setDeploying] = useState(null)
|
||||||
<>
|
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-update-controls">
|
|
||||||
<div className="config-update-stats">
|
|
||||||
<span className="badge ok">{installedCount} {t('config.installed')}</span>
|
|
||||||
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
|
|
||||||
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="config-update-buttons">
|
|
||||||
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
|
|
||||||
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
|
|
||||||
</button>
|
|
||||||
{needsUpdateCount > 0 && (
|
|
||||||
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
|
|
||||||
{updating === '__all__' ? t('config.updating') : `${t('config.updateAll')} (${needsUpdateCount})`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{updates.length === 0 ? (
|
const handleDeploy = async (name) => {
|
||||||
<div className="config-card">
|
setDeploying(name + '-deploy')
|
||||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
try {
|
||||||
</div>
|
await api.deploySkill(name)
|
||||||
) : (
|
loadData()
|
||||||
<div className="config-update-list">
|
} catch (err) {
|
||||||
{updates.map((u, i) => (
|
console.error('deploy skill:', err)
|
||||||
<div key={i} className="config-update-row">
|
}
|
||||||
<div className="config-update-info">
|
setDeploying(null)
|
||||||
<span className="config-update-name">{u.tool}</span>
|
}
|
||||||
<span className="config-update-versions">
|
|
||||||
{u.needsUpdate ? (
|
const handleUndeploy = async (name) => {
|
||||||
<>{u.current} → <span style={{ color: 'var(--warning)' }}>{u.latest}</span></>
|
setDeploying(name + '-undeploy')
|
||||||
) : (
|
try {
|
||||||
<span style={{ color: 'var(--success)' }}>{u.current}</span>
|
await api.undeploySkill(name)
|
||||||
)}
|
loadData()
|
||||||
</span>
|
} catch (err) {
|
||||||
</div>
|
console.error('undeploy skill:', err)
|
||||||
{u.needsUpdate && (
|
}
|
||||||
<button
|
setDeploying(null)
|
||||||
className="sm"
|
}
|
||||||
onClick={() => handleUpdateTool(u.tool)}
|
|
||||||
disabled={updating === u.tool}
|
if (skillList.length === 0) {
|
||||||
>
|
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
|
||||||
{updating === u.tool ? t('config.updating') : t('config.updateTool')}
|
}
|
||||||
</button>
|
|
||||||
|
return (
|
||||||
|
<div className="skills-list">
|
||||||
|
{skillList.map((s, i) => (
|
||||||
|
<div key={i} className="config-update-row" style={{ alignItems: 'center' }}>
|
||||||
|
<div className="skill-list-info">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="config-update-name">{s.name}</span>
|
||||||
|
{s.deployed ? (
|
||||||
|
<span className="badge ok">{t('config.installed')}</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge neutral">{t('config.notInstalled')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>{s.description}</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelLocale({ language, keyboard, layouts, api, t }) {
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [toast, setToast] = useState(null)
|
|
||||||
|
|
||||||
const showToast = (msg) => {
|
|
||||||
setToast(msg)
|
|
||||||
setTimeout(() => setToast(null), 2500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await api.savePreferences({ language, keyboard_layout: keyboard })
|
|
||||||
showToast(t('config.saved'))
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
|
||||||
}
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="config-card">
|
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
|
||||||
<div className="config-card-group">
|
|
||||||
<span className="config-card-group-label">{t('config.language')}</span>
|
|
||||||
<div className="chip-row">
|
|
||||||
{LANGUAGES.map(lang => (
|
|
||||||
<div
|
|
||||||
key={lang.id}
|
|
||||||
className={`chip ${language === lang.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setLanguage(lang.id)}
|
|
||||||
>
|
|
||||||
{lang.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-group">
|
|
||||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
|
||||||
<div className="chip-row">
|
|
||||||
{layouts.map(l => (
|
|
||||||
<div
|
|
||||||
key={l.id}
|
|
||||||
className={`chip ${keyboard === l.id ? 'active' : ''}`}
|
|
||||||
onClick={() => setKeyboard(l.id)}
|
|
||||||
>
|
|
||||||
{l.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="config-card-actions">
|
|
||||||
<button className="primary sm" onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? t('config.saving') : t('config.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelSkills({ skillList, t }) {
|
|
||||||
return (
|
|
||||||
<div className="config-card">
|
|
||||||
{skillList.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
{t('config.noSkills')}
|
|
||||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
skillList.map((s, i) => (
|
|
||||||
<div key={i} className="config-skill-row">
|
|
||||||
<span className="config-skill-name">{s.name}</span>
|
|
||||||
<span className="badge neutral">{s.target || 'both'}</span>
|
|
||||||
{s.version && <span className="badge" style={{ fontSize: 10 }}>{s.version}</span>}
|
|
||||||
{s.category && <span className="badge" style={{ fontSize: 10, opacity: 0.7 }}>{s.category}</span>}
|
|
||||||
<span className="config-skill-desc">{s.description}</span>
|
|
||||||
{s.dependencies && s.dependencies.length > 0 && (
|
|
||||||
<div style={{ marginTop: 4, fontSize: 10, color: 'var(--muted)' }}>
|
|
||||||
deps: {s.dependencies.map(d => d.name).join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
)}
|
<button
|
||||||
|
className="sm primary"
|
||||||
|
disabled={s.deployed || deploying === s.name + '-deploy'}
|
||||||
|
onClick={() => handleDeploy(s.name)}
|
||||||
|
>
|
||||||
|
{deploying === s.name + '-deploy' ? '...' : t('config.apply')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sm ghost"
|
||||||
|
disabled={!s.deployed || deploying === s.name + '-undeploy'}
|
||||||
|
onClick={() => handleUndeploy(s.name)}
|
||||||
|
>
|
||||||
|
{deploying === s.name + '-undeploy' ? '...' : t('config.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelSystem({ api, t }) {
|
function PanelSystem({ api, t }) {
|
||||||
const [resetConfirm, setResetConfirm] = useState(false)
|
const [showResetModal, setShowResetModal] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
const [isSudo, setIsSudo] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {})
|
||||||
|
}, [api])
|
||||||
|
|
||||||
const showToast = (msg) => {
|
const showToast = (msg) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
@@ -570,7 +444,7 @@ function PanelSystem({ api, t }) {
|
|||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
await api.resetConfig()
|
await api.resetConfig()
|
||||||
setResetConfirm(false)
|
setShowResetModal(false)
|
||||||
showToast(t('config.resetDone'))
|
showToast(t('config.resetDone'))
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -578,49 +452,163 @@ function PanelSystem({ api, t }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyStarship = async () => {
|
const handleSystemUpdate = () => {
|
||||||
try {
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
await api.applyStarshipTheme('charm')
|
if (isSudo) {
|
||||||
showToast(t('config.starshipApplied'))
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } }))
|
||||||
} catch (err) {
|
} else {
|
||||||
showToast(`${t('config.error')}: ${err.message}`)
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configureTool = (tool) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('navigate-to-shell'))
|
||||||
|
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_TOOLS = [
|
||||||
|
{
|
||||||
|
id: 'crush',
|
||||||
|
name: 'Crush',
|
||||||
|
icon: 'Zap',
|
||||||
|
description: t('config.toolCrushDesc'),
|
||||||
|
prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude',
|
||||||
|
name: 'Claude Code',
|
||||||
|
icon: 'Bot',
|
||||||
|
description: t('config.toolClaudeDesc'),
|
||||||
|
prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gh',
|
||||||
|
name: 'GitHub CLI',
|
||||||
|
icon: 'GitBranch',
|
||||||
|
description: t('config.toolGhDesc'),
|
||||||
|
prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docker',
|
||||||
|
name: 'Docker',
|
||||||
|
icon: 'Container',
|
||||||
|
description: t('config.toolDockerDesc'),
|
||||||
|
prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go',
|
||||||
|
name: 'Go',
|
||||||
|
icon: 'Circle',
|
||||||
|
description: t('config.toolGoDesc'),
|
||||||
|
prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node',
|
||||||
|
name: 'Node.js',
|
||||||
|
icon: 'Hexagon',
|
||||||
|
description: t('config.toolNodeDesc'),
|
||||||
|
prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python',
|
||||||
|
name: 'Python',
|
||||||
|
icon: 'Code',
|
||||||
|
description: t('config.toolPythonDesc'),
|
||||||
|
prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'starship',
|
||||||
|
name: 'Starship',
|
||||||
|
icon: 'Rocket',
|
||||||
|
description: t('config.toolStarshipDesc'),
|
||||||
|
prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{toast && <div className="config-toast">{toast}</div>}
|
{toast && <div className="config-toast">{toast}</div>}
|
||||||
<div className="config-card">
|
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
<div className="section-title" style={{ marginBottom: 8 }}>{t('config.systemConfig')}</div>
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
|
||||||
</div>
|
<div className="section-title" style={{ marginTop: 4, marginBottom: 8, fontSize: 12, color: 'var(--text-tertiary)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
<Bot size={13} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
{t('config.starshipApplied')}
|
{t('config.aiToolsConfig')}
|
||||||
</div>
|
|
||||||
<button className="sm primary" onClick={handleApplyStarship}>
|
|
||||||
{t('config.applyStarship')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="config-card" style={{ marginTop: 12 }}>
|
<div className="config-ai-tools-grid">
|
||||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
{AI_TOOLS.map(tool => {
|
||||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
const Icon = ICON_MAP[tool.icon] || Bot
|
||||||
</div>
|
return (
|
||||||
{resetConfirm ? (
|
<div key={tool.id} className="config-ai-tool-card">
|
||||||
<div>
|
<div className="config-ai-tool-header">
|
||||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
<span className="config-ai-tool-icon"><Icon size={16} /></span>
|
||||||
{t('config.resetConfirm')}
|
<span className="config-ai-tool-name">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="config-ai-tool-desc">{tool.description}</div>
|
||||||
|
<button className="sm primary" onClick={() => configureTool(tool)} style={{ marginTop: 'auto' }}>
|
||||||
|
<Sparkles size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
|
{t('config.configureViaAI')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
)
|
||||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
})}
|
||||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
</div>
|
||||||
|
|
||||||
|
<div className="config-card" style={{ marginTop: 12, marginBottom: 4 }}>
|
||||||
|
<div className="config-card-row" style={{ alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.systemUpdate')}</span>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||||
|
{isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button className="sm primary" onClick={handleSystemUpdate}>
|
||||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
<Download size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
||||||
{t('config.resetConfig')}
|
{t('config.updateBtn')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||||
|
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||||
|
Zone Rouge
|
||||||
|
</div>
|
||||||
|
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
|
||||||
|
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||||
|
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
|
Cette action supprimera toute votre configuration et relancera l'application.
|
||||||
|
</div>
|
||||||
|
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
|
||||||
|
{t('config.resetConfig')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showResetModal && (
|
||||||
|
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
|
||||||
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
|
||||||
|
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||||
|
{t('config.resetConfig')}
|
||||||
|
</div>
|
||||||
|
<div className="shell-modal-body">
|
||||||
|
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
|
||||||
|
{t('config.resetConfirm')}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>
|
||||||
|
Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shell-modal-footer">
|
||||||
|
<button className="ghost" onClick={() => setShowResetModal(false)}>{t('config.cancel')}</button>
|
||||||
|
<button className="danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { useI18n } from '../i18n'
|
|||||||
|
|
||||||
const MAX_POINTS = 30
|
const MAX_POINTS = 30
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 5000
|
||||||
|
const MAX_IDLE_POLLS = 3
|
||||||
|
|
||||||
|
function formatTokens(n) {
|
||||||
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
function MiniGraph({ data, max, color, label, unit }) {
|
function MiniGraph({ data, max, color, label, unit }) {
|
||||||
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||||
const m = max || Math.max(...data, 1)
|
const m = max || Math.max(...data, 1)
|
||||||
@@ -34,12 +43,32 @@ function MiniGraph({ data, max, color, label, unit }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BarChart({ data, max, color }) {
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
const barW = 100 / 7
|
||||||
|
const m = max || Math.max(...data.map(d => d.tokens), 1)
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 100 40" className="dash-graph-svg" preserveAspectRatio="none">
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const h = Math.max(1, (d.tokens / m) * 36)
|
||||||
|
const x = i * barW + barW * 0.15
|
||||||
|
const w = barW * 0.7
|
||||||
|
return (
|
||||||
|
<rect key={i} x={x} y={40 - h} width={w} height={h} rx="1.5" fill={color} opacity={0.85} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard({ api, refreshRef }) {
|
export default function Dashboard({ api, refreshRef }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [quota, setQuota] = useState(null)
|
const [quota, setQuota] = useState(null)
|
||||||
|
const [consumption, setConsumption] = useState(null)
|
||||||
const [recentCmds, setRecentCmds] = useState([])
|
const [recentCmds, setRecentCmds] = useState([])
|
||||||
const [processes, setProcesses] = useState([])
|
const [processes, setProcesses] = useState([])
|
||||||
const [metrics, setMetrics] = useState(null)
|
const [metrics, setMetrics] = useState(null)
|
||||||
|
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||||
const cpuRef = useRef([])
|
const cpuRef = useRef([])
|
||||||
const memRef = useRef([])
|
const memRef = useRef([])
|
||||||
const netRxRef = useRef([])
|
const netRxRef = useRef([])
|
||||||
@@ -47,13 +76,15 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
|
const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
|
||||||
api.getProvidersQuota().catch(() => null),
|
api.getProvidersQuota().catch(() => null),
|
||||||
|
api.getProvidersConsumption().catch(() => null),
|
||||||
api.getRecentCommands().catch(() => ({ commands: [] })),
|
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||||
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||||
api.getSystemMetrics().catch(() => null),
|
api.getSystemMetrics().catch(() => null),
|
||||||
])
|
])
|
||||||
setQuota(quotaData?.providers || [])
|
setQuota(quotaData?.providers || [])
|
||||||
|
setConsumption(consumData?.providers || {})
|
||||||
setRecentCmds(cmdData.commands || [])
|
setRecentCmds(cmdData.commands || [])
|
||||||
setProcesses(procData.processes || [])
|
setProcesses(procData.processes || [])
|
||||||
if (metricsData) {
|
if (metricsData) {
|
||||||
@@ -71,12 +102,70 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
if (refreshRef) refreshRef.current = loadData
|
if (refreshRef) refreshRef.current = loadData
|
||||||
const iv = setInterval(loadData, 5000)
|
let active = true
|
||||||
return () => clearInterval(iv)
|
let idleTicks = 0
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
|
||||||
|
if (hidden) {
|
||||||
|
idleTicks++
|
||||||
|
if (idleTicks >= MAX_IDLE_POLLS) return
|
||||||
|
} else {
|
||||||
|
idleTicks = 0
|
||||||
|
}
|
||||||
|
if (active) loadData()
|
||||||
|
}, POLL_INTERVAL)
|
||||||
|
return () => { active = false; clearInterval(iv) }
|
||||||
}, [loadData, refreshRef])
|
}, [loadData, refreshRef])
|
||||||
|
|
||||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||||
const zai = (quota || []).find(p => p.name === 'zai')
|
|
||||||
|
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||||||
|
|
||||||
|
const topCmds = (() => {
|
||||||
|
const counts = {}
|
||||||
|
for (const c of recentCmds) {
|
||||||
|
const base = c.cmd.split(/\s+/)[0]
|
||||||
|
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
|
||||||
|
if (!/^[a-zA-Z@.\/]/.test(base)) continue
|
||||||
|
counts[base] = (counts[base] || 0) + 1
|
||||||
|
}
|
||||||
|
return Object.entries(counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([cmd, count]) => ({ cmd, count }))
|
||||||
|
})()
|
||||||
|
|
||||||
|
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||||||
|
|
||||||
|
const copyCmd = (cmd, key) => {
|
||||||
|
navigator.clipboard.writeText(cmd)
|
||||||
|
setCopiedSet(prev => new Set(prev).add(key))
|
||||||
|
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeTime = (ts) => {
|
||||||
|
if (!ts) return ''
|
||||||
|
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||||
|
if (diff < 60) return `${diff}s`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||||
|
return `${Math.floor(diff / 86400)}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentUnique = (() => {
|
||||||
|
const seen = new Set()
|
||||||
|
return recentCmds.filter(c => {
|
||||||
|
if (seen.has(c.cmd)) return false
|
||||||
|
seen.add(c.cmd)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
const providerEntries = consumption ? Object.entries(consumption) : []
|
||||||
|
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
|
||||||
|
const maxDaily = providerEntries.length > 0
|
||||||
|
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
|
||||||
|
: 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash-grid">
|
<div className="dash-grid">
|
||||||
@@ -108,34 +197,36 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Quota */}
|
{/* Consommation */}
|
||||||
<div className="dash-card">
|
<div className="dash-card">
|
||||||
<div className="dash-card-head">
|
<div className="dash-card-head">
|
||||||
<span className="dash-label">API Quota</span>
|
<span className="dash-label">Consommation</span>
|
||||||
|
<span className="dash-count">7j</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-quota-list">
|
<div className="dash-consumption-list">
|
||||||
{minimax && minimax.data?.models?.map((m, i) => (
|
{providerEntries.length === 0 && (
|
||||||
<div key={i} className="dash-quota-row">
|
<span className="dash-empty">Aucune donnée</span>
|
||||||
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
|
)}
|
||||||
<div className="dash-bar">
|
{providerEntries.map(([name, p], pi) => (
|
||||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
<div key={name} className="dash-consumption-provider">
|
||||||
|
<div className="dash-consumption-head">
|
||||||
|
<span className="dash-consumption-name" style={{ color: colors[pi % colors.length] }}>
|
||||||
|
{name.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="dash-consumption-total">
|
||||||
|
{formatTokens(p.total_tokens)} tokens · {p.total_requests} req
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
|
||||||
|
<div className="dash-consumption-days">
|
||||||
|
{(p.daily || []).map((d, i) => (
|
||||||
|
<span key={i} className="dash-consumption-day">
|
||||||
|
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{minimax && minimax.data?.models?.length === 0 && (
|
|
||||||
<div className="dash-quota-row">
|
|
||||||
<span className="dash-quota-name">MiniMax</span>
|
|
||||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{zai && (
|
|
||||||
<div className="dash-quota-row">
|
|
||||||
<span className="dash-quota-name">Z.AI</span>
|
|
||||||
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,16 +248,34 @@ export default function Dashboard({ api, refreshRef }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Commands */}
|
{/* Recent Commands */}
|
||||||
<div className="dash-card">
|
<div className="dash-card dash-cmd-card">
|
||||||
<div className="dash-card-head">
|
<div className="dash-card-head">
|
||||||
<span className="dash-label">Recent Commands</span>
|
<span className="dash-label">Recent Commands</span>
|
||||||
|
<span className="dash-count">{recentUnique.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{topCmds.length > 0 && (
|
||||||
|
<div className="dash-cmd-freq">
|
||||||
|
<span className="dash-cmd-freq-title">Most used</span>
|
||||||
|
{topCmds.map((c, i) => (
|
||||||
|
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||||||
|
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||||||
|
<div className="dash-cmd-freq-bar-wrap">
|
||||||
|
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="dash-cmd-list">
|
<div className="dash-cmd-list">
|
||||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||||
{recentCmds.map((c, i) => (
|
{recentUnique.map((c, i) => (
|
||||||
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||||||
<span className="dash-cmd-shell">{c.shell}</span>
|
<div className="dash-cmd-left">
|
||||||
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||||||
|
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,12 @@ const en = {
|
|||||||
switchWindow: 'Switch window',
|
switchWindow: 'Switch window',
|
||||||
sendMessage: 'Send message',
|
sendMessage: 'Send message',
|
||||||
newLine: 'New line',
|
newLine: 'New line',
|
||||||
|
copy: 'Copy',
|
||||||
|
paste: 'Paste',
|
||||||
|
search: 'Search',
|
||||||
|
zoom: 'Zoom +/−',
|
||||||
|
switchTab: 'Switch tab',
|
||||||
|
nextTab: 'Next tab',
|
||||||
runCommand: 'Run command',
|
runCommand: 'Run command',
|
||||||
commandHistory: 'Command history',
|
commandHistory: 'Command history',
|
||||||
},
|
},
|
||||||
@@ -114,6 +120,8 @@ const en = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
keyPath: 'SSH key path',
|
keyPath: 'SSH key path',
|
||||||
|
password: 'Password',
|
||||||
|
passwordHint: 'requires sshpass installed',
|
||||||
connect: 'Connect',
|
connect: 'Connect',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
@@ -203,8 +211,27 @@ const en = {
|
|||||||
resetConfirm: 'Are you sure? All preferences will be erased.',
|
resetConfirm: 'Are you sure? All preferences will be erased.',
|
||||||
resetDone: 'Settings reset.',
|
resetDone: 'Settings reset.',
|
||||||
applyStarship: 'Apply starship',
|
applyStarship: 'Apply starship',
|
||||||
|
apply: 'Apply',
|
||||||
|
remove: 'Remove',
|
||||||
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
starshipApplied: 'Starship theme applied! Restart your shell to see the result.',
|
||||||
starshipError: 'Failed to apply starship theme.',
|
starshipError: 'Failed to apply starship theme.',
|
||||||
|
systemConfig: 'System Configuration',
|
||||||
|
aiToolsConfig: 'Tools & Environments',
|
||||||
|
configureViaAI: 'Configure',
|
||||||
|
toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.',
|
||||||
|
toolClaudeDesc: 'AI coding assistant by Anthropic.',
|
||||||
|
toolGhDesc: 'Command-line interface for GitHub.',
|
||||||
|
toolDockerDesc: 'Application containerization platform.',
|
||||||
|
toolGoDesc: 'Programming language and runtime environment.',
|
||||||
|
toolNodeDesc: 'JavaScript runtime and package manager.',
|
||||||
|
toolPythonDesc: 'Programming language, pip and uv manager.',
|
||||||
|
toolStarshipDesc: 'Modern and customizable shell prompt.',
|
||||||
|
systemUpdate: 'System Update',
|
||||||
|
systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Shows update commands to run manually.',
|
||||||
|
updateBtn: 'Update',
|
||||||
|
notInstalled: 'Not installed',
|
||||||
|
install: 'Install',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const fr = {
|
|||||||
switchWindow: 'Changer de fen\u00eatre',
|
switchWindow: 'Changer de fen\u00eatre',
|
||||||
sendMessage: 'Envoyer le message',
|
sendMessage: 'Envoyer le message',
|
||||||
newLine: 'Nouvelle ligne',
|
newLine: 'Nouvelle ligne',
|
||||||
|
copy: 'Copier',
|
||||||
|
paste: 'Coller',
|
||||||
|
search: 'Rechercher',
|
||||||
|
zoom: 'Zoom +/\u2212',
|
||||||
|
switchTab: 'Changer d\u2019onglet',
|
||||||
|
nextTab: 'Onglet suivant',
|
||||||
runCommand: 'Ex\u00e9cuter',
|
runCommand: 'Ex\u00e9cuter',
|
||||||
commandHistory: 'Historique',
|
commandHistory: 'Historique',
|
||||||
},
|
},
|
||||||
@@ -114,6 +120,8 @@ const fr = {
|
|||||||
port: 'Port',
|
port: 'Port',
|
||||||
user: 'Utilisateur',
|
user: 'Utilisateur',
|
||||||
keyPath: 'Chemin cl\u00e9 SSH',
|
keyPath: 'Chemin cl\u00e9 SSH',
|
||||||
|
password: 'Mot de passe',
|
||||||
|
passwordHint: 'n\u00e9cessite sshpass install\u00e9',
|
||||||
connect: 'Se connecter',
|
connect: 'Se connecter',
|
||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
@@ -136,7 +144,7 @@ const fr = {
|
|||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
updates: 'Mises \u00e0 jour',
|
updates: 'Mises \u00e0 jour',
|
||||||
locale: 'Langue & Clavier',
|
locale: 'Langue & Clavier',
|
||||||
skills: 'Comp\u00e9ENCES',
|
skills: 'Compétences',
|
||||||
system: 'Syst\u00e8me',
|
system: 'Syst\u00e8me',
|
||||||
},
|
},
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
@@ -160,7 +168,7 @@ const fr = {
|
|||||||
save: 'Enregistrer',
|
save: 'Enregistrer',
|
||||||
saved: 'Enregistr\u00e9 !',
|
saved: 'Enregistr\u00e9 !',
|
||||||
error: 'Erreur',
|
error: 'Erreur',
|
||||||
skills: 'Comp\u00e9ENCES',
|
skills: 'Compétences',
|
||||||
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
|
||||||
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
@@ -203,8 +211,27 @@ const fr = {
|
|||||||
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.',
|
||||||
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.',
|
||||||
applyStarship: 'Appliquer starship',
|
applyStarship: 'Appliquer starship',
|
||||||
|
apply: 'Appliquer',
|
||||||
|
remove: 'Retirer',
|
||||||
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.',
|
||||||
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.',
|
||||||
|
systemConfig: 'Configuration Syst\u00e8me',
|
||||||
|
aiToolsConfig: 'Outils & Environnements',
|
||||||
|
configureViaAI: 'Configurer',
|
||||||
|
toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.',
|
||||||
|
toolClaudeDesc: 'Assistant de codage IA par Anthropic.',
|
||||||
|
toolGhDesc: 'Interface en ligne de commande pour GitHub.',
|
||||||
|
toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.',
|
||||||
|
toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.',
|
||||||
|
toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.',
|
||||||
|
toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.',
|
||||||
|
toolStarshipDesc: 'Prompt shell moderne et personnalisable.',
|
||||||
|
systemUpdate: 'Mise à jour système',
|
||||||
|
systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).',
|
||||||
|
systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.',
|
||||||
|
updateBtn: 'Mettre à jour',
|
||||||
|
notInstalled: 'Non installé',
|
||||||
|
install: 'Installer',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
.content { flex: 1; overflow: hidden; }
|
.content { flex: 1; overflow: hidden; position: relative; }
|
||||||
|
.content > div { position: absolute; inset: 0; overflow: hidden; }
|
||||||
|
.tab-hidden { display: none; }
|
||||||
|
|
||||||
.statusbar {
|
.statusbar {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -274,8 +276,8 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
.shell-layout { display: flex; height: 100%; }
|
.shell-layout { display: flex; height: 100%; overflow: hidden; }
|
||||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||||||
|
|
||||||
.shell-tabs-bar {
|
.shell-tabs-bar {
|
||||||
display: flex; align-items: center; background: var(--bg-surface);
|
display: flex; align-items: center; background: var(--bg-surface);
|
||||||
@@ -327,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.shell-zoom-badge {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
|
||||||
|
color: var(--accent); background: var(--accent-bg);
|
||||||
|
padding: 2px 6px; border-radius: 3px;
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-new-tab-wrapper { position: relative; }
|
.shell-new-tab-wrapper { position: relative; }
|
||||||
.shell-new-tab-btn {
|
.shell-new-tab-btn {
|
||||||
display: flex; align-items: center; gap: 2px;
|
display: flex; align-items: center; gap: 2px;
|
||||||
@@ -369,40 +379,177 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.shell-menu-item-row { display: flex; align-items: center; }
|
.shell-menu-item-row { display: flex; align-items: center; }
|
||||||
.shell-menu-item-icon {
|
.shell-menu-item-icon {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 24px; height: 24px; border-radius: var(--radius);
|
width: 26px; height: 26px; border-radius: var(--radius);
|
||||||
background: transparent; border: none; color: var(--text-disabled);
|
background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary);
|
||||||
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
cursor: pointer; transition: all 0.1s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); }
|
.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); }
|
||||||
.shell-menu-empty {
|
.shell-menu-empty {
|
||||||
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
font-size: 12px; color: var(--text-disabled); padding: 8px 10px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||||
|
|
||||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
||||||
.shell-xterm-instance {
|
|
||||||
position: absolute; inset: 0; padding: 4px;
|
.shell-search-bar {
|
||||||
display: block !important;
|
position: absolute; top: 8px; right: 12px; z-index: 20;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 4px 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||||
|
.shell-search-input {
|
||||||
|
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
||||||
|
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono); outline: none;
|
||||||
|
}
|
||||||
|
.shell-search-input:focus { border-color: var(--accent); }
|
||||||
|
.shell-search-nav {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
|
||||||
|
padding: 0; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
|
||||||
|
.shell-search-close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; border-radius: 4px;
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-disabled); cursor: pointer; padding: 0;
|
||||||
|
}
|
||||||
|
.shell-search-close:hover { color: var(--accent); }
|
||||||
|
.shell-xterm-instance {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.shell-xterm-instance.active {
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.shell-xterm-instance .xterm { height: 100%; }
|
||||||
|
|
||||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||||
.connection-dot.off { background: var(--error); }
|
.connection-dot.off { background: var(--error); }
|
||||||
|
|
||||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
|
||||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); }
|
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
|
||||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
|
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
||||||
|
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.sudo-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||||
|
.sudo-indicator.sudo-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); }
|
||||||
|
.sudo-indicator.sudo-blocked { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); }
|
||||||
|
.shell-analyze-btn {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
padding: 4px 10px; border-radius: var(--radius);
|
||||||
|
background: transparent; border: 1px solid var(--accent-dim);
|
||||||
|
color: var(--accent); font-size: 11px; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.shell-analyze-btn:hover:not(:disabled) { background: var(--accent-bg); }
|
||||||
|
.shell-analyze-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.shell-ai-token-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.shell-ai-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
|
||||||
|
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
|
||||||
|
.shell-ai-token-fill.warn { background: var(--warning); }
|
||||||
|
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||||
|
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||||
.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
||||||
.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); }
|
.ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
|
||||||
|
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
|
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||||
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
|
||||||
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
|
||||||
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
|
||||||
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
||||||
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
||||||
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
|
||||||
|
.ai-panel-input .ai-clear-btn {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
padding: 10px 16px; border-radius: var(--radius); border: 1px solid var(--accent);
|
||||||
|
background: var(--accent-bg); color: var(--accent); font-size: 13px; font-weight: 700;
|
||||||
|
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.ai-panel-input .ai-clear-btn:hover { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
.shell-code-block {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
margin: 8px 0 4px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.shell-code-block pre {
|
||||||
|
padding: 10px 12px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
|
||||||
|
overflow-x: auto; color: var(--text-primary); margin: 0;
|
||||||
|
}
|
||||||
|
.shell-code-lang {
|
||||||
|
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||||
|
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.shell-code-actions {
|
||||||
|
display: flex; border-top: 1px solid var(--border); background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.shell-code-actions button {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||||
|
padding: 5px 0; background: transparent; border: none; border-right: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); font-size: 11px; cursor: pointer; transition: all 0.1s;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.shell-code-actions button:last-child { border-right: none; }
|
||||||
|
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.shell-code-actions button.copied { background: var(--accent-bg); color: var(--accent); animation: copy-flash 0.3s ease; }
|
||||||
|
|
||||||
|
.shell-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||||
|
.shell-mermaid-container svg { max-width: 100%; height: auto; }
|
||||||
|
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
|
.ai-message thead, .ai-message tbody { display: table-row-group; }
|
||||||
|
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
|
||||||
|
.ai-message tr { display: table-row; }
|
||||||
|
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
|
||||||
|
@keyframes copy-flash {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-analysis-modal {
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.shell-analysis-modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 700; font-size: 15px; color: var(--accent);
|
||||||
|
}
|
||||||
|
.shell-analysis-modal-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
||||||
|
color: var(--text-primary); word-break: break-word;
|
||||||
|
}
|
||||||
|
.shell-analysis-modal-body table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||||
|
.shell-analysis-modal-body th { background: var(--bg-surface); padding: 4px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.shell-analysis-modal-body td { padding: 3px 10px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.shell-analysis-modal-body tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
.shell-analysis-modal-body .msg-h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin: 16px 0 6px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-h4 { font-size: 15px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 4px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-h2 { font-size: 20px; font-weight: 700; color: var(--accent); margin: 20px 0 8px; display: block; }
|
||||||
|
.shell-analysis-modal-body .msg-bullet { display: block; padding-left: 4px; margin: 2px 0; color: var(--text-primary); }
|
||||||
|
.shell-analysis-modal-body .msg-step { display: flex; gap: 8px; align-items: baseline; margin: 2px 0; }
|
||||||
|
.shell-analysis-modal-body .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); flex-shrink: 0; }
|
||||||
|
.shell-analysis-modal-body strong { color: var(--accent-light); }
|
||||||
|
.shell-analysis-modal-body .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
|
|
||||||
.shell-modal-overlay {
|
.shell-modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
@@ -480,6 +627,9 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||||
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||||
|
.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||||
|
.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); }
|
||||||
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
.provider-setup-hint {
|
.provider-setup-hint {
|
||||||
@@ -504,10 +654,24 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
|
||||||
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
|
||||||
|
|
||||||
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
.skill-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||||
.config-skill-row:last-child { border-bottom: none; }
|
.skill-tile { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; cursor: pointer; transition: border-color 0.15s; }
|
||||||
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
|
.skill-tile:hover { border-color: var(--accent-dim); }
|
||||||
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.skill-tile-name { font-weight: 600; color: var(--text-primary); font-size: 14px; margin-bottom: 6px; }
|
||||||
|
.skill-tile-desc { font-size: 12px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
.skill-tile-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.skill-detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 50; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.skill-detail-panel { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 90%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.skill-detail-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.skill-detail-name { font-weight: 600; font-size: 16px; color: var(--text-primary); }
|
||||||
|
.skill-detail-body { flex: 1; overflow-y: auto; padding: 20px; }
|
||||||
|
.skill-detail-section { margin-bottom: 16px; }
|
||||||
|
.skill-detail-label { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||||
|
.skill-detail-meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.skill-detail-content { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
|
||||||
|
.skill-detail-deps { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.skill-detail-dep { font-size: 12px; color: var(--text-tertiary); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.skill-detail-dep .badge { font-size: 10px; }
|
||||||
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.config-toast {
|
.config-toast {
|
||||||
@@ -539,6 +703,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.dash-grid {
|
.dash-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -548,7 +713,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg); padding: 14px 16px;
|
border-radius: var(--radius-lg); padding: 14px 16px;
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; justify-content: center; gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +763,25 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Consumption */
|
||||||
|
.dash-consumption-list { display: flex; flex-direction: column; gap: 10px; max-height: 270px; overflow-y: auto; }
|
||||||
|
.dash-consumption-provider { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.dash-consumption-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.dash-consumption-name {
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.dash-consumption-total {
|
||||||
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.dash-consumption-days {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dash-consumption-day {
|
||||||
|
font-size: 9px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||||
|
background: var(--bg-input); padding: 1px 5px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.dash-consumption-day strong { color: var(--text-secondary); }
|
||||||
|
|
||||||
/* Processes */
|
/* Processes */
|
||||||
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||||
.dash-proc-row {
|
.dash-proc-row {
|
||||||
@@ -606,28 +790,45 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
.dash-proc-name {
|
.dash-proc-name {
|
||||||
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
font-size: 11px; font-weight: 600; color: var(--text-primary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dash-proc-res {
|
.dash-proc-res {
|
||||||
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Commands */
|
/* Commands */
|
||||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
|
||||||
|
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
||||||
.dash-cmd-row {
|
.dash-cmd-row {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
padding: 3px 0; overflow: hidden;
|
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||||
}
|
background: var(--bg-surface); cursor: pointer;
|
||||||
.dash-cmd-shell {
|
transition: background 0.12s;
|
||||||
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
|
|
||||||
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
|
|
||||||
text-transform: uppercase; flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
.dash-cmd-row:hover { background: var(--accent-bg); }
|
||||||
|
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||||
.dash-cmd-text {
|
.dash-cmd-text {
|
||||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
|
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
flex: 1; min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
|
||||||
|
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
|
||||||
|
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
|
||||||
|
|
||||||
|
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||||
|
.dash-cmd-freq-row {
|
||||||
|
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||||
|
padding: 3px 4px; border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
|
||||||
|
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||||
|
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
|
||||||
|
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
|
||||||
/* Services */
|
/* Services */
|
||||||
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||||
@@ -708,7 +909,17 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
|
|
||||||
/* ── Studio Feed ── */
|
/* ── Studio Feed ── */
|
||||||
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
.studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||||
|
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
|
||||||
|
.studio-scroll-btn {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%; padding: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
|
||||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||||
.feed-item:hover { background: var(--bg-card); }
|
.feed-item:hover { background: var(--bg-card); }
|
||||||
@@ -730,7 +941,7 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
}
|
}
|
||||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
.feed-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; }
|
||||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||||
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
||||||
@@ -784,17 +995,41 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
overflow: hidden; margin: 8px 0;
|
overflow: hidden; margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
.studio-code-header {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end;
|
||||||
|
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||||
.studio-code-lang {
|
.studio-code-lang {
|
||||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
background: var(--bg-surface); text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
.studio-copy-btn {
|
||||||
|
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||||
|
background: transparent; border: none; border-left: 1px solid var(--border);
|
||||||
|
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
||||||
|
|
||||||
|
.studio-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||||
|
.studio-mermaid-container svg { max-width: 100%; height: auto; }
|
||||||
|
.studio-mermaid-loading { padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||||
|
.studio-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||||
|
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||||
|
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||||
|
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
.msg-h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; display: block; }
|
||||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; }
|
||||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
|
.msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
|
||||||
|
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
@@ -842,6 +1077,47 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.studio-stop-btn:hover { opacity: 0.8; }
|
.studio-stop-btn:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
/* ── Image Attachments ── */
|
||||||
|
.studio-attach-btn {
|
||||||
|
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius); background: var(--bg-card); color: var(--text-tertiary);
|
||||||
|
border: 1px solid var(--border); cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.studio-attach-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
||||||
|
.studio-attach-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.studio-image-previews {
|
||||||
|
display: flex; gap: 10px; padding: 10px 8px; flex-wrap: wrap; justify-content: center;
|
||||||
|
}
|
||||||
|
.studio-image-preview {
|
||||||
|
position: relative; width: 110px; height: 110px; border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden; border: 2px solid var(--border); background: var(--bg-surface);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.studio-image-preview:hover { border-color: var(--accent-dim); }
|
||||||
|
.studio-image-preview img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
}
|
||||||
|
.studio-image-remove {
|
||||||
|
position: absolute; top: 4px; right: 4px; width: 24px; height: 24px;
|
||||||
|
border-radius: 50%; background: rgba(0,0,0,0.75); color: #fff; border: none;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center;
|
||||||
|
justify-content: center; line-height: 1; transition: background 0.15s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.studio-image-remove:hover { background: var(--error); }
|
||||||
|
|
||||||
|
/* ── Feed Images (in chat messages) ── */
|
||||||
|
.feed-images {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.feed-image {
|
||||||
|
max-width: 240px; max-height: 180px; border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border); object-fit: cover; cursor: pointer;
|
||||||
|
transition: transform 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.feed-image:hover { transform: scale(1.03); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||||
|
|
||||||
/* ── Collapsed Messages ── */
|
/* ── Collapsed Messages ── */
|
||||||
@@ -859,6 +1135,22 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||||
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
|
||||||
|
|
||||||
|
.feed-summary-block { margin: 4px 0; }
|
||||||
|
.feed-summary-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.feed-summary-header:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
|
||||||
|
.feed-summary-header svg { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.feed-summary-text { font-size: 11px; color: var(--text-tertiary); flex: 1; font-weight: 600; }
|
||||||
|
.feed-summary-toggle { font-size: 10px; color: var(--accent); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.skill-list-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||||
|
.skills-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
/* ── Studio Tool Blocks ── */
|
/* ── Studio Tool Blocks ── */
|
||||||
.studio-tool-block {
|
.studio-tool-block {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -947,3 +1239,124 @@ input::placeholder { color: var(--text-disabled); }
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === XTerm Custom Styling === */
|
||||||
|
/* Styles for xterm.js integrated with Muyue theme */
|
||||||
|
.shell-xterm-instance .xterm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport {
|
||||||
|
background-color: var(--bg-base) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-screen {
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for xterm */
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection styling */
|
||||||
|
.shell-xterm-instance .xterm-selection {
|
||||||
|
background: var(--accent-dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring styling */
|
||||||
|
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure consistent font rendering */
|
||||||
|
.shell-xterm-instance .xterm .xterm-char-measure-element {
|
||||||
|
font-family: var(--font-mono) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bell animation styling */
|
||||||
|
.shell-xterm-instance .xterm-bell {
|
||||||
|
animation: xterm-bell-flash 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes xterm-bell-flash {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor styling */
|
||||||
|
.shell-xterm-instance .xterm-cursor {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link styling for web links addon */
|
||||||
|
.shell-xterm-instance .xterm-link {
|
||||||
|
color: var(--accent-light) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-xterm-instance .xterm-link:hover {
|
||||||
|
color: var(--accent-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-card:hover {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-ai-tool-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user