feat: robot-app에 내장 채팅 패널 추가 (doctor/lawyer 앱과 같은 사이드 패널 패턴)

부품 리스트를 보면서 바로 옆에서 로봇 프로젝트 얘기를 할 수 있게 리사이즈
가능한 채팅 패널을 대시보드에 붙임. 세션ID를 "rb_"로 시작하게 해서
tool-scope.ts가 이 세션 안에서는 키워드 매칭 없이 robot_project 도구를
항상 열어주도록 예외 처리(doctor_app의 'dr_'/lawyer_app의 'lw_'와 동일한
패턴) — "이 부품 지워줘"처럼 짧게 말해도 바로 동작한다.

채팅 응답이 끝나면 대시보드(부품표/예산/작업)를 자동으로 다시 불러와서
채팅으로 바꾼 내용이 즉시 화면에 반영되게 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 14:48:50 +09:00
co-authored by Claude Sonnet 5
parent e3d5da38cf
commit 54285c8a4c
2 changed files with 196 additions and 1 deletions
+4 -1
View File
@@ -305,6 +305,9 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
const isDetectiveAppSession = /^dt_/.test(String(sessionId || ''));
const isInvestorAppSession = /^iv_/.test(String(sessionId || ''));
const isDoctorAppSession = /^dr_/.test(String(sessionId || ''));
// robot-app.html의 전용 채팅 패널('rb_' 세션) 안에서는 그 대화 전체가 이미 로봇 프로젝트
// 얘기라는 게 확실하므로, 키워드 없이 짧게 말해도("이 부품 지워줘") robot_project가 열리게 함.
const isRobotAppSession = /^rb_/.test(String(sessionId || ''));
const filter = (name: string): boolean => {
if (dormantAgentTools.has(name)) return false;
if (dormantBrowserTools.has(name)) return false;
@@ -372,7 +375,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if ((name === 'eonet_events' || name === 'satellite_snapshot') && !hasSatelliteKeyword) return false;
if (name === 'nvr_snapshot' && !hasCctvKeyword) return false;
if (name === 'print3d_model' && !hasPrint3dKeyword) return false;
if (name === 'robot_project' && !hasRobotProjectKeyword) return false;
if (name === 'robot_project' && !isRobotAppSession && !hasRobotProjectKeyword) return false;
if (name === 'run_command' && !hasRunCommandKeyword) return false;
return true;
};
+192
View File
@@ -24,6 +24,9 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.rb-budget-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.rb-budget-val{font-size:15px;font-weight:700;margin-top:2px;}
.rb-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
.rb-main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden;}
.rb-tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;padding:0 14px;}
.rb-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:8px 14px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;margin-bottom:-1px;}
.rb-tab-btn.active{color:#f59e0b;border-bottom-color:#f59e0b;}
@@ -32,6 +35,47 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.rb-pane{display:none;}
.rb-pane.active{display:block;}
/* ── 채팅 패널 ── */
.rb-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
.rb-resize:hover,.rb-resize.dragging{background:#f59e0b;}
.rb-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
.rb-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
.rb-chat-label{font-size:10px;font-weight:700;color:#f59e0b;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
.rb-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
.rb-chat-clear:hover{color:#ef4444;}
.rb-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.rb-msgs::-webkit-scrollbar{width:3px;}
.rb-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.rb-msg{display:flex;flex-direction:column;}
.rb-msg.user{align-items:flex-end;}
.rb-msg.assistant{align-items:flex-start;}
.rb-msg.system{align-items:center;}
.rb-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
.rb-msg.user .rb-bubble{background:#3d2c05;color:#fde9b8;border-radius:10px 10px 3px 10px;}
.rb-msg.assistant .rb-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
.rb-msg.system .rb-bubble{background:transparent;color:var(--muted);font-size:11px;}
.rb-bubble p{margin:3px 0;}
.rb-bubble h3{font-size:13px;margin:6px 0 3px;}
.rb-bubble ul,.rb-bubble ol{padding-left:16px;margin:3px 0;}
.rb-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
.rb-bubble strong{font-weight:700;}
.rb-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
.rb-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:rbDot 1.2s ease-in-out infinite;}
.rb-dot:nth-child(2){animation-delay:.2s;}.rb-dot:nth-child(3){animation-delay:.4s;}
@keyframes rbDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
.rb-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
.rb-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
.rb-textarea:focus{border-color:#f59e0b;}
.rb-send{background:#7c4a03;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.rb-send:hover:not(:disabled){background:#9a5c03;}
.rb-send:disabled{opacity:.5;cursor:not-allowed;}
@media (max-width:900px){
.rb-layout{flex-direction:column;}
.rb-resize{display:none;}
.rb-chat{width:auto;height:38vh;border-left:none;border-top:1px solid var(--line);}
}
/* ── 부품 표 ── */
.rb-parts-table{width:100%;border-collapse:collapse;font-size:12px;}
.rb-parts-table th{text-align:left;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:4px 6px;border-bottom:1px solid var(--line);}
@@ -94,6 +138,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="rb-budget-item"><div class="rb-budget-label">총 예상비용</div><div class="rb-budget-val" id="sum-total">0원</div></div>
</div>
<div class="rb-layout">
<div class="rb-main">
<div class="rb-tabs">
<button class="rb-tab-btn active" data-tab="parts" onclick="switchTab('parts')">부품</button>
<button class="rb-tab-btn" data-tab="tasks" onclick="switchTab('tasks')">작업</button>
@@ -145,6 +191,24 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
</div>
</div>
</div>
<div class="rb-resize" id="resize-handle"></div>
<div class="rb-chat" id="chat-panel">
<div class="rb-chat-hdr">
<span class="rb-chat-label">🤖 로봇 프로젝트 채팅</span>
<span class="rb-hdr-sp"></span>
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
</div>
<div class="rb-msgs" id="chat-msgs"></div>
<div class="rb-input-bar">
<textarea class="rb-textarea" id="chat-input" rows="1" placeholder="부품/작업/예산에 대해 물어보세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="rb-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
</div>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
@@ -474,10 +538,138 @@ function onNotesChange(){
scheduleSave();
}
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
// 세션ID를 "rb_"로 시작하게 만들어서 tool-scope.ts가 이 세션에서는 키워드 없이도
// robot_project 도구를 항상 열어주게 한다(doctor/lawyer 앱과 같은 패턴).
const APP_KEY='rb';
let _sessionId=null;
function getSessionId(){return _sessionId||'rb_main';}
async function initSessionId(){
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
_sessionId='rb_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
}
async function loadHistory(){
const sid=getSessionId();
if(!sid||sid.endsWith('_main'))return;
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)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 escHtml(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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;
div.appendChild(b);
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
}
function renderMd(text){
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
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);
}
async function callChat(message){
const btn=document.getElementById('send-btn');
btn.disabled=true;btn.textContent='...';
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:'이 대화는 "로봇 프로젝트"(메카넘휠 이동로봇) 관리 대시보드 페이지의 전용 채팅입니다. robot_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});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
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);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='전송';}
// 채팅으로 데이터가 바뀌었을 수 있으니(robot_project 도구 호출) 대시보드를 새로고침한다.
await loadProject();
}
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','새 대화가 시작되었습니다.');
}
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{};});
})();
// ── Init ──────────────────────────────────────────────────────────────────────
(async function init(){
if(!(await checkAuth()))return;
await loadProject();
await initSessionId();
await loadHistory();
addMsg('system','🤖 로봇 프로젝트 채팅입니다. 부품 구매/상태 변경, 작업 완료 체크, 예산 확인 등을 물어보세요.');
document.getElementById('chat-input').focus();
})();
</script>
</body>