feat: agrega integraciones OCR y Whisper ASR (servicios propios)
Nuevos submódulos en Integraciones para conectar el servicio propio de OCR (extracción de texto de imágenes) y el de transcripción de audio self-hosted (whisper-asr-webservice, Basic Auth), con panel de configuración y prueba en vivo, siguiendo el patrón ya usado por WebSMS/Hostinger. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
997e3cc790
commit
d493d6dee6
@@ -158,6 +158,9 @@ func main() {
|
||||
&models.UmindEventoLog{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
// Integraciones: OCR y transcripción de audio (servicios propios)
|
||||
&models.OcrConfig{},
|
||||
&models.WhisperAsrConfig{},
|
||||
}
|
||||
for _, m := range modelosBase {
|
||||
if err := app.Http.Database.DB.AutoMigrate(m); err != nil {
|
||||
|
||||
@@ -129,6 +129,9 @@ func Migrate() {
|
||||
&models.UmindEventoLog{},
|
||||
// API Keys de /api/v2 (token + IP obligatoria + scopes)
|
||||
&models.ApiKey{},
|
||||
// Integraciones: OCR y transcripción de audio (servicios propios)
|
||||
&models.OcrConfig{},
|
||||
&models.WhisperAsrConfig{},
|
||||
}
|
||||
for _, m := range modelosPrincipales {
|
||||
if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil {
|
||||
@@ -571,6 +574,8 @@ func SeedIntegraciones() {
|
||||
{"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"},
|
||||
{"Cloudflare", "Gestión de zonas, DNS, SSL y firewall en Cloudflare", "/app/cloudflare"},
|
||||
{"VCard API", "Integración con el sistema VCard externo (Laravel + Sanctum): usuarios, membresías, vcards, pagos y más", "/app/vcard-api"},
|
||||
{"OCR", "Extracción de texto de imágenes (comprobantes, capturas) vía servicio OCR propio", "/app/ocr"},
|
||||
{"Whisper ASR", "Transcripción de audio vía servicio propio de reconocimiento de voz (self-hosted)", "/app/whisper-asr"},
|
||||
}
|
||||
|
||||
var insertados []models.Submodules
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OcrConfig almacena la conexión al servicio propio de OCR (extracción de
|
||||
// texto de imágenes, ej. comprobantes de pago). Solo un registro activo a
|
||||
// la vez, mismo patrón que HostingerConfig/WebSmsConfig.
|
||||
type OcrConfig struct {
|
||||
gorm.Model
|
||||
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://ocr.u-s.app/extract
|
||||
Token string `json:"token" gorm:"column:token;type:text;not null"` // Bearer token
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (OcrConfig) TableName() string { return "ocr_config" }
|
||||
|
||||
func GetOcrConfig() (*OcrConfig, error) {
|
||||
var item OcrConfig
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func SaveOcrConfig(s OcrConfig) error {
|
||||
app.Http.Database.DB.Model(&OcrConfig{}).Where("activo = ?", true).Update("activo", false)
|
||||
s.Activo = true
|
||||
if s.ID > 0 {
|
||||
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
||||
"base_url": s.BaseURL,
|
||||
"token": s.Token,
|
||||
"notas": s.Notas,
|
||||
"activo": true,
|
||||
}).Error
|
||||
}
|
||||
return app.Http.Database.DB.Create(&s).Error
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WhisperAsrConfig almacena la conexión al servicio propio de transcripción
|
||||
// de audio (whisper-asr-webservice self-hosted, autenticado con Basic Auth)
|
||||
// — distinto del Whisper de OpenAI que ya se configura vía AiConfig con
|
||||
// modulo "whisper" para el bot de Telegram. Solo un registro activo a la vez.
|
||||
type WhisperAsrConfig struct {
|
||||
gorm.Model
|
||||
BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://whisper.u-s.app/asr
|
||||
Username string `json:"username" gorm:"column:username;size:255;not null"`
|
||||
Password string `json:"password" gorm:"column:password;type:text;not null"`
|
||||
Notas string `json:"notas" gorm:"column:notas;type:text"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
}
|
||||
|
||||
func (WhisperAsrConfig) TableName() string { return "whisper_asr_config" }
|
||||
|
||||
func GetWhisperAsrConfig() (*WhisperAsrConfig, error) {
|
||||
var item WhisperAsrConfig
|
||||
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func SaveWhisperAsrConfig(s WhisperAsrConfig) error {
|
||||
app.Http.Database.DB.Model(&WhisperAsrConfig{}).Where("activo = ?", true).Update("activo", false)
|
||||
s.Activo = true
|
||||
if s.ID > 0 {
|
||||
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
|
||||
"base_url": s.BaseURL,
|
||||
"username": s.Username,
|
||||
"password": s.Password,
|
||||
"notas": s.Notas,
|
||||
"activo": true,
|
||||
}).Error
|
||||
}
|
||||
return app.Http.Database.DB.Create(&s).Error
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
var ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// ExtraerTextoOCR manda una imagen al servicio propio de OCR y devuelve el
|
||||
// texto extraído. mimeType ej: "image/png", "image/jpeg".
|
||||
func ExtraerTextoOCR(imagenBytes []byte, mimeType string) (string, error) {
|
||||
cfg, err := models.GetOcrConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("el servicio de OCR no está configurado (Integraciones → OCR)")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"image_base64": base64.StdEncoding.EncodeToString(imagenBytes),
|
||||
"mime_type": mimeType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.BaseURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := ocrHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no se pudo contactar el servicio de OCR: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("el servicio de OCR respondió %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
Text string `json:"text"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del servicio de OCR: %s", string(raw))
|
||||
}
|
||||
if !out.Success {
|
||||
msg := out.Error
|
||||
if msg == "" {
|
||||
msg = "el servicio de OCR no pudo procesar la imagen"
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
return out.Text, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
var whisperAsrHTTPClient = &http.Client{Timeout: 120 * time.Second} // audio largo puede tardar
|
||||
|
||||
// TranscribirAudioSelfHosted manda un archivo de audio al servicio propio de
|
||||
// transcripción (whisper-asr-webservice, Basic Auth) y devuelve el texto.
|
||||
func TranscribirAudioSelfHosted(audioBytes []byte, filename string) (string, error) {
|
||||
cfg, err := models.GetWhisperAsrConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("el servicio de transcripción no está configurado (Integraciones → Whisper ASR)")
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "audio.wav"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("audio_file", filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(audioBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.WriteField("response_format", "json")
|
||||
if err := w.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.BaseURL, &buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.SetBasicAuth(cfg.Username, cfg.Password)
|
||||
|
||||
resp, err := whisperAsrHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no se pudo contactar el servicio de transcripción: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("el servicio de transcripción respondió %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("respuesta inesperada del servicio de transcripción: %s", string(raw))
|
||||
}
|
||||
return out.Text, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<div x-data="ocrApp()" x-init="init()" class="p-6 max-w-3xl mx-auto">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">OCR</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Extracción de texto de imágenes vía servicio propio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Configuración</h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="label">URL del servicio</label>
|
||||
<input x-model="form.base_url" class="input-field w-full" required placeholder="https://ocr.u-s.app/extract">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Token (Bearer)</label>
|
||||
<div class="relative">
|
||||
<input x-model="form.token" :type="showToken?'text':'password'" class="input-field w-full pr-10" required>
|
||||
<button type="button" @click="showToken=!showToken" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Notas</label>
|
||||
<input x-model="form.notas" class="input-field w-full" placeholder="Opcional">
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<p x-show="saved" class="text-green-600 text-sm mt-3">Configuración guardada</p>
|
||||
<div class="flex justify-end mt-5">
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar configuración'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Probar -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Probar extracción</h2>
|
||||
<form @submit.prevent="test()">
|
||||
<label class="label">Imagen</label>
|
||||
<input type="file" accept="image/*" @change="archivo=$event.target.files[0]" class="input-field w-full">
|
||||
<p x-show="testError" x-text="testError" class="text-red-500 text-sm mt-3"></p>
|
||||
<div x-show="testText" class="bg-slate-50 border border-slate-200 rounded-lg p-3 mt-3 text-sm text-slate-700 whitespace-pre-wrap" x-text="testText"></div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button type="submit" :disabled="testing || !archivo" class="btn-primary" x-text="testing?'Extrayendo...':'Extraer texto'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function ocrApp() {
|
||||
return {
|
||||
form:{ base_url:'', token:'', notas:'' },
|
||||
showToken:false, saving:false, error:'', saved:false,
|
||||
archivo:null, testing:false, testError:'', testText:'',
|
||||
|
||||
async init(){ await this.loadConfig(); },
|
||||
|
||||
async loadConfig(){
|
||||
try {
|
||||
const r=await axios.get('/app/ocr/config');
|
||||
if(r.data.data){
|
||||
this.form={id:r.data.data.ID, base_url:r.data.data.base_url, token:r.data.data.token, notas:r.data.data.notas||''};
|
||||
}
|
||||
} catch(e){}
|
||||
},
|
||||
|
||||
async save(){
|
||||
this.saving=true; this.error=''; this.saved=false;
|
||||
try {
|
||||
await axios.post('/app/ocr/save', this.form);
|
||||
this.saved=true;
|
||||
await this.loadConfig();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
async test(){
|
||||
if(!this.archivo) return;
|
||||
this.testing=true; this.testError=''; this.testText='';
|
||||
try {
|
||||
const fd=new FormData();
|
||||
fd.append('imagen', this.archivo);
|
||||
const r=await axios.post('/app/ocr/test', fd);
|
||||
this.testText=r.data.text||'(sin texto)';
|
||||
} catch(e){ this.testError=e.response?.data?.error||'Error al extraer'; }
|
||||
finally{ this.testing=false; }
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.btn-primary { background:#8eb02f; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; transition:background .15s; }
|
||||
.btn-primary:hover { background:#6d8c24; }
|
||||
.btn-primary:disabled { opacity:.6; cursor:not-allowed; }
|
||||
.input-field { border:1px solid #e2e8f0; border-radius:0.5rem; padding:0.5rem 0.75rem; font-size:.875rem; outline:none; transition:border-color .15s; }
|
||||
.input-field:focus { border-color:#8eb02f; }
|
||||
.label { display:block; font-size:.75rem; font-weight:600; color:#475569; margin-bottom:.25rem; text-transform:uppercase; letter-spacing:.05em; }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<div x-data="whisperAsrApp()" x-init="init()" class="p-6 max-w-3xl mx-auto">
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Whisper ASR</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Transcripción de audio vía servicio propio (self-hosted)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Configuración</h2>
|
||||
<form @submit.prevent="save()">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="label">URL del servicio</label>
|
||||
<input x-model="form.base_url" class="input-field w-full" required placeholder="https://whisper.u-s.app/asr">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Usuario</label>
|
||||
<input x-model="form.username" class="input-field w-full" required placeholder="whisper">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contraseña</label>
|
||||
<div class="relative">
|
||||
<input x-model="form.password" :type="showPass?'text':'password'" class="input-field w-full pr-10" required>
|
||||
<button type="button" @click="showPass=!showPass" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Notas</label>
|
||||
<input x-model="form.notas" class="input-field w-full" placeholder="Opcional">
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" x-text="error" class="text-red-500 text-sm mt-3"></p>
|
||||
<p x-show="saved" class="text-green-600 text-sm mt-3">Configuración guardada</p>
|
||||
<div class="flex justify-end mt-5">
|
||||
<button type="submit" :disabled="saving" class="btn-primary" x-text="saving?'Guardando...':'Guardar configuración'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Probar -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Probar transcripción</h2>
|
||||
<form @submit.prevent="test()">
|
||||
<label class="label">Audio</label>
|
||||
<input type="file" accept="audio/*" @change="archivo=$event.target.files[0]" class="input-field w-full">
|
||||
<p x-show="testError" x-text="testError" class="text-red-500 text-sm mt-3"></p>
|
||||
<div x-show="testText" class="bg-slate-50 border border-slate-200 rounded-lg p-3 mt-3 text-sm text-slate-700 whitespace-pre-wrap" x-text="testText"></div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button type="submit" :disabled="testing || !archivo" class="btn-primary" x-text="testing?'Transcribiendo...':'Transcribir'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function whisperAsrApp() {
|
||||
return {
|
||||
form:{ base_url:'', username:'', password:'', notas:'' },
|
||||
showPass:false, saving:false, error:'', saved:false,
|
||||
archivo:null, testing:false, testError:'', testText:'',
|
||||
|
||||
async init(){ await this.loadConfig(); },
|
||||
|
||||
async loadConfig(){
|
||||
try {
|
||||
const r=await axios.get('/app/whisper-asr/config');
|
||||
if(r.data.data){
|
||||
this.form={id:r.data.data.ID, base_url:r.data.data.base_url, username:r.data.data.username, password:r.data.data.password, notas:r.data.data.notas||''};
|
||||
}
|
||||
} catch(e){}
|
||||
},
|
||||
|
||||
async save(){
|
||||
this.saving=true; this.error=''; this.saved=false;
|
||||
try {
|
||||
await axios.post('/app/whisper-asr/save', this.form);
|
||||
this.saved=true;
|
||||
await this.loadConfig();
|
||||
} catch(e){ this.error=e.response?.data?.error||'Error al guardar'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
|
||||
async test(){
|
||||
if(!this.archivo) return;
|
||||
this.testing=true; this.testError=''; this.testText='';
|
||||
try {
|
||||
const fd=new FormData();
|
||||
fd.append('audio', this.archivo);
|
||||
const r=await axios.post('/app/whisper-asr/test', fd);
|
||||
this.testText=r.data.text||'(sin texto)';
|
||||
} catch(e){ this.testError=e.response?.data?.error||'Error al transcribir'; }
|
||||
finally{ this.testing=false; }
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.btn-primary { background:#8eb02f; color:#fff; padding:0.5rem 1rem; border-radius:0.5rem; font-weight:600; font-size:.875rem; transition:background .15s; }
|
||||
.btn-primary:hover { background:#6d8c24; }
|
||||
.btn-primary:disabled { opacity:.6; cursor:not-allowed; }
|
||||
.input-field { border:1px solid #e2e8f0; border-radius:0.5rem; padding:0.5rem 0.75rem; font-size:.875rem; outline:none; transition:border-color .15s; }
|
||||
.input-field:focus { border-color:#8eb02f; }
|
||||
.label { display:block; font-size:.75rem; font-weight:600; color:#475569; margin-bottom:.25rem; text-transform:uppercase; letter-spacing:.05em; }
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
func OcrConfigPage(c *fiber.Ctx) error {
|
||||
cfg, _ := models.GetOcrConfig()
|
||||
return c.Render("ocr_config", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
"cfg": cfg,
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func GetOcrConfigHandler(c *fiber.Ctx) error {
|
||||
cfg, err := models.GetOcrConfig()
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{"data": nil})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": cfg})
|
||||
}
|
||||
|
||||
func SaveOcrConfigHandler(c *fiber.Ctx) error {
|
||||
type body struct {
|
||||
ID uint `json:"id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Token string `json:"token"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
if b.BaseURL == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "base_url es requerida"})
|
||||
}
|
||||
if b.Token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "token es requerido"})
|
||||
}
|
||||
|
||||
cfg := models.OcrConfig{BaseURL: b.BaseURL, Token: b.Token, Notas: b.Notas}
|
||||
cfg.ID = b.ID
|
||||
if err := models.SaveOcrConfig(cfg); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "Configuración guardada"})
|
||||
}
|
||||
|
||||
// TestOcrConfigHandler recibe una imagen de prueba y devuelve el texto que
|
||||
// extrae el servicio configurado — confirma que la URL/token funcionan de
|
||||
// verdad, no solo que se guardaron.
|
||||
func TestOcrConfigHandler(c *fiber.Ctx) error {
|
||||
fh, err := c.FormFile("imagen")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "imagen requerida"})
|
||||
}
|
||||
if fh.Size > 8*1024*1024 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "máximo 8MB"})
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
|
||||
mimeType := fh.Header.Get("Content-Type")
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
texto, err := services.ExtraerTextoOCR(data, mimeType)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"text": texto})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
func WhisperAsrConfigPage(c *fiber.Ctx) error {
|
||||
cfg, _ := models.GetWhisperAsrConfig()
|
||||
return c.Render("whisper_asr_config", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
"cfg": cfg,
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func GetWhisperAsrConfigHandler(c *fiber.Ctx) error {
|
||||
cfg, err := models.GetWhisperAsrConfig()
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{"data": nil})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": cfg})
|
||||
}
|
||||
|
||||
func SaveWhisperAsrConfigHandler(c *fiber.Ctx) error {
|
||||
type body struct {
|
||||
ID uint `json:"id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Notas string `json:"notas"`
|
||||
}
|
||||
var b body
|
||||
if err := c.BodyParser(&b); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
||||
}
|
||||
if b.BaseURL == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "base_url es requerida"})
|
||||
}
|
||||
if b.Username == "" || b.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "usuario y contraseña son requeridos"})
|
||||
}
|
||||
|
||||
cfg := models.WhisperAsrConfig{BaseURL: b.BaseURL, Username: b.Username, Password: b.Password, Notas: b.Notas}
|
||||
cfg.ID = b.ID
|
||||
if err := models.SaveWhisperAsrConfig(cfg); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "Configuración guardada"})
|
||||
}
|
||||
|
||||
// TestWhisperAsrConfigHandler recibe un audio de prueba y devuelve la
|
||||
// transcripción — confirma que la URL/credenciales funcionan de verdad.
|
||||
func TestWhisperAsrConfigHandler(c *fiber.Ctx) error {
|
||||
fh, err := c.FormFile("audio")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "audio requerido"})
|
||||
}
|
||||
if fh.Size > 20*1024*1024 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "máximo 20MB"})
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
||||
}
|
||||
|
||||
texto, err := services.TranscribirAudioSelfHosted(data, fh.Filename)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"text": texto})
|
||||
}
|
||||
@@ -619,6 +619,18 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/websms/logs", controllers.GetWebSmsLogs)
|
||||
protected.Get("/websms/webhook-logs", controllers.GetWebSmsWebhookLogs)
|
||||
|
||||
// ─── OCR (servicio propio) ─────────────────────────────────────────────────
|
||||
protected.Get("/ocr", middlewares.MenuMiddleware, controllers.OcrConfigPage)
|
||||
protected.Get("/ocr/config", controllers.GetOcrConfigHandler)
|
||||
protected.Post("/ocr/save", controllers.SaveOcrConfigHandler)
|
||||
protected.Post("/ocr/test", controllers.TestOcrConfigHandler)
|
||||
|
||||
// ─── Whisper ASR (transcripción, servicio propio) ─────────────────────────
|
||||
protected.Get("/whisper-asr", middlewares.MenuMiddleware, controllers.WhisperAsrConfigPage)
|
||||
protected.Get("/whisper-asr/config", controllers.GetWhisperAsrConfigHandler)
|
||||
protected.Post("/whisper-asr/save", controllers.SaveWhisperAsrConfigHandler)
|
||||
protected.Post("/whisper-asr/test", controllers.TestWhisperAsrConfigHandler)
|
||||
|
||||
// ─── Planes dLocal (gestión desde panel protegido) ────────────────────────
|
||||
protected.Get("/dlocal/planes", apiControllers.SeePlanes)
|
||||
protected.Post("/dlocal/planes", apiControllers.CreatePlan)
|
||||
|
||||
Reference in New Issue
Block a user