Files
homeclaw/web-ui/js/app/workshop-chat.js
T
kimandClaude Code 03a462d72e fix: 작업실 채팅의 링크가 클릭 불가 평문이던 문제
- 작업실 채팅 renderMd에 링크 지원이 아예 없었다(굵게·표·리스트만 구현) — 모델이
  표에 넣어준 출처 링크([바로가기](https://www.printables.com/model/145094))를
  클릭해도 아무 일 없었음(2026-09-25 사용자 실측).
- [라벨](URL) → <a target=_blank> 변환 추가 + 벗겨진 벌주 URL도 자동 링크.
  이스케이프 이후 처리라 URL의 &가 &amp;로 href에 들어가는 것까지 확인.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:36:47 +09:00

318 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 작업실 앱 — 채팅 패널(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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 속 & 는 &amp; — 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');
});