El servicio devolvía la transcripción correcta y la reportábamos como
error: "respuesta inesperada del servicio de transcripción: <la
transcripción completa>". La causa es que mandábamos response_format=json,
que es la convención de la API de OpenAI — whisper-asr-webservice usa el
query param `output`, así que ignoró el campo y respondió en su formato
por defecto (txt), y el parser exigía JSON.
- textoDeRespuestaWhisper acepta las dos formas (JSON {"text":...} o texto
pelado) en vez de adivinar qué variante corre del otro lado. Con test:
8 casos, incluidos JSON sin campo text y un texto que empieza con "{".
- Whisper y OCR: la caja de resultado ahora muestra el conteo de
caracteres, scrollea si es largo, y tiene Copiar y Descargar .txt (Blob
+ <a download>, el texto ya está en el navegador).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
131 lines
5.9 KiB
HTML
131 lines
5.9 KiB
HTML
<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="mt-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-slate-500" x-text="testText.length + ' caracteres'"></span>
|
|
<div class="flex gap-2">
|
|
<button type="button" @click="copiar()" class="text-xs text-slate-500 hover:text-slate-700 underline" x-text="copiado?'Copiado':'Copiar'"></button>
|
|
<button type="button" @click="descargarTxt()" class="text-xs text-slate-500 hover:text-slate-700 underline">Descargar .txt</button>
|
|
</div>
|
|
</div>
|
|
<div class="bg-slate-50 border border-slate-200 rounded-lg p-3 text-sm text-slate-700 whitespace-pre-wrap max-h-80 overflow-y-auto" x-text="testText"></div>
|
|
</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:'', copiado:false,
|
|
|
|
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; }
|
|
},
|
|
|
|
copiar(){
|
|
navigator.clipboard.writeText(this.testText);
|
|
this.copiado=true; setTimeout(()=>this.copiado=false, 2000);
|
|
},
|
|
|
|
descargarTxt(){
|
|
const base=(this.archivo?.name||'texto-extraido').replace(/\.[^.]+$/, '');
|
|
const url=URL.createObjectURL(new Blob([this.testText], {type:'text/plain;charset=utf-8'}));
|
|
const a=document.createElement('a');
|
|
a.href=url; a.download=base+'.txt';
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
|
|
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>
|