- 작업실 채팅 renderMd에 링크 지원이 아예 없었다(굵게·표·리스트만 구현) — 모델이 표에 넣어준 출처 링크([바로가기](https://www.printables.com/model/145094))를 클릭해도 아무 일 없었음(2026-09-25 사용자 실측). - [라벨](URL) → <a target=_blank> 변환 추가 + 벗겨진 벌주 URL도 자동 링크. 이스케이프 이후 처리라 URL의 &가 &로 href에 들어가는 것까지 확인. Co-Authored-By: Claude Code <noreply@anthropic.com>
318 lines
19 KiB
JavaScript
318 lines
19 KiB
JavaScript
// 작업실 앱 — 채팅 패널(2026-09-25 옛 단일 workshop-app.js를 기능별로 분리, 내용 변경 없음).
|
||
// 채팅 송수신·렌더, 사진 첨부, 패널 리사이즈, 모델 선택.
|
||
// classic script — 전역 함수/변수를 공유한다. HTML에서 core → parts → tasks → files → chat → init 순서로 로드(원래 한 파일의 순서 그대로).
|
||
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
|
||
// 세션ID를 프로젝트별로 분리한다("ws_" 접두어는 유지 — tool-scope.ts가 이 세션에서는
|
||
// 키워드 없이도 workshop_project 도구를 항상 열어주고, 메인 사이드바 목록에서도 걸러진다).
|
||
// projectId 기반 결정적 파생이므로 앱세션 저장소(app-sessions.json) 없이도 새로고침/
|
||
// 재접속 시 같은 프로젝트의 이전 대화를 그대로 이어받는다. 옛 앱 전용 세션(ws_main/랜덤id)의
|
||
// 대화는 앱 전체 공용이던 터라 프로젝트 귀속이 불가 — 어느 프로젝트에도 더 안 보인다.
|
||
function getSessionId(){
|
||
const proj=currentProject();
|
||
return 'ws_'+(proj?proj.id:'noproject');
|
||
}
|
||
|
||
let _chatLoadSeq=0;
|
||
async function loadHistory(){
|
||
const sid=getSessionId();
|
||
const seq=++_chatLoadSeq;
|
||
try{
|
||
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
||
if(!r.ok)return;
|
||
const d=await r.json();
|
||
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
|
||
if(!msgs.length||seq!==_chatLoadSeq)return;
|
||
for(const m of msgs.slice(-30)){
|
||
if(m.role==='user')addMsg('user',escHtml(m.content||''));
|
||
else addMsg('assistant',renderMd(m.content||''));
|
||
}
|
||
}catch{}
|
||
}
|
||
|
||
function addMsg(role,html){
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const div=document.createElement('div');div.className='rb-msg '+role;
|
||
const b=document.createElement('div');b.className='rb-bubble';b.innerHTML=html;
|
||
if(typeof renderKatexIn==='function')renderKatexIn(b);
|
||
div.appendChild(b);
|
||
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
|
||
return b;
|
||
}
|
||
|
||
function renderMd(text){
|
||
// 코드펜스를 먼저 자리표시자로 빼두고 모든 인라인/단락 처리가 끝난 뒤 복원한다 —
|
||
// 그래야 예전처럼 \n\n→<p> 치환이 <pre> 안까지 파고들어 코드 블록의 빈 줄을
|
||
// </p><p> 로 끊어먹는 사고가 안 난다. 인라인 `코드`/굵게 처리도 pre 안을 건드리지 못한다.
|
||
const blocks=[];
|
||
let s=String(text||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
s=s.replace(/```[\w]*\n?([\s\S]*?)```/g,(m,code)=>{
|
||
blocks.push('<pre><code>'+code.replace(/^\n/,'')+'</code></pre>');
|
||
return '\n\n\u0000B'+(blocks.length-1)+'\u0000\n\n';
|
||
});
|
||
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>');
|
||
// 링크 — [라벨](URL) → <a>. 작업실 채팅엔 링크 지원이 아예 없어 모델이 표에 넣어준
|
||
// 출처 링크(Printables 바로가기 등)가 클릭 불가 평문이었다(2026-09-25 사용자 실측).
|
||
// 이스케이프(47행) 이후에 돌리므로 URL 속 & 는 & — href 속성값으로 그대로 맞다.
|
||
s=s.replace(/\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g,
|
||
'<a href="$2" target="_blank" rel="noopener" style="color:#f59e0b;text-decoration:underline">$1</a>');
|
||
// 남는 벌주 URL도 자동 링크. 이미 만든 <a href="..."> 안은 앞 글자가 "라 안 걸리고,
|
||
// 표/리스트 처리 전이라 셀 안에서도 살아난다.
|
||
s=s.replace(/(^|[\s>])(https?:\/\/[^\s<)"'\u0000]+)/g,(m,pre,url)=>
|
||
pre+'<a href="'+url+'" target="_blank" rel="noopener" style="color:#f59e0b;text-decoration:underline">'+url+'</a>');
|
||
// 인라인 수식 $...$ — KaTeX가 실려 있으면 삽입 직후 renderKatexIn이 진짜 수식으로
|
||
// 그려주므로 그냥 둔다(09-24: 다른 앱들은 전부 KaTeX 실려 있었는데 작업실만 빠져 있었다).
|
||
// KaTeX가 없는 환경의 폴백으로만 이 유니코드 치환을 돌린다. 자주 나오는 LaTeX 기호를
|
||
// 유니코드로 바꾸고 $를 벗긴다. 내용에 백슬래시(LaTeX 명령)가 없으면 건드리지 않는다 —
|
||
// 가격 "$19 ... $29" 짝을 수식으로 먹어버리는 사고 방지([[reference_katex_math_rendering]]).
|
||
if(!(typeof window!=='undefined' && typeof renderKatexIn==='function' && window.katex)){
|
||
s=s.replace(/\$([^$\n]*\\[^$\n]*)\$/g,(m,body)=>{
|
||
let t=body;
|
||
t=t.replace(/\\(?:text|mathrm|mathbf)\s*\{([^{}]*)\}/g,'$1');
|
||
t=t.replace(/\\times/g,'×').replace(/\\approx/g,'≈').replace(/\\sim/g,'~')
|
||
.replace(/\\pm/g,'±').replace(/\\cdot/g,'·').replace(/\\le(?:q)?\b/g,'≤')
|
||
.replace(/\\ge(?:q)?\b/g,'≥').replace(/\\neq?\b/g,'≠')
|
||
.replace(/\\to\b|\\rightarrow\b/g,'→').replace(/\\leftarrow\b/g,'←')
|
||
.replace(/\\deg(ree)?\b/g,'°').replace(/\\mu\b/g,'μ').replace(/\\%/g,'%');
|
||
t=t.replace(/\\[a-zA-Z]+/g,'').replace(/\\([{}])/g,'$1').trim();
|
||
return t;
|
||
});
|
||
}
|
||
// 리스트 — 연속하는 [-*] 줄을 하나의 <ul>로 묶는다(항목별 <ul>이 따로 생기던 버그 수정).
|
||
s=s.replace(/(^|\n)([-*]\s+.+(?:\n[-*]\s+.+)*)/g,(m,lead,grp)=>{
|
||
const items=grp.split('\n').map(line=>'<li>'+line.replace(/^[-*]\s+/,'')+'</li>').join('');
|
||
return lead+'<ul style="padding-left:16px;margin:3px 0">'+items+'</ul>';
|
||
});
|
||
// 표 — 연속하는 파이프 라인(구분행 |---| 포함)을 <table>로. 표 지원이 없어 파이프 문자열이
|
||
// 평문으로 뭉개져 한 줄에 다 붙어 보였다(09-24 실측: 카메라 모듈 스펙 표가 그대로 복사됨).
|
||
// 구분행이 없으면 표가 아니라고 보고 그대로 둔다.
|
||
s=s.replace(/(?:\|.*\n?)+/g, grp=>{
|
||
const lines=grp.replace(/\n$/,'').split('\n').filter(l=>l.trim());
|
||
if(lines.length<2 || !/^\|[\s:|-]+$/.test(lines[1])) return grp;
|
||
const mkRow=(line,tag)=>{
|
||
const cs=line.replace(/^\s*\||\|\s*$/g,'').split('|')
|
||
.map(c=>'<'+tag+' style="border:1px solid var(--rb-border,#d5d0c8);padding:2px 8px;text-align:left;font-weight:'+(tag==='th'?'600':'400')+'">'+c.trim()+'</'+tag+'>').join('');
|
||
return '<tr>'+cs+'</tr>';
|
||
};
|
||
return '<table style="border-collapse:collapse;margin:6px 0;font-size:12px">'
|
||
+mkRow(lines[0],'th')+lines.slice(2).map(l=>mkRow(l,'td')).join('')+'</table>';
|
||
});
|
||
// 단락 — 빈 줄 단위로 split. 이미 블록 태그(<h3>/<ul>/코드 자리표시자)로 시작하면
|
||
// <p>로 감싸지 않는다.
|
||
s=s.split(/\n{2,}/).map(chunk=>{
|
||
const t=chunk.trim();
|
||
if(!t)return '';
|
||
if(/^\u0000B\d+\u0000$/.test(t))return t;
|
||
if(/^<(h3|ul|pre|table)\b/.test(t))return t;
|
||
return '<p>'+t+'</p>';
|
||
}).join('');
|
||
s=s.replace(/\u0000B(\d+)\u0000/g,(m,i)=>blocks[+i]||'');
|
||
return s;
|
||
}
|
||
|
||
function chatGreeting(){
|
||
// 프로젝트 이름은 addMsg→innerHTML로 들어가므로 반드시 이스케이프 — 이름에
|
||
// <script>/태그 문자가 섞이면(예: 채팅으로 지은 프로젝트 이름) 채팅창에서 실행된다.
|
||
return '🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+escHtml(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.';
|
||
}
|
||
|
||
// 프로젝트 전환(수동/생성/삭제) 시 채팅창을 그 프로젝트의 세션 기록으로 리셋한다 —
|
||
// 세션ID가 프로젝트별로 분리돼 있으므로 여기서 지우고 다시 당겨오는 것으로 충분하다.
|
||
let _chatRefreshSeq=0;
|
||
async function refreshChatForProject(){
|
||
const seq=++_chatRefreshSeq;
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
await loadHistory();
|
||
if(seq!==_chatRefreshSeq)return; // 그 사이 다른 프로젝트로 또 전환됨 — 인사말 중복 방지
|
||
addMsg('system',chatGreeting());
|
||
}
|
||
|
||
async function sendMessage(){
|
||
const input=document.getElementById('chat-input');
|
||
const msg=input.value.trim();
|
||
if(!msg||document.getElementById('send-btn').disabled)return;
|
||
input.value='';autoResize(input);
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}
|
||
|
||
function setProjectControlsBusy(busy){
|
||
for(const el of document.querySelectorAll('#project-select,.rb-hdr .hdr-btn:not(#theme-btn)'))el.disabled=busy;
|
||
}
|
||
|
||
async function callChat(message){
|
||
const btn=document.getElementById('send-btn');
|
||
btn.disabled=true;btn.textContent='...';
|
||
// 응답을 받는 동안 프로젝트를 바꾸면 채팅창이 지워져 진행 중이던 답변이 안 보인다 — 잠근다.
|
||
setProjectControlsBusy(true);
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const typingDiv=document.createElement('div');typingDiv.className='rb-msg assistant';
|
||
typingDiv.innerHTML='<div class="rb-typing"><div class="rb-dot"></div><div class="rb-dot"></div><div class="rb-dot"></div></div>';
|
||
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
||
let bubble=null,textBuf='';
|
||
try{
|
||
const body={
|
||
message,sessionId:getSessionId(),useTools:true,
|
||
skillContext:'이 대화는 "작업실"(여러 메이커 프로젝트 관리) 대시보드 페이지의 전용 채팅입니다. 현재 선택된 프로젝트: "'+(currentProject()?.name||'')+'". workshop_project 도구로 이 프로젝트의 부품/작업/메모를 바로 조회·수정하세요(project_name을 생략하면 이 프로젝트가 대상입니다. 다른 프로젝트를 다루려면 project_name을 명시하거나 set_active_project로 먼저 전환하세요).',
|
||
};
|
||
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
||
if(!r.ok)throw new Error('서버 오류 '+r.status);
|
||
typingDiv.remove();
|
||
const msgDiv=document.createElement('div');msgDiv.className='rb-msg assistant';
|
||
bubble=document.createElement('div');bubble.className='rb-bubble';
|
||
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
|
||
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
|
||
while(true){
|
||
const{done,value}=await reader.read();if(done)break;
|
||
buf+=dec.decode(value,{stream:true});
|
||
// 전역 부품 배열 parts를 가리지 않게 SSE 조각은 sseParts로.
|
||
const sseParts=buf.split('\n\n');buf=sseParts.pop()||'';
|
||
for(const part of sseParts){
|
||
if(!part.startsWith('data: '))continue;
|
||
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
|
||
if(ev.type==='token'||ev.type==='text_delta'){
|
||
textBuf+=(ev.text||'');
|
||
if(bubble){bubble.innerHTML=renderMd(textBuf);if(typeof renderKatexIn==='function')renderKatexIn(bubble);msgs.scrollTop=msgs.scrollHeight;}
|
||
}
|
||
}
|
||
}
|
||
}catch(e){
|
||
typingDiv.remove();
|
||
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:#ef4444">오류: '+escHtml(e.message)+'</span>';}
|
||
}finally{btn.disabled=false;btn.textContent='전송';setProjectControlsBusy(false);}
|
||
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
|
||
// 단, 데이터가 안 바뀌었으면(도구가 get 처럼 읽기만 한 경우) 화면을 건드리지 않는다 —
|
||
// 펼쳐둔 부품 카드가 접히거나 편집 중이던 input의 포커스가 날아가는 사고를 막기 위함.
|
||
await softRefreshWorkshop();
|
||
// 09-22: 채팅으로 scad_to_stl 등 파일도구를 불러 새 파일이 생겨도 파일탭이 이미 열려있으면
|
||
// 안 새로고침되던 문제(사용자 신고: "저장되었는데 보이지 않네") — 탭 전환 없이도 갱신.
|
||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||
}
|
||
|
||
async function clearChat(){
|
||
const sid=getSessionId();
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
||
addMsg('system','새 대화가 시작되었습니다.');
|
||
}
|
||
|
||
// ── 사진 첨부(📷) ─────────────────────────────────────────────────────────────
|
||
// 이 프로젝트의 사진을 골라 채팅 입력에 "[첨부 사진: 경로]"를 넣는다. 서버가 이 표시를 보면
|
||
// workshop_project view_image로 그 사진을 모델이 직접 보게 한다(비전 모델일 때).
|
||
async function toggleAttachPop(ev){
|
||
ev.stopPropagation();
|
||
const pop=document.getElementById('attach-pop');
|
||
if(pop.classList.contains('open')){pop.classList.remove('open');return;}
|
||
pop.classList.add('open');
|
||
pop.innerHTML='<div class="rb-empty">불러오는 중…</div>';
|
||
const proj=currentProject();
|
||
if(!proj)return;
|
||
let photos=[];
|
||
try{
|
||
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||
const d=await r.json();
|
||
photos=(d.files||[]).filter(f=>f.category==='photo').slice(-30).reverse();
|
||
}catch{}
|
||
pop.innerHTML='<label class="rb-mini-btn" style="display:block;text-align:center;cursor:pointer">+ 새 사진 올려서 첨부<input type="file" accept="image/*" style="display:none" onchange="attachUpload(event)"></label>'
|
||
+(photos.length
|
||
?'<div class="rb-attach-grid">'+photos.map(f=>'<img src="'+escAttr(f.url)+'" title="'+escAttr(f.relPath)+'" loading="lazy" onclick="attachPhoto(\''+escJs(f.relPath)+'\')">').join('')+'</div>'
|
||
:'<div class="rb-empty">이 프로젝트에 올린 사진이 없습니다.</div>');
|
||
}
|
||
function attachPhoto(relPath){
|
||
const inp=document.getElementById('chat-input');
|
||
inp.value=(inp.value?inp.value.replace(/\s+$/,'')+' ':'')+'[첨부 사진: '+relPath+'] ';
|
||
autoResize(inp);inp.focus();
|
||
document.getElementById('attach-pop').classList.remove('open');
|
||
}
|
||
async function attachUpload(ev){
|
||
const file=ev.target.files&&ev.target.files[0];
|
||
ev.target.value='';
|
||
const proj=currentProject();
|
||
if(!file||!proj)return;
|
||
const pop=document.getElementById('attach-pop');
|
||
pop.innerHTML='<div class="rb-empty">업로드 중… '+escHtml(file.name)+'</div>';
|
||
try{
|
||
const fd=new FormData();fd.append('file',file);
|
||
const r=await fetch('/api/workshop/upload?projectId='+encodeURIComponent(proj.id)+'&folder='+encodeURIComponent('사진'),{method:'POST',headers:authH(),body:fd});
|
||
const d=await r.json().catch(()=>({}));
|
||
if(!r.ok||!d.relPath)throw new Error(d.error||'업로드 실패');
|
||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||
attachPhoto(d.relPath);
|
||
}catch(e){pop.innerHTML='<div class="rb-empty">업로드 실패: '+escHtml(e.message)+'</div>';}
|
||
}
|
||
document.addEventListener('click',(e)=>{
|
||
const w=document.querySelector('.rb-attach-wrap');
|
||
if(w&&!w.contains(e.target))document.getElementById('attach-pop')?.classList.remove('open');
|
||
});
|
||
|
||
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
||
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
|
||
|
||
// ── 채팅 패널 리사이즈 ────────────────────────────────────────────────────────
|
||
(function(){
|
||
const CHAT_W='rb_chat_width';
|
||
const handle=document.getElementById('resize-handle');
|
||
const panel=document.getElementById('chat-panel');
|
||
let dragging=false,startX=0,startW=0;
|
||
panel.style.width=(parseInt(localStorage.getItem(CHAT_W)||'340',10))+'px';
|
||
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
|
||
document.addEventListener('mousemove',e=>{if(!dragging)return;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
|
||
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','모델 변경 실패: '+escHtml(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');
|
||
});
|