웹 채팅: 에이전트 선택 버튼·화면 고정·상태 보기·코디 세션 열기
- 하단 탭 → 입력창 왼쪽 에이전트 선택 버튼(목록 팝업, 안 읽은 답 빨간 점) - 모바일 약 80% 크기(대화 본문 13.5px), iOS 입력 확대 방지 - 상단·하단 프레임 고정(페이지 스크롤 차단, 키보드 시 visualViewport 맞춤) - ⋯ 메뉴에 상태 보기(/status) - 🛠 코디: remote-control 세션 기동/재사용 후 Claude 앱 링크 제공 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+123
-15
@@ -45,6 +45,7 @@ DEFAULT_PORT = 18793
|
||||
|
||||
AGENTS = [('main', '클로', '🦞'), ('stock', '레이', ''), ('budget', '골디', '📒')]
|
||||
AGENT_IDS = {a[0] for a in AGENTS}
|
||||
SESSION_TOOL = ROOT / 'workspace' / 'skills' / 'claude-code-session' / 'scripts' / 'session_tool.py'
|
||||
|
||||
SESSION_TTL = 30 * 86400
|
||||
OTP_TTL = 300
|
||||
@@ -210,6 +211,59 @@ def _send_otp(code: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------- 코디 (Claude Code remote-control 세션) ----------------
|
||||
# 코디는 이 웹의 대화 상대가 아니다 — 세션 데몬을 띄우고(이미 떠 있으면 재사용) 접속 주소를 돌려줄 뿐이다.
|
||||
# 실제 대화는 Claude 앱/claude.ai 에서 하므로 Anthropic 로그인이 그대로 보호막이 된다.
|
||||
_cody_lock = threading.Lock()
|
||||
|
||||
|
||||
def _session_tool():
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('session_tool', SESSION_TOOL)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _cody_url(name: str) -> str | None:
|
||||
"""세션 로그의 마지막 화면에서 주소를 찾는다. 연결된 세션이 있으면 그 세션, 없으면 환경 진입 주소."""
|
||||
import re
|
||||
p = ROOT / 'logs' / f'claude-session-{name}.log'
|
||||
if not p.exists():
|
||||
return None
|
||||
txt = p.read_bytes()[-200_000:].decode('utf-8', 'ignore')
|
||||
last = txt[txt.rfind('Capacity:'):] if 'Capacity:' in txt else txt # 로그는 화면 다시그리기의 연속 — 마지막 프레임만 본다
|
||||
m = re.findall(r'https://claude\.ai/code/session_[A-Za-z0-9]+', last)
|
||||
if m:
|
||||
return m[-1]
|
||||
m = re.findall(r'https://claude\.ai/code\?environment=env_[A-Za-z0-9]+', txt)
|
||||
return m[-1] if m else None
|
||||
|
||||
|
||||
def _open_cody() -> dict:
|
||||
with _cody_lock:
|
||||
st = _session_tool()
|
||||
running = [x for x in st._list_active_sessions() if x['running'] and Path(x['workdir']) == ROOT]
|
||||
if running:
|
||||
name, reused = running[-1]['session'], True
|
||||
else:
|
||||
r = subprocess.run([sys.executable, str(SESSION_TOOL), 'session', 'open', 'openclaw'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
import re
|
||||
m = re.search(r'세션 시작됨: (\S+)', r.stdout)
|
||||
if not m:
|
||||
log(f'cody open 실패 rc={r.returncode} {r.stderr[-300:]!r}')
|
||||
return {'error': '세션을 띄우지 못했습니다'}
|
||||
name, reused = m.group(1), False
|
||||
log(f'cody session opened {name}')
|
||||
for _ in range(25): # 새로 띄운 세션은 연결까지 몇 초 걸린다
|
||||
url = _cody_url(name)
|
||||
if url:
|
||||
return {'session': name, 'url': url, 'reused': reused}
|
||||
time.sleep(1)
|
||||
return {'error': f'세션({name})은 떴지만 접속 주소를 아직 못 찾았습니다', 'session': name}
|
||||
|
||||
|
||||
# ---------------- 대화 기록·에이전트 호출 ----------------
|
||||
|
||||
def _session_key(agent: str) -> str:
|
||||
@@ -331,7 +385,7 @@ button{width:100%;padding:11px;border:0;border-radius:10px;background:var(--acce
|
||||
|
||||
def _page(title: str, body: str, css: str = LOGIN_CSS, extra_head: str = '') -> bytes:
|
||||
return (f'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
|
||||
f'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
||||
f'<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,viewport-fit=cover">'
|
||||
f'<title>{html.escape(title)}</title>'
|
||||
# 홈 화면 아이콘 — 없으면 iOS 가 제목 첫 글자('채')로 아이콘을 만든다
|
||||
f'<link rel="apple-touch-icon" href="/apple-touch-icon.png">'
|
||||
@@ -358,17 +412,31 @@ def _otp_page(err: str = '') -> bytes:
|
||||
|
||||
|
||||
APP_CSS = CSS + """
|
||||
body{display:flex;flex-direction:column;height:100dvh}
|
||||
/* 페이지 자체는 움직이지 않고 대화 영역(main)만 스크롤 — iOS 바운스·키보드가 상단/하단 프레임을 끌고 가지 않게 */
|
||||
html,body{overflow:hidden;overscroll-behavior:none}
|
||||
body{position:fixed;top:0;left:0;right:0;display:flex;flex-direction:column;height:100dvh}
|
||||
header{display:flex;align-items:center;gap:6px;padding:8px 10px;padding-top:max(8px,env(safe-area-inset-top));background:var(--panel);border-bottom:1px solid var(--line)}
|
||||
.tabs{display:flex;gap:6px;flex:1;min-width:0}
|
||||
.tab{position:relative;border:0;background:var(--chip);border-radius:18px;padding:6px 12px;white-space:nowrap;cursor:pointer}
|
||||
.tab.on{background:var(--accent);color:#fff}
|
||||
.tab .dot{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--err);display:none}
|
||||
.tab.unread .dot{display:block}
|
||||
.title{flex:1;min-width:0;font-weight:600;font-size:16px;padding-left:4px}
|
||||
.agent-pick{position:relative;flex:none}
|
||||
.agent-btn{position:relative;width:40px;height:40px;border-radius:50%;border:1px solid var(--line);background:var(--chip);font-size:20px;line-height:1;padding:0;cursor:pointer}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--err);display:none}
|
||||
.agent-btn .dot{position:absolute;top:0;right:0}.agent-btn.unread .dot{display:block}
|
||||
.agent-list{position:absolute;left:0;bottom:calc(100% + 8px);min-width:170px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 6px 20px rgba(0,0,0,.18);padding:4px 0;display:none;z-index:6}
|
||||
.agent-list.open{display:block}
|
||||
.agent-item{display:flex;align-items:center;gap:8px;width:100%;border:0;background:none;padding:10px 14px;text-align:left;cursor:pointer}
|
||||
.agent-item.on{color:var(--accent);font-weight:600}.agent-item .st{margin-left:auto;font-size:12px;color:var(--muted)}
|
||||
.agent-item.unread .dot{display:inline-block}
|
||||
.agent-item.cody{border-top:1px solid var(--line)}
|
||||
.cody-box{position:fixed;inset:0;background:rgba(0,0,0,.35);display:none;align-items:center;justify-content:center;z-index:9}
|
||||
.cody-box.open{display:flex}
|
||||
.cody-card{background:var(--panel);border-radius:14px;padding:18px;width:min(300px,86vw);text-align:center}
|
||||
.cody-card .msg2{margin:8px 0 14px;color:var(--muted);font-size:13px}
|
||||
.cody-card a{display:block;background:var(--accent);color:#fff;text-decoration:none;border-radius:10px;padding:10px;font-weight:600}
|
||||
.cody-card button{margin-top:10px;border:0;background:none;color:var(--muted);cursor:pointer}
|
||||
.menu-btn{border:0;background:none;font-size:20px;padding:4px 8px;cursor:pointer}
|
||||
.menu{position:absolute;right:10px;top:48px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.15);display:none;z-index:5}
|
||||
.menu.open{display:block}.menu button{display:block;width:100%;text-align:left;border:0;background:none;padding:10px 16px;cursor:pointer}
|
||||
main{flex:1;overflow-y:auto;padding:12px 10px}
|
||||
main{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;padding:12px 10px}
|
||||
.msg{max-width:min(82%,640px);margin:6px 0;padding:8px 11px;border-radius:14px;word-wrap:break-word;overflow-wrap:anywhere}
|
||||
.msg.user{margin-left:auto;background:var(--me);border-bottom-right-radius:4px}
|
||||
.msg.agent{background:var(--them);border:1px solid var(--line);border-bottom-left-radius:4px}
|
||||
@@ -387,18 +455,33 @@ textarea{flex:1;min-width:0;resize:none;max-height:40vh;font-size:16px;padding:9
|
||||
.choices{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 8px}
|
||||
.choice{border:1px solid var(--accent);background:var(--panel);color:var(--accent);border-radius:16px;padding:6px 14px;font-size:14px;cursor:pointer}
|
||||
.choice:disabled{opacity:.4}
|
||||
/* 모바일은 약 80% 크기 (관리자님 요청). 입력칸이 16px 미만이면 iOS 가 포커스 때 확대하므로 viewport maximum-scale=1 로 막는다 */
|
||||
@media (max-width:600px){
|
||||
body{font-size:12px}
|
||||
header{padding:6px 8px;padding-top:max(6px,env(safe-area-inset-top))}.title{font-size:13px}.menu-btn{font-size:16px}.menu{top:38px}
|
||||
main{padding:10px 8px;font-size:13.5px}.msg{padding:6px 9px;border-radius:11px;margin:5px 0}.msg .t{font-size:9px}.sys{font-size:10px}
|
||||
.msg pre,.msg code{font-size:12px}.msg.error,.typing{font-size:11px}.empty{font-size:12px}
|
||||
footer{gap:6px;padding:6px 8px;padding-bottom:max(6px,env(safe-area-inset-bottom))}
|
||||
textarea{font-size:13px;padding:7px 10px;border-radius:16px}
|
||||
#send,.agent-btn{width:32px;height:32px}#send{font-size:14px}.agent-btn{font-size:16px}
|
||||
.agent-item{padding:8px 12px}.agent-item .st{font-size:10px}
|
||||
.choice{font-size:12px;padding:5px 11px}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _app_page() -> bytes:
|
||||
agents = json.dumps([{'id': a, 'name': n, 'emoji': e} for a, n, e in AGENTS], ensure_ascii=False)
|
||||
body = f'''<header><div class="tabs" id="tabs"></div>
|
||||
body = f'''<header><div class="title" id="title"></div>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="메뉴">⋯</button>
|
||||
<div class="menu" id="menu"><button id="resetBtn">새 대화 시작</button>
|
||||
<div class="menu" id="menu"><button id="statusBtn">상태 보기</button><button id="resetBtn">새 대화 시작</button>
|
||||
<form method="post" action="/logout" style="margin:0"><button>로그아웃</button></form></div></header>
|
||||
<main id="log"></main>
|
||||
<div class="cody-box" id="codyBox"><div class="cody-card"><b>🛠 코디</b><div class="msg2" id="codyMsg"></div>
|
||||
<a id="codyLink" href="#" target="_blank" rel="noopener">Claude 앱에서 열기</a><button id="codyClose">닫기</button></div></div>
|
||||
<div class="typing" id="typing"></div>
|
||||
<footer><textarea id="input" rows="1" placeholder="메시지"></textarea>
|
||||
<footer><div class="agent-pick"><button class="agent-btn" id="agentBtn" aria-label="에이전트 선택"><span id="agentEmoji"></span><span class="dot"></span></button>
|
||||
<div class="agent-list" id="agentList"></div></div><textarea id="input" rows="1" placeholder="메시지"></textarea>
|
||||
<button id="send" aria-label="전송">➤</button></footer>
|
||||
<script>window.AGENTS={agents};</script><script src="/app.js"></script>'''
|
||||
return _page('채팅', body, APP_CSS)
|
||||
@@ -406,6 +489,12 @@ def _app_page() -> bytes:
|
||||
|
||||
APP_JS = r"""
|
||||
(function(){
|
||||
// 키보드가 올라오면 iOS 는 보이는 영역(visualViewport)만 줄이고 페이지를 밀어 올린다 → 몸통을 보이는 영역에 맞춘다
|
||||
const vv=window.visualViewport;
|
||||
if(vv){const fit=()=>{const m=document.getElementById('log'),stick=m&&m.scrollHeight-m.clientHeight-m.scrollTop<40;
|
||||
document.body.style.height=vv.height+'px';document.body.style.top=vv.offsetTop+'px';
|
||||
if(stick)m.scrollTop=m.scrollHeight}; // 맨 아래를 보던 중이면 키보드가 올라와도 마지막 메시지를 가리지 않게
|
||||
vv.addEventListener('resize',fit);vv.addEventListener('scroll',fit);fit()}
|
||||
const A=window.AGENTS, $=s=>document.querySelector(s);
|
||||
const log=$('#log'), input=$('#input'), send=$('#send'), typing=$('#typing');
|
||||
let cur=localStorage.getItem('cw_agent')||'main', status={}, lastSeen={}, timer=null;
|
||||
@@ -430,8 +519,12 @@ function choices(t){const g=BTN.exec(t);if(g)return [...new Set(g[1].split('|').
|
||||
while((m=re.exec(t))){const c=m[1].trim();if(c&&!out.includes(c))out.push(c)}return out.slice(0,6)}
|
||||
function hm(ts){return ts?ts.slice(11,16):''}
|
||||
function renderTabs(){
|
||||
$('#tabs').innerHTML=A.map(a=>{const st=status[a.id]||{};const un=a.id!==cur&&st.last&&st.last!==lastSeen[a.id];
|
||||
return '<button class="tab'+(a.id===cur?' on':'')+(un?' unread':'')+'" data-id="'+a.id+'">'+a.emoji+' '+a.name+(st.pending?' …':'')+'<span class="dot"></span></button>'}).join('');
|
||||
const ca=A.find(x=>x.id===cur);$('#title').textContent=ca.emoji+' '+ca.name;
|
||||
$('#agentEmoji').textContent=ca.emoji;let anyUn=false;
|
||||
$('#agentList').innerHTML=A.map(a=>{const st=status[a.id]||{};const un=a.id!==cur&&st.last&&st.last!==lastSeen[a.id];if(un)anyUn=true;
|
||||
return '<button class="agent-item'+(a.id===cur?' on':'')+(un?' unread':'')+'" data-id="'+a.id+'">'+a.emoji+' '+a.name+'<span class="dot"></span><span class="st">'+(st.pending?'입력 중…':'')+'</span></button>'}).join('')
|
||||
+'<button class="agent-item cody" data-cody="1">🛠 코디<span class="st">Claude 앱</span></button>';
|
||||
$('#agentBtn').classList.toggle('unread',anyUn);
|
||||
}
|
||||
function renderLog(items){
|
||||
const a=A.find(x=>x.id===cur);
|
||||
@@ -459,13 +552,26 @@ function doSend(choice){
|
||||
.catch(()=>{send.disabled=false});
|
||||
}
|
||||
function autosize(){input.style.height='auto';input.style.height=Math.min(input.scrollHeight,window.innerHeight*0.4)+'px'}
|
||||
$('#tabs').addEventListener('click',e=>{const b=e.target.closest('.tab');if(!b)return;cur=b.dataset.id;localStorage.setItem('cw_agent',cur);load()});
|
||||
$('#agentBtn').addEventListener('click',e=>{e.stopPropagation();$('#menu').classList.remove('open');$('#agentList').classList.toggle('open')});
|
||||
$('#agentList').addEventListener('click',e=>{const b=e.target.closest('.agent-item');if(!b)return;e.stopPropagation();$('#agentList').classList.remove('open');
|
||||
if(b.dataset.cody){openCody();return}
|
||||
if(b.dataset.id!==cur){cur=b.dataset.id;localStorage.setItem('cw_agent',cur);load()}});
|
||||
send.addEventListener('click',()=>doSend());
|
||||
log.addEventListener('click',e=>{const b=e.target.closest('.choice');if(b&&!b.disabled)doSend(b.dataset.choice)});
|
||||
input.addEventListener('input',autosize);
|
||||
input.addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.metaKey||e.ctrlKey)){e.preventDefault();doSend()}});
|
||||
$('#menuBtn').addEventListener('click',e=>{e.stopPropagation();$('#menu').classList.toggle('open')});
|
||||
document.addEventListener('click',()=>$('#menu').classList.remove('open'));
|
||||
document.addEventListener('click',()=>{$('#menu').classList.remove('open');$('#agentList').classList.remove('open')});
|
||||
$('#statusBtn').addEventListener('click',()=>doSend('/status'));
|
||||
// 코디: 세션을 띄우고(떠 있으면 재사용) 주소를 받아 버튼으로 준다 — iOS 는 사용자가 직접 누른 링크여야 앱으로 넘긴다
|
||||
function openCody(){const box=$('#codyBox'),msg=$('#codyMsg'),a=$('#codyLink');
|
||||
msg.textContent='세션 준비 중… (처음 띄우면 10초쯤 걸립니다)';a.style.display='none';box.classList.add('open');
|
||||
api('/api/cody',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(d=>{
|
||||
if(d.error){msg.textContent=d.error;return}
|
||||
msg.textContent=(d.reused?'떠 있는 세션':'새 세션')+' '+d.session+(d.url.includes('/session_')?' — 진행 중인 대화로 엽니다':' — 이 환경에서 새 대화를 시작합니다');
|
||||
a.href=d.url;a.style.display='block'}).catch(()=>{msg.textContent='요청 실패'})}
|
||||
$('#codyClose').addEventListener('click',()=>$('#codyBox').classList.remove('open'));
|
||||
$('#codyLink').addEventListener('click',()=>setTimeout(()=>$('#codyBox').classList.remove('open'),300));
|
||||
$('#resetBtn').addEventListener('click',()=>{const a=A.find(x=>x.id===cur);
|
||||
if(!confirm(a.name+'와의 대화 맥락을 새로 시작할까요? (기록은 화면에 남습니다)'))return;
|
||||
api('/api/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:cur})}).then(d=>{if(d.error)alert(d.error);load()})});
|
||||
@@ -629,6 +735,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
data = json.loads(self._body() or b'{}')
|
||||
except Exception:
|
||||
return self._json({'error': 'bad json'}, 400)
|
||||
if path == '/api/cody':
|
||||
return self._json(_open_cody())
|
||||
ag = data.get('agent')
|
||||
if ag not in AGENT_IDS:
|
||||
return self._json({'error': 'agent'}, 400)
|
||||
|
||||
Reference in New Issue
Block a user