feat(shell): dedicated System Analyst AI, no code execution, analyze system
All checks were successful
Beta Release / beta (push) Successful in 45s
All checks were successful
Beta Release / beta (push) Successful in 45s
- New ShellConvStore with persistent history (shell_conversation.json) - 100k token limit — input grays out, must /clear to continue - Commands limited to /clear and /help only - Shell AI has NO tools — read-only analysis, never executes code - "Analyste Système" panel with system analysis button - System analysis uses Studio AI to write system_analysis.md, prepended as context on every conversation start - Code blocks show "Copier" and "Terminal" buttons to copy or send code directly to the active terminal via WebSocket - Token bar shows usage with warning at 80% 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -1,53 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
)
|
||||
|
||||
const maxShellToolIterations = 10
|
||||
|
||||
type ShellChatRequest struct {
|
||||
Message string `json:"message"`
|
||||
Context string `json:"context,omitempty"`
|
||||
History []string `json:"history,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type ShellChatResponse struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
Result *toolResponseData `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func toBool(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
Message string `json:"message"`
|
||||
Context string `json:"context,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -56,6 +27,11 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.shellConvStore.AtLimit() {
|
||||
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req ShellChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -67,142 +43,237 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.shellConvStore.Add("user", req.Message)
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||
orb.SetTools(s.agentToolsJSON)
|
||||
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
|
||||
|
||||
if req.Stream {
|
||||
s.handleShellChatStream(w, orb, req)
|
||||
s.handleShellChatStreamV2(w, orb)
|
||||
} else {
|
||||
s.handleShellChatNonStream(w, orb, req)
|
||||
s.handleShellChatNonStreamV2(w, orb)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec:
|
||||
- Exécuter des commandes shell
|
||||
- Expliquer des erreurs de commandes
|
||||
- Suggérer des commandes appropriées pour la tâche demandée
|
||||
- Lire et explorer des fichiers
|
||||
- Configurer l'environnement de développement
|
||||
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
|
||||
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
|
||||
|
||||
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses.
|
||||
RÈGLES STRICTES:
|
||||
- Tu ne peux JAMAIS exécuter de commande ou de code
|
||||
- Tu ne peux que analyser, expliquer, et proposer des solutions
|
||||
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
|
||||
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
|
||||
|
||||
`)
|
||||
|
||||
if req.Cwd != "" {
|
||||
sb.WriteString("Répertoire courant: " + req.Cwd + "\n")
|
||||
analysis := LoadSystemAnalysis()
|
||||
if analysis != "" {
|
||||
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
|
||||
sb.WriteString(analysis)
|
||||
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
|
||||
}
|
||||
if req.Platform != "" {
|
||||
sb.WriteString("Plateforme: " + req.Platform + "\n")
|
||||
}
|
||||
if req.Context != "" {
|
||||
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
|
||||
}
|
||||
if len(req.History) > 0 {
|
||||
sb.WriteString("\nDernières commandes exécutées:\n")
|
||||
for _, h := range req.History {
|
||||
sb.WriteString(" " + h + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
sb.WriteString("Hostname: " + hostname + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
SetupSSEHeaders(w)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
sseWriter := NewSSEWriter(w)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []orchestrator.Message{
|
||||
{Role: "user", Content: req.Message},
|
||||
// Rebuild history into orchestrator
|
||||
history := s.shellConvStore.Get()
|
||||
for _, m := range history[:len(history)-1] { // all except last user msg
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
// Pre-load orchestrator history
|
||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||
lastUserMsg := history[len(history)-1].Content
|
||||
|
||||
var toolCalls []ToolCallInfo
|
||||
engine.OnChunk(func(data map[string]interface{}) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
sseWriter.Write(data)
|
||||
var finalContent string
|
||||
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
|
||||
finalContent = chunk
|
||||
sseWriter.Write(map[string]interface{}{"content": chunk})
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
if tc, ok := data["tool_call"].(map[string]interface{}); ok {
|
||||
argsMap := make(map[string]interface{})
|
||||
if args, ok := tc["args"].(string); ok {
|
||||
json.Unmarshal([]byte(args), &argsMap)
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCallInfo{
|
||||
ID: toString(tc["tool_call_id"]),
|
||||
Name: toString(tc["name"]),
|
||||
Args: argsMap,
|
||||
})
|
||||
}
|
||||
if tr, ok := data["tool_result"].(map[string]interface{}); ok {
|
||||
tcID := toString(tr["tool_call_id"])
|
||||
for i := range toolCalls {
|
||||
if toolCalls[i].ID == tcID {
|
||||
if err, ok := tr["is_error"].(bool); ok && err {
|
||||
toolCalls[i].Error = toString(tr["content"])
|
||||
} else {
|
||||
toolCalls[i].Result = &toolResponseData{
|
||||
Content: toString(tr["content"]),
|
||||
IsError: toBool(tr["is_error"]),
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
finalContent, _, _, err := engine.RunWithTools(ctx, messages)
|
||||
if err != nil {
|
||||
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if finalContent == "" && len(toolCalls) > 0 {
|
||||
finalContent = "(opérations terminées)"
|
||||
content := result
|
||||
if content == "" {
|
||||
content = finalContent
|
||||
}
|
||||
|
||||
writeJSONResp, _ := json.Marshal(ShellChatResponse{
|
||||
Content: finalContent,
|
||||
ToolCalls: toolCalls,
|
||||
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
|
||||
|
||||
sseWriter.Write(map[string]interface{}{
|
||||
"done": "true",
|
||||
"tokens": s.shellConvStore.ApproxTokens(),
|
||||
})
|
||||
sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
|
||||
ctx := context.Background()
|
||||
messages := []orchestrator.Message{
|
||||
{Role: "user", Content: req.Message},
|
||||
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
history := s.shellConvStore.Get()
|
||||
for _, m := range history[:len(history)-1] {
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||
lastUserMsg := history[len(history)-1].Content
|
||||
|
||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||
result, err := orb.Send(lastUserMsg)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if finalContent == "" {
|
||||
finalContent = "(tool calls completed, no text response)"
|
||||
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"content": result,
|
||||
"tokens": s.shellConvStore.ApproxTokens(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
messages := s.shellConvStore.Get()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"messages": messages,
|
||||
"tokens": s.shellConvStore.ApproxTokens(),
|
||||
"max_tokens": shellMaxTokens,
|
||||
"at_limit": s.shellConvStore.AtLimit(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.shellConvStore.Clear()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"tokens": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, ShellChatResponse{
|
||||
Content: finalContent,
|
||||
ToolCalls: nil,
|
||||
var sysInfo strings.Builder
|
||||
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
|
||||
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
sysInfo.WriteString("Hostname: " + hostname + "\n")
|
||||
}
|
||||
if user := os.Getenv("USER"); user != "" {
|
||||
sysInfo.WriteString("User: " + user + "\n")
|
||||
}
|
||||
|
||||
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "model name") {
|
||||
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
|
||||
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
if len(lines) >= 2 {
|
||||
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
|
||||
for i := 1; i < len(lines) && i <= 10; i++ {
|
||||
fields := strings.Fields(lines[i])
|
||||
if len(fields) >= 11 {
|
||||
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.scanResult != nil {
|
||||
sysInfo.WriteString("\nOutils installés:\n")
|
||||
for _, t := range s.scanResult.Tools {
|
||||
status := "✗"
|
||||
if t.Installed {
|
||||
status = "✓"
|
||||
}
|
||||
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
|
||||
}
|
||||
}
|
||||
|
||||
orb, err := orchestrator.New(s.config)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
||||
|
||||
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
|
||||
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
|
||||
1. Un résumé de l'état du système
|
||||
2. Les points d'attention (performance, sécurité, configuration)
|
||||
3. Des recommandations spécifiques d'optimisation
|
||||
4. Les outils manquants qui pourraient être utiles
|
||||
5. L'état du réseau et des connexions
|
||||
|
||||
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
|
||||
|
||||
` + sysInfo.String()
|
||||
|
||||
result, err := orb.Send(analysisPrompt)
|
||||
if err != nil {
|
||||
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SaveSystemAnalysis(result)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"analysis": result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type Server struct {
|
||||
scanResult *scanner.ScanResult
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
shellConvStore *ShellConvStore
|
||||
agentRegistry *agent.Registry
|
||||
agentToolsJSON json.RawMessage
|
||||
workflowEngine *workflow.Engine
|
||||
@@ -46,6 +47,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
s.config = cfg
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
s.convStore = NewConversationStore()
|
||||
s.shellConvStore = NewShellConvStore()
|
||||
s.agentRegistry = agent.DefaultRegistry()
|
||||
tools := s.agentRegistry.OpenAITools()
|
||||
toolsJSON, _ := json.Marshal(tools)
|
||||
@@ -89,6 +91,9 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
|
||||
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
|
||||
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
|
||||
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
|
||||
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
|
||||
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
|
||||
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
||||
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
||||
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||
|
||||
121
internal/api/shell_conversation.go
Normal file
121
internal/api/shell_conversation.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
const shellMaxTokens = 100000
|
||||
const shellCharsPerToken = 4
|
||||
|
||||
type ShellMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type ShellConvStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
msgs []ShellMessage
|
||||
}
|
||||
|
||||
func NewShellConvStore() *ShellConvStore {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
dir = "/tmp/muyue"
|
||||
}
|
||||
path := filepath.Join(dir, "shell_conversation.json")
|
||||
s := &ShellConvStore{path: path}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) load() {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
s.msgs = []ShellMessage{}
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &s.msgs)
|
||||
if s.msgs == nil {
|
||||
s.msgs = []ShellMessage{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) save() {
|
||||
data, _ := json.MarshalIndent(s.msgs, "", " ")
|
||||
os.MkdirAll(filepath.Dir(s.path), 0755)
|
||||
os.WriteFile(s.path, data, 0600)
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) Get() []ShellMessage {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]ShellMessage, len(s.msgs))
|
||||
copy(out, s.msgs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) Add(role, content string) ShellMessage {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
msg := ShellMessage{
|
||||
ID: time.Now().Format("20060102150405.000"),
|
||||
Role: role,
|
||||
Content: content,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
s.msgs = append(s.msgs, msg)
|
||||
s.save()
|
||||
return msg
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.msgs = []ShellMessage{}
|
||||
s.save()
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) ApproxTokens() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
total := 0
|
||||
for _, m := range s.msgs {
|
||||
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) AtLimit() bool {
|
||||
return s.ApproxTokens() >= shellMaxTokens
|
||||
}
|
||||
|
||||
func LoadSystemAnalysis() string {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func SaveSystemAnalysis(content string) error {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.MkdirAll(dir, 0755)
|
||||
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
|
||||
}
|
||||
Reference in New Issue
Block a user