feat: 작업실 채팅에 경량 모델 pill 추가

메인 채팅의 전체 설정 모달을 그대로 들여오는 대신, 같은 백엔드
엔드포인트(/api/settings/model, /api/settings/provider, /api/models/test)만
재사용해 채팅 헤더에 작은 pill+드롭다운을 추가. 클릭하면 ollama provider가
보유한 로컬+클라우드 모델 태그 목록을 보여주고 선택 시 전역 models.primary를
바꾼다. curl로 GET/POST 전 구간 실측 확인(목록 12개 모델 조회, 전환→원복).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-21 21:50:40 +09:00
co-authored by Claude Sonnet 5
parent 3330910c3b
commit dc76158c6f
+70
View File
@@ -72,6 +72,16 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.rb-send:hover:not(:disabled){background:#9a5c03;}
.rb-send:disabled{opacity:.5;cursor:not-allowed;}
.rb-model-pill{position:relative;flex-shrink:0;}
.rb-model-pill-btn{background:none;border:1px solid var(--line);border-radius:6px;padding:2px 8px;font-size:10px;font-weight:700;color:var(--muted);cursor:pointer;font-family:inherit;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:.15s;}
.rb-model-pill-btn:hover{border-color:#f59e0b;color:#f59e0b;}
.rb-model-dropdown{display:none;position:absolute;top:100%;right:0;margin-top:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.35);min-width:200px;max-height:280px;overflow-y:auto;z-index:50;padding:4px;}
.rb-model-dropdown.open{display:block;}
.rb-model-opt{padding:6px 10px;font-size:12px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);}
.rb-model-opt:hover{background:var(--panel);}
.rb-model-opt.current{color:#f59e0b;font-weight:700;}
.rb-model-dropdown-msg{padding:6px 10px;font-size:11px;color:var(--muted);}
@media (max-width:900px){
.rb-layout{flex-direction:column;}
.rb-resize{display:none;}
@@ -224,6 +234,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="rb-chat-hdr">
<span class="rb-chat-label" id="chat-label">🛠️ 작업실 채팅</span>
<span class="rb-hdr-sp"></span>
<div class="rb-model-pill">
<button class="rb-model-pill-btn" id="model-pill-btn" onclick="toggleModelDropdown(event)" title="모델 변경">-</button>
<div class="rb-model-dropdown" id="model-dropdown"></div>
</div>
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
</div>
<div class="rb-msgs" id="chat-msgs"></div>
@@ -847,12 +861,68 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
})();
// ── 모델 pill ─────────────────────────────────────────────────────────────────
// 경량 버전: 메인 채팅의 설정 모달(settings-provider.js)을 통째로 들여오지 않고, 같은 백엔드
// 엔드포인트(/api/settings/model, /api/settings/provider, /api/models/test)만 재사용해서
// 전역 models.primary를 바꾼다. ollama provider(로컬+클라우드 모델 태그) 안에서만 전환됨 —
// provider 자체(openai/anthropic/google 등)를 바꾸는 건 메인 설정에서 해야 한다.
let _modelDropdownLoaded=false;
async function loadModelPill(){
try{
const r=await fetch('/api/settings/model',{headers:authH()});
if(!r.ok)return;
const d=await r.json();
const btn=document.getElementById('model-pill-btn');
if(btn && d.primary) btn.textContent=d.primary;
}catch{}
}
async function toggleModelDropdown(ev){
if(ev) ev.stopPropagation();
const dd=document.getElementById('model-dropdown');
if(dd.classList.contains('open')){dd.classList.remove('open');return;}
dd.classList.add('open');
if(_modelDropdownLoaded)return;
dd.innerHTML='<div class="rb-model-dropdown-msg">모델 불러오는 중…</div>';
try{
const pr=await fetch('/api/settings/provider',{headers:authH()});
const pd=await pr.json();
const tr=await fetch('/api/models/test',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({llm:pd.llm})});
const td=await tr.json();
const models=(td.models||[]).map(m=>typeof m==='string'?m:(m.name||String(m)));
if(!models.length){dd.innerHTML='<div class="rb-model-dropdown-msg">모델 목록을 가져오지 못했습니다.</div>';return;}
const current=(document.getElementById('model-pill-btn').textContent||'').trim();
dd.innerHTML=models.map(m=>`<div class="rb-model-opt${m===current?' current':''}" data-model="${escHtml(m)}">${escHtml(m)}</div>`).join('');
dd.querySelectorAll('.rb-model-opt').forEach(el=>el.addEventListener('click',()=>selectModel(el.dataset.model)));
_modelDropdownLoaded=true;
}catch(e){dd.innerHTML='<div class="rb-model-dropdown-msg">오류: '+escHtml(e.message)+'</div>';}
}
async function selectModel(name){
document.getElementById('model-dropdown').classList.remove('open');
const btn=document.getElementById('model-pill-btn');
const prev=btn.textContent;
if(name===prev)return;
btn.textContent=name;
try{
const r=await fetch('/api/settings/model',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({primary:name})});
if(!r.ok)throw new Error('저장 실패');
_modelDropdownLoaded=false;
}catch(e){
btn.textContent=prev;
addMsg('system','모델 변경 실패: '+e.message);
}
}
document.addEventListener('click',(e)=>{
const pill=document.querySelector('.rb-model-pill');
if(pill && !pill.contains(e.target)) document.getElementById('model-dropdown')?.classList.remove('open');
});
// ── Init ──────────────────────────────────────────────────────────────────────
(async function init(){
if(!(await checkAuth()))return;
await loadWorkshopData();
await initSessionId();
await loadHistory();
loadModelPill();
addMsg('system','🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.');
document.getElementById('chat-input').focus();
})();