feat: OpenRouter 외부 AI 연동 (STT 교정 + OCR Vision)

This commit is contained in:
root
2026-04-28 15:38:06 +09:00
parent f9075ae3f6
commit f35fe1143a
5 changed files with 667 additions and 299 deletions

View File

@@ -251,6 +251,17 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
.ollama-status{font-family:var(--mono);font-size:.63rem;padding:4px 9px;border-radius:2px}
.ollama-status.ok{background:rgba(0,229,160,.1);color:var(--accent);border:1px solid rgba(0,229,160,.2)}
.ollama-status.fail{background:rgba(255,107,53,.1);color:var(--warn);border:1px solid rgba(255,107,53,.2)}
.openrouter-status.ok{background:rgba(77,166,255,.1);color:var(--blue);border:1px solid rgba(77,166,255,.2)}
.openrouter-status.fail{background:rgba(255,107,53,.1);color:var(--warn);border:1px solid rgba(255,107,53,.2)}
.or-section{margin-top:10px;padding:12px;background:var(--surf2);border:1px solid #1c2840;border-radius:4px}
.key-input-wrap{display:flex;gap:6px;margin-top:6px}
.key-input-wrap input{flex:1;background:var(--surf);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.78rem;outline:none;-webkit-appearance:none}
.key-input-wrap input:focus{border-color:var(--blue)}
.btn-test{padding:9px 14px;background:none;border:1px solid #3a7cc4;color:var(--blue);border-radius:3px;font-family:var(--mono);font-size:.68rem;cursor:pointer;white-space:nowrap;transition:all .15s}
.btn-test:hover{background:rgba(77,166,255,.08)}
.or-model-tabs{display:flex;gap:5px;margin-top:8px;flex-wrap:wrap}
.or-model-tab{font-family:var(--mono);font-size:.6rem;padding:4px 10px;border:1px solid var(--border2);background:none;color:var(--muted);border-radius:2px;cursor:pointer;transition:all .12s;text-transform:uppercase}
.or-model-tab.active{border-color:var(--blue);color:var(--blue);background:rgba(77,166,255,.07)}
/* ── ADMIN ── */
#page-admin{display:none;flex-direction:column}
@@ -372,11 +383,17 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="engine-btns">
<button class="engine-btn active" data-engine="whisper"><span class="e-icon"></span><span class="e-name">faster-whisper</span><span class="e-desc">로컬 CPU 변환<br>빠르고 안정적</span></button>
<button class="engine-btn" data-engine="whisper+ollama"><span class="e-icon">🦙</span><span class="e-name">+ Ollama 교정</span><span class="e-desc">Whisper 후<br>Ollama 교정</span></button>
<button class="engine-btn" data-engine="whisper+openrouter" style="grid-column:1/-1"><span class="e-icon">🌐</span><span class="e-name">+ OpenRouter 교정</span><span class="e-desc">외부 AI 모델로 문장 부호·맞춤법 교정 (텍스트 전용 모델도 사용 가능)</span></button>
</div>
<div class="ollama-opts" id="stt-ollama-opts">
<div class="sec-label" style="margin-top:0">후처리 모델</div>
<select class="model-select" id="stt-ollama-model"><option value="">설정 기본 모델 사용</option></select>
</div>
<div class="ollama-opts" id="stt-or-opts">
<div class="sec-label" style="margin-top:0">OpenRouter 후처리 모델</div>
<select class="model-select" id="stt-or-model"><option value="">설정 기본 모델 사용</option></select>
<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:5px">⚙️ 설정 → OpenRouter에서 API 키 및 기본 모델을 설정하세요</div>
</div>
<button class="btn-start green" id="stt-btn" disabled>변환 시작</button>
<div class="prog-box" id="stt-prog">
<div class="prog-header"><span class="prog-msg" id="stt-pmsg">처리 중...</span><span class="prog-pct" id="stt-ppct">0%</span></div>
@@ -428,6 +445,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="engine-btns">
<button class="engine-btn active" data-engine="paddle"><span class="e-icon">🐾</span><span class="e-name">PaddleOCR</span><span class="e-desc">로컬 실행<br>표 구조 분석</span></button>
<button class="engine-btn" data-engine="ollama"><span class="e-icon">🦙</span><span class="e-name">Ollama Vision</span><span class="e-desc">자연어 지시<br>커스텀 프롬프트</span></button>
<button class="engine-btn" data-engine="openrouter" style="grid-column:1/-1"><span class="e-icon">🌐</span><span class="e-name">OpenRouter Vision</span><span class="e-desc">Claude / GPT-4o / Gemini 등 외부 Vision 모델 사용</span></button>
</div>
<div class="ollama-opts" id="ocr-ollama-opts">
<div class="sec-label" style="margin-top:0">Vision 모델</div>
@@ -435,6 +453,13 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<span class="cprompt-toggle" id="cprompt-toggle">▶ 커스텀 프롬프트</span>
<textarea class="cprompt" id="custom-prompt" placeholder="예: 이 영수증의 품목과 금액을 JSON으로 추출해줘"></textarea>
</div>
<div class="ollama-opts" id="ocr-or-opts">
<div class="sec-label" style="margin-top:0">OpenRouter Vision 모델</div>
<select class="model-select" id="ocr-or-model"><option value="">설정 기본 모델 사용</option></select>
<span class="cprompt-toggle" id="cprompt-toggle-or">▶ 커스텀 프롬프트</span>
<textarea class="cprompt" id="custom-prompt-or" placeholder="예: 이 영수증의 품목과 금액을 JSON으로 추출해줘"></textarea>
<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:5px">⚠️ Vision 기능을 지원하는 모델만 이미지 처리 가능 (Claude-3, GPT-4o, Gemini 등)</div>
</div>
<div class="sec-label">인식 모드</div>
<div class="mode-btns">
<button class="mode-btn active" data-mode="text">📄 텍스트 추출</button>
@@ -560,6 +585,40 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<select class="settings-select" id="setting-ocr-model" style="margin-top:8px"><option value="">(없음)</option></select>
</div>
<!-- OpenRouter -->
<div class="settings-section">
<h3>🌐 OpenRouter 외부 AI 연동</h3>
<label class="settings-label">API 키<small>openrouter.ai에서 발급 — 저장 후 "연결 테스트"로 확인</small></label>
<div class="key-input-wrap">
<input type="password" id="or-api-key" placeholder="sk-or-v1-..." autocomplete="off">
<button class="btn-test" id="btn-or-test">연결 테스트</button>
</div>
<div id="or-test-result" style="font-family:var(--mono);font-size:.68rem;margin-top:6px;display:none"></div>
<label class="settings-label" style="margin-top:12px">API URL<small>기본값 사용 권장</small></label>
<input type="text" id="or-url" value="https://openrouter.ai/api/v1"
style="width:100%;background:var(--surf2);border:1px solid var(--border2);color:var(--text);padding:9px 10px;border-radius:3px;font-family:var(--mono);font-size:.75rem;outline:none;margin-top:6px">
<div id="or-models-wrap" style="display:none;margin-top:14px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
<span id="or-connected-badge" class="openrouter-status ok"></span>
<div class="or-model-tabs">
<button class="or-model-tab active" data-filter="vision">Vision 모델</button>
<button class="or-model-tab" data-filter="text">텍스트 모델</button>
<button class="or-model-tab" data-filter="all">전체</button>
</div>
</div>
<label class="settings-label">STT 교정 기본 모델<small>텍스트 전용 모델도 사용 가능</small></label>
<select class="settings-select" id="setting-or-stt-model" style="margin-top:6px">
<option value="">(없음)</option>
</select>
<label class="settings-label" style="margin-top:10px">OCR 기본 Vision 모델<small>반드시 Vision 지원 모델 선택</small></label>
<select class="settings-select" id="setting-or-ocr-model" style="margin-top:6px">
<option value="">(없음)</option>
</select>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;align-items:center">
<div id="settings-msg" style="font-family:var(--mono);font-size:.68rem;color:var(--accent);display:none">✓ 저장됨 (CPU·타임아웃: worker 재시작 후 반영)</div>
<button class="btn-settings blue" id="btn-save-settings">저장</button>
@@ -643,6 +702,7 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
// ══ STATE ══
let token=null,currentUser=null,ollamaModels=[],appSettings={};
let sttFile=null,sttOutputFile=null,sttEngine='whisper';
let orModels=[],orVisionModels=[],orTextModels=[];
let ocrFile=null,ocrOutputTxt=null,ocrOutputXlsx=null,ocrEngine='paddle',ocrMode='text';
let editTarget=null,sysTimer=null;
let histPage=1,histType='',histTotal=0;
@@ -721,6 +781,8 @@ function populateModelSelects(){
fill(document.getElementById('ocr-ollama-model'),appSettings.ocr_ollama_model,'설정 기본 모델 사용');
fill(document.getElementById('setting-stt-model'),appSettings.stt_ollama_model,'(없음)');
fill(document.getElementById('setting-ocr-model'),appSettings.ocr_ollama_model,'(없음)');
// OpenRouter 드롭다운
populateOrSelects();
}
// ══ 설정 ══
@@ -729,7 +791,11 @@ async function loadSettings(){
const th=appSettings.cpu_threads||0;cpuSlider.value=th;cpuDisplay.textContent=th===0?'0 (자동)':th+' 스레드';
document.getElementById('stt-timeout').value=appSettings.stt_timeout||0;
document.getElementById('ollama-timeout').value=appSettings.ollama_timeout||600;
populateModelSelects()}catch{}
if(appSettings.openrouter_url)document.getElementById('or-url').value=appSettings.openrouter_url;
if(appSettings.openrouter_api_key_masked)document.getElementById('or-api-key').placeholder='저장된 키: '+appSettings.openrouter_api_key_masked;
populateModelSelects();
// 기존 OR 모델 로드
if(appSettings.openrouter_api_key_masked)loadOrModels();}catch{}
}
document.getElementById('btn-save-settings').addEventListener('click',async()=>{
const fd=new FormData();
@@ -738,6 +804,10 @@ document.getElementById('btn-save-settings').addEventListener('click',async()=>{
fd.append('cpu_threads',cpuSlider.value);
fd.append('stt_timeout',document.getElementById('stt-timeout').value||'0');
fd.append('ollama_timeout',document.getElementById('ollama-timeout').value||'600');
fd.append('openrouter_url',document.getElementById('or-url').value||'https://openrouter.ai/api/v1');
const orKey=document.getElementById('or-api-key').value.trim();if(orKey)fd.append('openrouter_api_key',orKey);
fd.append('openrouter_stt_model',document.getElementById('setting-or-stt-model').value);
fd.append('openrouter_ocr_model',document.getElementById('setting-or-ocr-model').value);
try{const r=await api('POST','/api/settings',fd);if(r.ok){appSettings=(await r.json()).settings;const msg=document.getElementById('settings-msg');msg.style.display='block';setTimeout(()=>msg.style.display='none',3500)}}catch{}
});
document.getElementById('btn-refresh-models').addEventListener('click',loadOllamaModels);
@@ -762,10 +832,11 @@ sttDrop.addEventListener('dragover',e=>{e.preventDefault();sttDrop.classList.add
sttDrop.addEventListener('dragleave',()=>sttDrop.classList.remove('dragover'));
sttDrop.addEventListener('drop',e=>{e.preventDefault();sttDrop.classList.remove('dragover');setSttFile(e.dataTransfer.files[0])});
function setSttFile(f){if(!f)return;sttFile=f;showFileInfo('stt',f);document.getElementById('stt-btn').disabled=false;document.getElementById('stt-err').style.display='none'}
document.querySelectorAll('#page-stt .engine-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('#page-stt .engine-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');sttEngine=btn.dataset.engine;document.getElementById('stt-ollama-opts').classList.toggle('visible',sttEngine==='whisper+ollama');document.getElementById('stt-btn').className='btn-start '+(sttEngine==='whisper+ollama'?'purple':'green')})});
document.querySelectorAll('#page-stt .engine-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('#page-stt .engine-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');sttEngine=btn.dataset.engine;document.getElementById('stt-ollama-opts').classList.toggle('visible',sttEngine==='whisper+ollama');document.getElementById('stt-or-opts').classList.toggle('visible',sttEngine==='whisper+openrouter');const isOr=sttEngine==='whisper+openrouter',isOllama=sttEngine==='whisper+ollama';document.getElementById('stt-btn').className='btn-start '+(isOr||isOllama?'purple':'green')})});
document.getElementById('stt-btn').addEventListener('click',async()=>{
if(!sttFile)return;document.getElementById('stt-err').style.display='none';setSttLoading(true);
const fd=new FormData();fd.append('file',sttFile);fd.append('use_ollama',sttEngine==='whisper+ollama'?'true':'false');fd.append('ollama_model',document.getElementById('stt-ollama-model').value||'');
fd.append('use_openrouter',sttEngine==='whisper+openrouter'?'true':'false');fd.append('openrouter_model',document.getElementById('stt-or-model').value||'');
try{const r=await api('POST','/api/transcribe',fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');pollTask(d.task_id,dt=>setProg('stt',dt.progress||0,dt.message||'처리 중...'),showSttResult,e=>{showErr('stt-err',e);setSttLoading(false)})}
catch(e){showErr('stt-err',e.message);setSttLoading(false)}
});
@@ -793,12 +864,13 @@ ocrDrop.addEventListener('dragover',e=>{e.preventDefault();ocrDrop.classList.add
ocrDrop.addEventListener('dragleave',()=>ocrDrop.classList.remove('dragover'));
ocrDrop.addEventListener('drop',e=>{e.preventDefault();ocrDrop.classList.remove('dragover');setOcrFile(e.dataTransfer.files[0])});
function setOcrFile(f){if(!f)return;ocrFile=f;showFileInfo('ocr',f);document.getElementById('ocr-btn').disabled=false;document.getElementById('ocr-err').style.display='none';const p=document.getElementById('ocr-preview'),w=document.getElementById('ocr-preview-wrap');p.src=URL.createObjectURL(f);w.style.display='block'}
document.querySelectorAll('#page-ocr .engine-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('#page-ocr .engine-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');ocrEngine=btn.dataset.engine;document.getElementById('ocr-ollama-opts').classList.toggle('visible',ocrEngine==='ollama');document.getElementById('ocr-btn').className='btn-start '+(ocrEngine==='ollama'?'purple':'green')})});
document.querySelectorAll('#page-ocr .engine-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('#page-ocr .engine-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');ocrEngine=btn.dataset.engine;document.getElementById('ocr-ollama-opts').classList.toggle('visible',ocrEngine==='ollama');document.getElementById('ocr-or-opts').classList.toggle('visible',ocrEngine==='openrouter');const isOr=ocrEngine==='openrouter',isOllama=ocrEngine==='ollama';document.getElementById('ocr-btn').className='btn-start '+(isOr||isOllama?'purple':'green')})});
document.getElementById('cprompt-toggle').addEventListener('click',()=>{const ta=document.getElementById('custom-prompt');const open=ta.style.display!=='block';ta.style.display=open?'block':'none';document.getElementById('cprompt-toggle').textContent=(open?'▼':'▶')+' 커스텀 프롬프트'});
document.getElementById('cprompt-toggle-or').addEventListener('click',()=>{const ta=document.getElementById('custom-prompt-or');const open=ta.style.display!=='block';ta.style.display=open?'block':'none';document.getElementById('cprompt-toggle-or').textContent=(open?'▼':'▶')+' 커스텀 프롬프트'});
document.querySelectorAll('.mode-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');ocrMode=btn.dataset.mode;document.getElementById('mode-desc').textContent=ocrMode==='structure'?'표 구조를 감지하고 Excel로 저장합니다':'일반 텍스트와 글자를 인식합니다'})});
document.getElementById('ocr-btn').addEventListener('click',async()=>{
if(!ocrFile)return;document.getElementById('ocr-err').style.display='none';setOcrLoading(true);
const fd=new FormData();fd.append('file',ocrFile);fd.append('mode',ocrMode);fd.append('backend',ocrEngine);fd.append('ollama_model',document.getElementById('ocr-ollama-model').value||'');fd.append('custom_prompt',document.getElementById('custom-prompt').value||'');
const fd=new FormData();fd.append('file',ocrFile);fd.append('mode',ocrMode);fd.append('ollama_model',document.getElementById('ocr-ollama-model').value||'');fd.append('custom_prompt',document.getElementById('custom-prompt').value||'');
try{const r=await api('POST','/api/ocr',fd);const d=await r.json();if(!r.ok)throw new Error(d.detail||'업로드 실패');pollTask(d.task_id,dt=>setProg('ocr',dt.progress||0,dt.message||'처리 중...'),showOcrResult,e=>{showErr('ocr-err',e);setOcrLoading(false)})}
catch(e){showErr('ocr-err',e.message);setOcrLoading(false)}
});
@@ -1067,6 +1139,72 @@ function fmtTime(s){const m=Math.floor(s/60),ss=Math.floor(s%60);return String(m
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
async function copyText(text,btn){try{await navigator.clipboard.writeText(text);const o=btn.textContent;btn.textContent='복사됨 ✓';setTimeout(()=>btn.textContent=o,1500)}catch{}}
// ══ OPENROUTER ══
async function loadOrModels(){
try{
const r=await api('GET','/api/openrouter/models');const d=await r.json();
const wrap=document.getElementById('or-models-wrap');
if(d.connected){
orModels=d.models||[];orVisionModels=d.vision_models||[];orTextModels=d.text_models||[];
wrap.style.display='block';
document.getElementById('or-connected-badge').textContent=`✓ 연결됨 — Vision ${orVisionModels.length}개 / 전체 ${orModels.length}`;
populateOrSelects('vision');
} else {
wrap.style.display='none';
}
}catch{}
}
let orFilter='vision';
document.querySelectorAll('.or-model-tab').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('.or-model-tab').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');orFilter=btn.dataset.filter;populateOrSelects(orFilter);
});
});
function populateOrSelects(filter){
filter=filter||orFilter;
const list = filter==='vision'?orVisionModels:filter==='text'?orTextModels:orModels;
const fillOr=(sel,def)=>{
const cur=sel.value||def||'';
sel.innerHTML='<option value="">(없음)</option>';
list.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;sel.appendChild(o)});
};
const sttSel=document.getElementById('setting-or-stt-model');
const ocrSel=document.getElementById('setting-or-ocr-model');
const sttPage=document.getElementById('stt-or-model');
const ocrPage=document.getElementById('ocr-or-model');
if(sttSel)fillOr(sttSel,appSettings.openrouter_stt_model);
if(ocrSel){
// OCR은 Vision만
const vlist=filter==='text'?[]:orVisionModels;
const cur=ocrSel.value||appSettings.openrouter_ocr_model||'';
ocrSel.innerHTML='<option value="">(없음)</option>';
vlist.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrSel.appendChild(o)});
}
if(sttPage)fillOr(sttPage,appSettings.openrouter_stt_model);
if(ocrPage){
const cur=ocrPage.value||appSettings.openrouter_ocr_model||'';
ocrPage.innerHTML='<option value="">설정 기본 모델 사용</option>';
orVisionModels.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===cur)o.selected=true;ocrPage.appendChild(o)});
}
}
document.getElementById('btn-or-test').addEventListener('click',async()=>{
const key=document.getElementById('or-api-key').value.trim();
const url=document.getElementById('or-url').value.trim()||'https://openrouter.ai/api/v1';
const result=document.getElementById('or-test-result');
if(!key){result.style.display='block';result.style.color='var(--warn)';result.textContent='API 키를 입력하세요';return}
result.style.display='block';result.style.color='var(--muted)';result.textContent='연결 중...';
try{
const fd=new FormData();fd.append('api_key',key);fd.append('base_url',url);
const r=await api('POST','/api/openrouter/test',fd);const d=await r.json();
result.style.color=d.ok?'var(--accent)':'var(--warn)';result.textContent=d.message;
if(d.ok)loadOrModels();
}catch{result.style.color='var(--warn)';result.textContent='요청 실패'}
});
checkAuth();
</script>
</body>