feat: explain-archive LLM endpoint + UI button v1.3.40
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user