// 작업실 앱 — 채팅 패널(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',userMsgHtml(m.content));
else addMsg('assistant',renderMd(m.content||''));
}
}catch{}
}
// 사용자 메시지 HTML — 카메라 프레임 마크다운()은 썸네일로, 나머지는 이스케이프 텍스트로.
// escHtml은 ! [ ] ( ) / 를 건드리지 않으므로 이스케이프한 뒤에 치환해도 안전하다.
function userMsgHtml(content){
return escHtml(content||'').replace(/!\[📷[^\]]*\]\((\/api\/files\/[^)]+)\)\s*/g,
(m,url)=>'');
}
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→
치환이
안까지 파고들어 코드 블록의 빈 줄을 //로 끊어먹는 사고가 안 난다. 인라인 `코드`/굵게 처리도 pre 안을 건드리지 못한다. const blocks=[]; let s=String(text||'').replace(/&/g,'&').replace(//g,'>'); s=s.replace(/```[\w]*\n?([\s\S]*?)```/g,(m,code)=>{ blocks.push('
'); return '\n\n\u0000B'+(blocks.length-1)+'\u0000\n\n'; }); s=s.replace(/`([^`]+)`/g,''+code.replace(/^\n/,'')+'$1') .replace(/\*\*(.+?)\*\*/g,'$1') .replace(/^#{1,3}\s+(.+)$/gm,'$1
'); // 링크 — [라벨](URL) → . 작업실 채팅엔 링크 지원이 아예 없어 모델이 표에 넣어준 // 출처 링크(Printables 바로가기 등)가 클릭 불가 평문이었다(2026-09-25 사용자 실측). // 이스케이프(47행) 이후에 돌리므로 URL 속 & 는 & — href 속성값으로 그대로 맞다. s=s.replace(/\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g, '$1'); // 남는 벌주 URL도 자동 링크. 이미 만든 안은 앞 글자가 "라 안 걸리고, // 표/리스트 처리 전이라 셀 안에서도 살아난다. s=s.replace(/(^|[\s>])(https?:\/\/[^\s<)"'\u0000]+)/g,(m,pre,url)=> pre+''+url+''); // 인라인 수식 $...$ — 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; }); } // 리스트 — 연속하는 [-*] 줄을 하나의
로 감싸지 않는다. 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 '
'+t+'
'; }).join(''); s=s.replace(/\u0000B(\d+)\u0000/g,(m,i)=>blocks[+i]||''); return s; } function chatGreeting(){ // 프로젝트 이름은 addMsg→innerHTML로 들어가므로 반드시 이스케이프 — 이름에 //