Compare commits

...
2 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 f4d3e1f03d feat(ai-config): agregar botón Probar para verificar conectividad del provider
- Endpoint GET /ai-config/:id/test que llama /api/tags (Ollama) o /v1/models (OpenAI-compat/Anthropic)
- Botón "Probar" en cada fila de la tabla con modal de resultado JSON

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 23:38:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 80c29bf0d3 feat(ai): integrar Ollama en AiConfig para ia_controller, query_runner y landing
- Agrega provider 'ollama' al panel de AiConfig con badge azul y hint de URL
- ia_controller: reemplaza webhook n8n hardcodeado por llamada directa a Ollama vía AiConfig (módulo 'ia')
- query_runner: agrega soporte explícito de ollama (model fallback gemma3:1b, Basic Auth para URL pública)
- HTML: agrega módulo 'IA / vCard' en el selector de módulos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 23:35:19 -05:00
6 changed files with 199 additions and 61 deletions
+4
View File
@@ -57,6 +57,10 @@ func DeleteAiConfig(id uint) error {
return app.Http.Database.DB.Delete(&AiConfig{}, id).Error return app.Http.Database.DB.Delete(&AiConfig{}, id).Error
} }
func GetAiConfigByID(id uint, out *AiConfig) error {
return app.Http.Database.DB.First(out, id).Error
}
// GetActiveAiConfig retorna la primera configuración activa del provider indicado. // GetActiveAiConfig retorna la primera configuración activa del provider indicado.
// Si provider está vacío, retorna cualquier config activa. // Si provider está vacío, retorna cualquier config activa.
func GetActiveAiConfig(provider string) (*AiConfig, error) { func GetActiveAiConfig(provider string) (*AiConfig, error) {
+41 -6
View File
@@ -73,9 +73,10 @@
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" <span class="px-2 py-0.5 rounded-full text-xs font-semibold"
:class="{ :class="{
'bg-blue-100 text-blue-700': m.trim() === 'landing', 'bg-blue-100 text-blue-700': m.trim() === 'landing',
'bg-[#e9f0cf] text-[#5a7a1e]': m.trim() === 'query_runner' 'bg-[#e9f0cf] text-[#5a7a1e]': m.trim() === 'query_runner',
'bg-purple-100 text-purple-700': m.trim() === 'ia'
}" }"
x-text="m.trim() === 'landing' ? 'Landing' : m.trim() === 'query_runner' ? 'Query Runner' : m.trim()"> x-text="m.trim() === 'landing' ? 'Landing' : m.trim() === 'query_runner' ? 'Query Runner' : m.trim() === 'ia' ? 'IA / vCard' : m.trim()">
</span> </span>
</template> </template>
</div> </div>
@@ -87,7 +88,8 @@
'bg-purple-100 text-purple-700': item.provider === 'qwen', 'bg-purple-100 text-purple-700': item.provider === 'qwen',
'bg-green-100 text-green-700': item.provider === 'openai', 'bg-green-100 text-green-700': item.provider === 'openai',
'bg-orange-100 text-orange-700': item.provider === 'anthropic', 'bg-orange-100 text-orange-700': item.provider === 'anthropic',
'bg-gray-100 text-gray-700': !['qwen','openai','anthropic'].includes(item.provider) 'bg-blue-100 text-blue-700': item.provider === 'ollama',
'bg-gray-100 text-gray-700': !['qwen','openai','anthropic','ollama'].includes(item.provider)
}" x-text="item.provider"></span> }" x-text="item.provider"></span>
</td> </td>
<td class="py-2 px-3 text-gray-500 font-mono text-xs" x-text="item.model_name || '—'"></td> <td class="py-2 px-3 text-gray-500 font-mono text-xs" x-text="item.model_name || '—'"></td>
@@ -99,6 +101,8 @@
</td> </td>
<td class="py-2 px-3 text-right"> <td class="py-2 px-3 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button @click="testConfig(item.ID)"
class="text-xs text-green-600 hover:text-green-800 font-medium transition">Probar</button>
<button @click="openEdit(item)" <button @click="openEdit(item)"
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button> class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
<button @click="confirmDelete(item.ID)" <button @click="confirmDelete(item.ID)"
@@ -143,6 +147,7 @@
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option> <option value="anthropic">Anthropic</option>
<option value="groq">Groq</option> <option value="groq">Groq</option>
<option value="ollama">Ollama (local)</option>
<option value="otro">Otro</option> <option value="otro">Otro</option>
</select> </select>
</div> </div>
@@ -164,10 +169,16 @@
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">Base URL <span class="text-gray-400">(opcional — dejar vacío para usar el default del provider)</span></label> <label class="block text-xs font-medium text-gray-600 mb-1">Base URL
<span x-show="form.provider !== 'ollama'" class="text-gray-400">(opcional — dejar vacío para usar el default del provider)</span>
<span x-show="form.provider === 'ollama'" class="text-blue-500">* requerido — incluir /v1 al final</span>
</label>
<input x-model="form.base_url" type="url" <input x-model="form.base_url" type="url"
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1" :placeholder="form.provider === 'ollama' ? 'http://10.0.1.15:11434/v1' : 'https://dashscope.aliyuncs.com/compatible-mode/v1'"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" /> class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<p x-show="form.provider === 'ollama'" x-cloak class="text-[10px] text-blue-500 mt-1">
Interna (red Coolify): <code>http://10.0.1.15:11434/v1</code> — Pública: <code>https://ollama.u-s.app/v1</code>
</p>
</div> </div>
<div> <div>
@@ -226,6 +237,22 @@
</div> </div>
</div> </div>
<!-- Modal resultado test -->
<div x-show="testResult" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div @click.outside="testResult = null" class="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
<div class="flex items-center gap-3 mb-3">
<span x-show="testResult && testResult.ok" class="text-2xl"></span>
<span x-show="testResult && !testResult.ok" class="text-2xl"></span>
<h2 class="text-lg font-bold" x-text="testResult && testResult.ok ? 'Conexión exitosa' : 'Error de conexión'"></h2>
</div>
<pre x-show="testResult" class="text-xs bg-gray-50 border border-gray-200 rounded-lg p-3 overflow-x-auto max-h-60"
x-text="JSON.stringify(testResult, null, 2)"></pre>
<div class="flex justify-end mt-4">
<button @click="testResult = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cerrar</button>
</div>
</div>
</div>
<!-- Modal confirmar eliminación --> <!-- Modal confirmar eliminación -->
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4"> <div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center"> <div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
@@ -251,13 +278,14 @@ function aiConfigApp() {
loading: false, saving: false, loading: false, saving: false,
items: [], total: 0, totalPages: 1, page: 1, items: [], total: 0, totalPages: 1, page: 1,
search: '', search: '',
showModal: false, editItem: null, deleteId: null, showModal: false, editItem: null, deleteId: null, testResult: null,
errorMsg: '', successMsg: '', formError: '', errorMsg: '', successMsg: '', formError: '',
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [] }, form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [] },
moduleOptions: [ moduleOptions: [
{ value: 'landing', label: 'Landing Generator' }, { value: 'landing', label: 'Landing Generator' },
{ value: 'query_runner', label: 'Query Runner SQL' }, { value: 'query_runner', label: 'Query Runner SQL' },
{ value: 'ia', label: 'IA / vCard' },
], ],
async init() { await this.load() }, async init() { await this.load() },
@@ -331,6 +359,13 @@ function aiConfigApp() {
await this.load() await this.load()
}, },
async testConfig(id) {
this.loading = true
const res = await fetch(`/app/ai-config/${id}/test`)
this.loading = false
this.testResult = await res.json()
},
showSuccess(msg) { showSuccess(msg) {
this.successMsg = msg this.successMsg = msg
setTimeout(() => { this.successMsg = '' }, 3000) setTimeout(() => { this.successMsg = '' }, 3000)
+78
View File
@@ -1,9 +1,12 @@
package controllers package controllers
import ( import (
"encoding/json"
"math" "math"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
@@ -164,6 +167,81 @@ func GetAiConfigSelect(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"registros": items}) return c.JSON(fiber.Map{"registros": items})
} }
// TestAiConfigHandler verifica conectividad con el provider de la config.
func TestAiConfigHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
var item models.AiConfig
if err := models.GetAiConfigByID(uint(id), &item); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "config no encontrada"})
}
client := &http.Client{Timeout: 10 * time.Second}
var testURL string
var req *http.Request
switch item.Provider {
case "ollama":
base := strings.TrimSuffix(strings.TrimRight(item.BaseURL, "/"), "/v1")
testURL = base + "/api/tags"
req, _ = http.NewRequest("GET", testURL, nil)
if item.ApiKey != "" && item.ApiKey != "ollama" {
req.SetBasicAuth("ollama", item.ApiKey)
}
case "anthropic":
// Anthropic no tiene /models; usamos /v1/models igual (devuelve 200 con lista)
base := item.BaseURL
if base == "" {
base = "https://api.anthropic.com/v1"
}
testURL = base + "/models"
req, _ = http.NewRequest("GET", testURL, nil)
req.Header.Set("x-api-key", item.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01")
default:
base := item.BaseURL
if base == "" {
switch item.Provider {
case "openai":
base = "https://api.openai.com/v1"
case "qwen":
base = "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "groq":
base = "https://api.groq.com/openai/v1"
default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "BaseURL requerida para este provider"})
}
}
testURL = base + "/models"
req, _ = http.NewRequest("GET", testURL, nil)
req.Header.Set("Authorization", "Bearer "+item.ApiKey)
}
resp, err := client.Do(req)
if err != nil {
return c.JSON(fiber.Map{"ok": false, "error": err.Error()})
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
return c.JSON(fiber.Map{"ok": false, "status": resp.StatusCode, "error": body})
}
// Para Ollama devolvemos la lista de modelos disponibles
if item.Provider == "ollama" {
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
return c.JSON(fiber.Map{"ok": true, "status": resp.StatusCode, "data": body})
}
return c.JSON(fiber.Map{"ok": true, "status": resp.StatusCode})
}
// DeleteAiConfigHandler elimina una configuración de IA. // DeleteAiConfigHandler elimina una configuración de IA.
func DeleteAiConfigHandler(c *fiber.Ctx) error { func DeleteAiConfigHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64) id, err := strconv.ParseUint(c.Params("id"), 10, 64)
+64 -53
View File
@@ -6,14 +6,17 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
) )
type DataIa struct { type DataIa struct {
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
Model string `json:"model"` Model string `json:"model"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
} }
type respuestaOllama struct { type respuestaOllama struct {
@@ -22,64 +25,72 @@ type respuestaOllama struct {
} }
func GeneraTextoStream(c *fiber.Ctx) error { func GeneraTextoStream(c *fiber.Ctx) error {
var data DataIa var data DataIa
if err := c.BodyParser(&data); err != nil { if err := c.BodyParser(&data); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
} }
data.Stream = true
// Forzar stream = true config, err := models.GetAiConfigForService("ia")
data.Stream = true 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",
})
}
jsonData, err := json.Marshal(data) if data.Model == "" {
if err != nil { data.Model = config.ModelName
return err }
} if data.Model == "" {
data.Model = "gemma3:1b"
}
req, err := http.NewRequest("POST", "https://n8n.u-s.app/webhook/c945b974-f534-419b-a6da-4ebd832f8cd9", bytes.NewBuffer(jsonData)) // Usar endpoint nativo de Ollama (/api/generate), no OpenAI compat (/v1/...)
if err != nil { baseURL := strings.TrimSuffix(strings.TrimRight(config.BaseURL, "/"), "/v1")
return err endpoint := baseURL + "/api/generate"
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer sk-43804e00f5294200ad1e599550fbee4f")
client := &http.Client{} jsonData, _ := json.Marshal(data)
resp, err := client.Do(req) req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Content-Type", "application/json")
// Sin auth para red interna; api_key como token para URL pública
if config.ApiKey != "" && config.ApiKey != "ollama" {
req.SetBasicAuth("ollama", config.ApiKey)
}
// Configurar headers para streaming client := &http.Client{Timeout: 120 * time.Second}
c.Set("Content-Type", "text/plain; charset=utf-8") resp, err := client.Do(req)
c.Set("Cache-Control", "no-cache") if err != nil {
c.Set("Connection", "keep-alive") return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { c.Set("Content-Type", "text/plain; charset=utf-8")
defer resp.Body.Close() c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
scanner := bufio.NewScanner(resp.Body) c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var msg respuestaOllama
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
if msg.Response != "" {
fmt.Fprint(w, msg.Response)
w.Flush()
}
if msg.Done {
break
}
}
})
for scanner.Scan() { return nil
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var msg respuestaOllama
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
if msg.Response != "" {
fmt.Fprint(w, msg.Response)
w.Flush() // <-- envía inmediatamente
}
if msg.Done {
break
}
}
})
return nil
} }
+11 -2
View File
@@ -552,6 +552,7 @@ SQL optimizado:`, sql)
case "anthropic": case "anthropic":
baseURL = "https://api.anthropic.com/v1" baseURL = "https://api.anthropic.com/v1"
default: default:
log.Printf("[AI_SQL] Provider '%s' requiere BaseURL configurada", config.Provider)
return nil return nil
} }
} }
@@ -565,6 +566,8 @@ SQL optimizado:`, sql)
modelName = "qwen2.5-72b-instruct" modelName = "qwen2.5-72b-instruct"
case "anthropic": case "anthropic":
modelName = "claude-3-haiku-20240307" modelName = "claude-3-haiku-20240307"
case "ollama":
modelName = "gemma3:1b"
default: default:
modelName = "gpt-4o-mini" modelName = "gpt-4o-mini"
} }
@@ -595,10 +598,16 @@ SQL optimizado:`, sql)
req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if config.Provider == "anthropic" { switch config.Provider {
case "anthropic":
req.Header.Set(authHeader, config.ApiKey) req.Header.Set(authHeader, config.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-version", "2023-06-01")
} else { case "ollama":
// Sin auth para red interna; para URL pública el api_key es "usuario:token"
if config.ApiKey != "" && config.ApiKey != "ollama" {
req.SetBasicAuth("ollama", config.ApiKey)
}
default:
req.Header.Set(authHeader, "Bearer "+config.ApiKey) req.Header.Set(authHeader, "Bearer "+config.ApiKey)
} }
+1
View File
@@ -289,6 +289,7 @@ func UserRoutes(app fiber.Router) {
protected.Post("/ai-config", controllers.CreateAiConfigHandler) protected.Post("/ai-config", controllers.CreateAiConfigHandler)
protected.Put("/ai-config/:id", controllers.UpdateAiConfigHandler) protected.Put("/ai-config/:id", controllers.UpdateAiConfigHandler)
protected.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler) protected.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler)
protected.Get("/ai-config/:id/test", controllers.TestAiConfigHandler)
// ─── OSS API (Alibaba Cloud + S3/MinIO) ──────────────────────────────────── // ─── OSS API (Alibaba Cloud + S3/MinIO) ────────────────────────────────────
protected.Get("/oss-api", middlewares.MenuMiddleware, controllers.OssApiIndex) protected.Get("/oss-api", middlewares.MenuMiddleware, controllers.OssApiIndex)