Prepara el terreno para que el cliente cargue su propia cuenta de IA sin que su clave quede en texto plano ni que el selector le muestre las de los demás. - ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se leen igual y quedan cifradas al primer guardado, sin script ni downtime. utils.Decrypt hace panic (no devuelve error) con entrada que no es un ciphertext válido, así que el fallback va sobre recover — eso mismo es el mecanismo de detección de "todavía está en claro". - Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram, Whisper, Query Runner, streaming de IA y Landing Generator. Un lector sin migrar mandaría el ciphertext como API key. - AiConfig gana TenantID (null = global del staff) y GetAiConfigSelectPorTenants para acotar el selector. - Test de los 4 casos del fallback, incluido hex válido que no descifra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
243 lines
5.8 KiB
Go
243 lines
5.8 KiB
Go
package controllers
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
type DataIa struct {
|
|
Prompt string `json:"prompt"`
|
|
Model string `json:"model"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
type respuestaOllama struct {
|
|
Response string `json:"response"`
|
|
Done bool `json:"done"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type openaiChatMsg struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type openaiChatReq struct {
|
|
Model string `json:"model"`
|
|
Messages []openaiChatMsg `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
type openaiChatChoice struct {
|
|
Delta struct {
|
|
Content string `json:"content"`
|
|
} `json:"delta"`
|
|
FinishReason *string `json:"finish_reason"`
|
|
}
|
|
|
|
type openaiChatResp struct {
|
|
Choices []openaiChatChoice `json:"choices"`
|
|
}
|
|
|
|
type geminiPart struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type geminiContent struct {
|
|
Parts []geminiPart `json:"parts"`
|
|
}
|
|
|
|
type geminiReq struct {
|
|
Contents []geminiContent `json:"contents"`
|
|
}
|
|
|
|
type geminiCandidate struct {
|
|
Content geminiContent `json:"content"`
|
|
FinishReason *string `json:"finishReason"`
|
|
}
|
|
|
|
type geminiResp struct {
|
|
Candidates []geminiCandidate `json:"candidates"`
|
|
}
|
|
|
|
func GeneraTextoStream(c *fiber.Ctx) error {
|
|
var data DataIa
|
|
if err := c.BodyParser(&data); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
|
|
}
|
|
data.Stream = true
|
|
|
|
config, err := models.GetAiConfigForService("ia")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
|
"error": "No hay configuración de IA activa. Configura una en /app/ai-config",
|
|
})
|
|
}
|
|
|
|
if data.Model == "" {
|
|
data.Model = config.ModelName
|
|
}
|
|
if data.Model == "" {
|
|
data.Model = "gemma3:1b"
|
|
}
|
|
|
|
provider := strings.ToLower(config.Provider)
|
|
baseURL := strings.TrimRight(config.BaseURL, "/")
|
|
hasV1 := strings.Contains(baseURL, "/v1")
|
|
|
|
var endpoint string
|
|
var bodyReader io.Reader
|
|
useOpenAI := provider != "ollama" || hasV1
|
|
useGemini := provider == "gemini"
|
|
|
|
if useGemini {
|
|
model := data.Model
|
|
endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s", model, config.ClaveEnClaro())
|
|
gReq := geminiReq{
|
|
Contents: []geminiContent{
|
|
{Parts: []geminiPart{{Text: data.Prompt}}},
|
|
},
|
|
}
|
|
jsonData, _ := json.Marshal(gReq)
|
|
bodyReader = bytes.NewBuffer(jsonData)
|
|
} else if !useOpenAI {
|
|
// Native Ollama API
|
|
endpoint = baseURL + "/api/generate"
|
|
jsonData, _ := json.Marshal(data)
|
|
bodyReader = bytes.NewBuffer(jsonData)
|
|
} else {
|
|
// OpenAI-compatible (/v1/chat/completions)
|
|
baseURL = strings.TrimSuffix(baseURL, "/v1")
|
|
endpoint = baseURL + "/v1/chat/completions"
|
|
chatReq := openaiChatReq{
|
|
Model: data.Model,
|
|
Messages: []openaiChatMsg{
|
|
{Role: "user", Content: data.Prompt},
|
|
},
|
|
Stream: true,
|
|
}
|
|
jsonData, _ := json.Marshal(chatReq)
|
|
bodyReader = bytes.NewBuffer(jsonData)
|
|
}
|
|
|
|
log.Printf("[IA] endpoint=%s model=%s provider=%s", endpoint, data.Model, provider)
|
|
|
|
req, err := http.NewRequest("POST", endpoint, bodyReader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Auth
|
|
clave := config.ClaveEnClaro()
|
|
if useGemini {
|
|
// API key ya va en la URL
|
|
} else if provider == "ollama" && clave != "" && clave != "ollama" {
|
|
req.SetBasicAuth("ollama", clave)
|
|
} else if clave != "" {
|
|
req.Header.Set("Authorization", "Bearer "+clave)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 120 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Printf("[IA] Error conectando: %v", err)
|
|
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
|
|
"error": fmt.Sprintf("Error conectando a %s: %v", provider, err),
|
|
})
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
log.Printf("[IA] %s status %d: %s", provider, resp.StatusCode, string(bodyBytes))
|
|
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
|
|
"error": fmt.Sprintf("%s respondió con status %d", provider, resp.StatusCode),
|
|
"details": string(bodyBytes),
|
|
})
|
|
}
|
|
|
|
c.Set("Content-Type", "text/plain; charset=utf-8")
|
|
c.Set("Cache-Control", "no-cache")
|
|
c.Set("X-Accel-Buffering", "no")
|
|
|
|
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
|
defer resp.Body.Close()
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if useGemini {
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
var msg geminiResp
|
|
if err := json.Unmarshal([]byte(line[6:]), &msg); err != nil {
|
|
continue
|
|
}
|
|
if len(msg.Candidates) > 0 && len(msg.Candidates[0].Content.Parts) > 0 {
|
|
text := msg.Candidates[0].Content.Parts[0].Text
|
|
if text != "" {
|
|
fmt.Fprint(w, text)
|
|
w.Flush()
|
|
}
|
|
if msg.Candidates[0].FinishReason != nil {
|
|
break
|
|
}
|
|
}
|
|
} else if useOpenAI {
|
|
var msg openaiChatResp
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
if err != io.EOF {
|
|
log.Printf("[IA] Error decodificando respuesta OpenAI: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
if len(msg.Choices) == 0 {
|
|
continue
|
|
}
|
|
content := msg.Choices[0].Delta.Content
|
|
if content != "" {
|
|
fmt.Fprint(w, content)
|
|
w.Flush()
|
|
}
|
|
if msg.Choices[0].FinishReason != nil {
|
|
break
|
|
}
|
|
} else {
|
|
var msg respuestaOllama
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
continue
|
|
}
|
|
if msg.Error != "" {
|
|
log.Printf("[IA] Ollama error: %s", msg.Error)
|
|
fmt.Fprintf(w, "[Error: %s]", msg.Error)
|
|
w.Flush()
|
|
break
|
|
}
|
|
if msg.Response != "" {
|
|
fmt.Fprint(w, msg.Response)
|
|
w.Flush()
|
|
}
|
|
if msg.Done {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return nil
|
|
}
|