feat: OpenRouter 외부 AI 연동 (STT 교정 + OCR Vision)
This commit is contained in:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user