From db3815d03d58ef69498300a75fa5793f312b6835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 3 May 2026 21:33:18 +0400 Subject: [PATCH] feat: explain-archive LLM endpoint + UI button v1.3.40 --- console/deploy/console.yaml | 2 +- console/internal/api/explain_archive.go | 180 ++++++++++++++++++++++++ console/internal/api/server.go | 1 + console/ui/index.html | 6 +- console/ui/js/functions.js | 47 +++++++ 5 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 console/internal/api/explain_archive.go diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 3e8688d..815ef40 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.39 + image: naeel/fission-console:v1.3.40 imagePullPolicy: Always ports: - containerPort: 8090 diff --git a/console/internal/api/explain_archive.go b/console/internal/api/explain_archive.go new file mode 100644 index 0000000..66ef623 --- /dev/null +++ b/console/internal/api/explain_archive.go @@ -0,0 +1,180 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + "context" +) + +const ( + // maxExplainCodeBytes — суммарный лимит кода из архива для отправки в LLM. + // LLM бесплатный и слабый — не перегружаем контекст. + maxExplainCodeBytes = 20 * 1024 +) + +// handleExplainArchive — POST /console/api/ai/explain-archive +// Принимает zip-архив, извлекает код, отправляет в LLM с вопросом "что делает?". +func (s *Server) handleExplainArchive(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if s.llmKey == "" { + writeJSONError(w, http.StatusServiceUnavailable, "AI-ассистент не настроен (FISSION_LLM_KEY не задан)") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024) + if err := r.ParseMultipartForm(maxArchiveBytes); err != nil { + writeJSONError(w, http.StatusBadRequest, "ошибка разбора multipart: "+err.Error()) + return + } + + f, _, err := r.FormFile("archive") + if err != nil { + writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно") + return + } + defer f.Close() + + var buf bytes.Buffer + if _, err := buf.ReadFrom(f); err != nil { + writeJSONError(w, http.StatusBadRequest, "ошибка чтения архива: "+err.Error()) + return + } + if buf.Len() > maxArchiveBytes { + writeJSONError(w, http.StatusRequestEntityTooLarge, + fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024)) + return + } + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "не удалось открыть zip: "+err.Error()) + return + } + + // Поддерживаемые расширения + supported := map[string]bool{".py": true, ".js": true, ".rb": true, ".php": true, ".go": true} + + type fileChunk struct { + name string + code string + } + var chunks []fileChunk + var totalBytes int + + for _, zf := range zr.File { + if zf.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(archiveFileExt(zf.Name)) + if !supported[ext] { + continue + } + if totalBytes >= maxExplainCodeBytes { + break + } + rc, err := zf.Open() + if err != nil { + continue + } + limit := maxExplainCodeBytes - totalBytes + content, err := io.ReadAll(io.LimitReader(rc, int64(limit))) + rc.Close() + if err != nil || len(content) == 0 { + continue + } + chunks = append(chunks, fileChunk{name: zf.Name, code: string(content)}) + totalBytes += len(content) + } + + if len(chunks) == 0 { + writeJSONError(w, http.StatusBadRequest, "нет поддерживаемых файлов (.py, .js, .rb, .php, .go)") + return + } + + // Собираем текст для LLM + var sb strings.Builder + sb.WriteString("Ниже приведены файлы из архива serverless-функции. Опиши КРАТКО (2-5 предложений) что делает эта функция.\n\n") + for _, c := range chunks { + sb.WriteString(fmt.Sprintf("=== %s ===\n%s\n\n", c.name, c.code)) + } + prompt := sb.String() + + llmReqBody := llmChatRequest{ + Model: "gpt-oss-120b", + Messages: []llmMessage{ + {Role: "system", Content: "Ты ассистент для анализа serverless-функций. Отвечай кратко, на русском языке."}, + {Role: "user", Content: prompt}, + }, + MaxTokens: 512, + } + + body, _ := json.Marshal(llmReqBody) + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + llmReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + strings.TrimRight(s.llmURL, "/")+"/chat/completions", + bytes.NewReader(body), + ) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + llmReq.Header.Set("Content-Type", "application/json") + llmReq.Header.Set("Authorization", "Bearer "+s.llmKey) + + resp, err := s.http.Do(llmReq) + if err != nil { + writeJSONError(w, http.StatusBadGateway, "LLM недоступен: "+err.Error()) + return + } + defer resp.Body.Close() + + var llmResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&llmResp); err != nil { + writeJSONError(w, http.StatusBadGateway, "parse error: "+err.Error()) + return + } + if llmResp.Error != nil { + writeJSONError(w, http.StatusBadGateway, llmResp.Error.Message) + return + } + if len(llmResp.Choices) == 0 { + writeJSONError(w, http.StatusBadGateway, "пустой ответ от LLM") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "answer": strings.TrimSpace(llmResp.Choices[0].Message.Content), + }) +} + +// archiveFileExt возвращает расширение файла из пути внутри zip (включая точку). +func archiveFileExt(name string) string { + for i := len(name) - 1; i >= 0 && name[i] != '/'; i-- { + if name[i] == '.' { + return name[i:] + } + } + return "" +} diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 46b42fe..65662f1 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -157,6 +157,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug)) mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck)) mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive)) + mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive)) // --- ai/ask feature (удалить строку чтобы выкосить роут) --- mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk)) // --- end ai/ask feature --- diff --git a/console/ui/index.html b/console/ui/index.html index 3fe7ac1..152ca35 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -100,7 +100,7 @@
NUBES
FISSION CONSOLE
-
v1.3.39
+
v1.3.40
@@ -229,6 +229,7 @@
+
- v1.3.39 + v1.3.40
diff --git a/console/ui/js/functions.js b/console/ui/js/functions.js index 53e63ca..73b93e8 100644 --- a/console/ui/js/functions.js +++ b/console/ui/js/functions.js @@ -75,6 +75,53 @@ function lintArchiveFile(prefix) { }); } +// explainArchiveFile — отправляет zip-архив на /console/api/ai/explain-archive, +// показывает ответ LLM в блоке {prefix}-ai-result. +function explainArchiveFile(prefix) { + var input = document.getElementById(prefix + '-archive-file'); + var resEl = document.getElementById(prefix + '-ai-result'); + var btn = document.getElementById(prefix + '-explain-archive-btn'); + function showRes(text, bg, color) { + if (resEl) { + resEl.style.display = 'block'; + resEl.style.background = bg; + resEl.style.color = color; + resEl.textContent = text; + } + } + if (!input || !input.files || !input.files[0]) { + showRes('Выберите .zip файл', '#3a1a1a', '#f88'); + return; + } + var file = input.files[0]; + if (file.size > 100 * 1024) { + showRes('Архив слишком большой: максимум 100 KB', '#3a1a1a', '#f88'); + return; + } + if (btn) { btn.disabled = true; btn.textContent = '⏳ Спрашиваю LLM…'; } + showRes('Запрашиваю у LLM…', 'var(--bg-alt)', 'var(--fg)'); + var fd = new FormData(); + fd.append('archive', file); + fetch(API_BASE + '/ai/explain-archive', { + method: 'POST', + headers: authHeaders(), + body: fd + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (btn) { btn.disabled = false; btn.textContent = '📖 LLM: Что делает?'; } + if (data.error) { + showRes('Ошибка: ' + data.error, '#3a2a00', '#ffa'); + return; + } + showRes(data.answer || '(пустой ответ)', '#1a2a3a', '#8cf'); + }) + .catch(function(e) { + if (btn) { btn.disabled = false; btn.textContent = '📖 LLM: Что делает?'; } + showRes('Ошибка сети: ' + e.message, '#3a2a00', '#ffa'); + }); +} + function parseMethods(v) { const items = String(v || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean); return items.length ? Array.from(new Set(items)) : ['GET'];