114a1ef816
- 하단 탭 → 입력창 왼쪽 에이전트 선택 버튼(목록 팝업, 안 읽은 답 빨간 점) - 모바일 약 80% 크기(대화 본문 13.5px), iOS 입력 확대 방지 - 상단·하단 프레임 고정(페이지 스크롤 차단, 키보드 시 visualViewport 맞춤) - ⋯ 메뉴에 상태 보기(/status) - 🛠 코디: remote-control 세션 기동/재사용 후 Claude 앱 링크 제공 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
782 lines
39 KiB
Python
782 lines
39 KiB
Python
#!/usr/bin/env python3
|
||
"""클로·레이·골디 웹 채팅 (chat.hyowons.net).
|
||
|
||
텔레그램처럼 세 에이전트와 대화하는 자체 웹. Tailscale 없이 NAS 리버스 프록시로 공개되므로
|
||
로그인은 본인 iMessage OTP(iOS 자동입력) → 30일 세션 쿠키. 비밀번호는 없다(관리자님 결정) — 대신 누구나
|
||
'코드 받기'를 누를 수 있으므로 발송 횟수를 제한한다(1분 1회, 1시간 5회).
|
||
|
||
에이전트 호출은 `openclaw agent --agent <id> --session-key web… --json` (게이트웨이 경유, 게이트웨이 자체는
|
||
외부에 열지 않는다). 세션 키가 텔레그램과 달라 웹 대화는 웹 전용 맥락이다. 응답은 텔레그램으로 나가지 않는다
|
||
(`--deliver` 미지정).
|
||
|
||
CLI:
|
||
python3 chat_web.py serve [--port 18793]
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import html
|
||
import json
|
||
import os
|
||
import secrets
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from zoneinfo import ZoneInfo
|
||
|
||
KST = ZoneInfo('Asia/Seoul')
|
||
ROOT = Path.home() / '.openclaw'
|
||
STATE = ROOT / 'workspace' / 'state' / 'chat_web'
|
||
HIST_DIR = STATE / 'history'
|
||
AUTH_FILE = STATE / 'auth.json'
|
||
KEYS_FILE = STATE / 'session_keys.json'
|
||
IMSG_CRED = ROOT / 'credentials' / 'admin_imessage.json'
|
||
ICON_FILE = Path(__file__).with_name('chat_web_icon.png')
|
||
OPENCLAW = str(Path.home() / '.local' / 'bin' / 'openclaw')
|
||
|
||
DOMAIN = 'chat.hyowons.net'
|
||
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
|
||
OTP_MAX_TRIES = 5
|
||
FAIL_WINDOW = 900 # 15분 안에
|
||
FAIL_LIMIT = 5 # 5번 틀리면
|
||
LOCK_SEC = 900 # 15분 잠금
|
||
SEND_GAP = 60 # 코드 발송 최소 간격
|
||
SEND_HOURLY = 5 # 1시간 발송 상한 (넘으면 잠금 — 폰으로 코드 폭탄 방지)
|
||
MAX_MSG = 8000
|
||
RUN_TIMEOUT = 600
|
||
HISTORY_LIMIT = 300
|
||
# 웹 메시지에만 덧붙이는 형식 안내 — 에이전트가 선택지를 지정하면 웹이 버튼으로 그린다.
|
||
# AGENTS.md 에 넣지 않는 이유: 텔레그램엔 버튼이 없어 마커가 날것으로 보인다. 화면 기록엔 저장하지 않는다.
|
||
WEB_HINT = ('\n\n(웹 채팅 안내: 관리자님이 골라서 답할 질문이면 답변 맨 마지막 줄에 '
|
||
'[[버튼: 선택1 | 선택2]] 형식으로 선택지를 적어주세요. 고를 것이 없으면 쓰지 마세요.)')
|
||
|
||
_auth_lock = threading.Lock()
|
||
_otp: dict[str, dict] = {} # pre 토큰 → {otp, exp, tries} (메모리 — 재시작 시 재로그인)
|
||
_pending: dict[str, float] = {} # agent → 시작 시각
|
||
_run_locks = {a: threading.Lock() for a in AGENT_IDS}
|
||
_hist_lock = threading.Lock()
|
||
|
||
|
||
def now_kst() -> datetime:
|
||
return datetime.now(KST)
|
||
|
||
|
||
def log(msg: str) -> None:
|
||
print(f'[{now_kst():%Y-%m-%d %H:%M:%S}] {msg}', flush=True)
|
||
|
||
|
||
def _read_json(p: Path, default):
|
||
try:
|
||
return json.loads(p.read_text(encoding='utf-8'))
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _write_json(p: Path, data) -> None:
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = p.with_suffix(p.suffix + '.tmp')
|
||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding='utf-8')
|
||
os.replace(tmp, p)
|
||
|
||
|
||
# ---------------- 인증 상태 ----------------
|
||
|
||
def _sha(s: str) -> str:
|
||
return hashlib.sha256(s.encode()).hexdigest()
|
||
|
||
|
||
def _load_auth() -> dict:
|
||
a = _read_json(AUTH_FILE, {})
|
||
a.setdefault('sessions', {})
|
||
a.setdefault('fails', [])
|
||
a.setdefault('locked_until', 0)
|
||
a.setdefault('sends', [])
|
||
return a
|
||
|
||
|
||
def _take_send_slot() -> str | None:
|
||
"""코드 발송 가능하면 기록하고 None, 아니면 사유. 상한 초과는 잠금으로 이어진다."""
|
||
t = time.time()
|
||
alert = False
|
||
with _auth_lock:
|
||
a = _load_auth()
|
||
a['sends'] = [s for s in a['sends'] if t - s < 3600]
|
||
if a['sends'] and t - a['sends'][-1] < SEND_GAP:
|
||
return f'방금 코드를 보냈습니다. {int(SEND_GAP - (t - a["sends"][-1])) + 1}초 뒤 다시 받을 수 있습니다.'
|
||
if len(a['sends']) >= SEND_HOURLY:
|
||
a['locked_until'] = t + LOCK_SEC
|
||
a['sends'] = []
|
||
_write_json(AUTH_FILE, a)
|
||
alert = True
|
||
else:
|
||
a['sends'].append(t)
|
||
_write_json(AUTH_FILE, a)
|
||
if alert:
|
||
log('LOCKED — 코드 요청 과다')
|
||
_alert(f'🔒 chat.hyowons.net 인증 코드 요청이 1시간에 {SEND_HOURLY}회를 넘어 {LOCK_SEC // 60}분 잠갔습니다.')
|
||
return f'요청이 너무 많아 {LOCK_SEC // 60}분 잠갔습니다.'
|
||
return None
|
||
|
||
|
||
def _locked() -> float:
|
||
"""잠겨 있으면 남은 초, 아니면 0."""
|
||
with _auth_lock:
|
||
left = _load_auth()['locked_until'] - time.time()
|
||
return max(0.0, left)
|
||
|
||
|
||
def _record_fail(ip: str, why: str) -> None:
|
||
t = time.time()
|
||
alert = False
|
||
with _auth_lock:
|
||
a = _load_auth()
|
||
a['fails'] = [f for f in a['fails'] if t - f < FAIL_WINDOW] + [t]
|
||
if len(a['fails']) >= FAIL_LIMIT and a['locked_until'] < t:
|
||
a['locked_until'] = t + LOCK_SEC
|
||
a['fails'] = []
|
||
alert = True
|
||
_write_json(AUTH_FILE, a)
|
||
log(f'login fail ({why}) ip={ip}')
|
||
if alert:
|
||
log(f'LOCKED {LOCK_SEC}s')
|
||
_alert(f'🔒 chat.hyowons.net 로그인 {FAIL_LIMIT}회 실패로 {LOCK_SEC // 60}분 잠갔습니다.\n'
|
||
f'마지막 시도 IP: {ip} ({why})')
|
||
|
||
|
||
def _alert(text: str) -> None:
|
||
try:
|
||
sys.path.insert(0, str(ROOT / 'workspace' / 'scripts'))
|
||
from codex_fallback_monitor import _send_telegram
|
||
_send_telegram(text)
|
||
except Exception as e:
|
||
log(f'alert 실패: {e}')
|
||
|
||
|
||
def _new_session(ip: str, ua: str) -> str:
|
||
tok = secrets.token_urlsafe(32)
|
||
t = time.time()
|
||
with _auth_lock:
|
||
a = _load_auth()
|
||
a['sessions'] = {k: v for k, v in a['sessions'].items() if v.get('exp', 0) > t}
|
||
a['sessions'][_sha(tok)] = {'exp': t + SESSION_TTL, 'created': now_kst().isoformat(timespec='seconds'),
|
||
'ip': ip, 'ua': ua[:120]}
|
||
a['fails'] = []
|
||
_write_json(AUTH_FILE, a)
|
||
return tok
|
||
|
||
|
||
def _valid_session(tok: str | None) -> bool:
|
||
if not tok:
|
||
return False
|
||
with _auth_lock:
|
||
s = _load_auth()['sessions'].get(_sha(tok))
|
||
return bool(s) and s.get('exp', 0) > time.time()
|
||
|
||
|
||
def _drop_session(tok: str | None) -> None:
|
||
if not tok:
|
||
return
|
||
with _auth_lock:
|
||
a = _load_auth()
|
||
a['sessions'].pop(_sha(tok), None)
|
||
_write_json(AUTH_FILE, a)
|
||
|
||
|
||
def _send_otp(code: str) -> bool:
|
||
handle = (_read_json(IMSG_CRED, {}) or {}).get('handle')
|
||
if not handle:
|
||
log('admin_imessage.json 없음 — OTP 발송 불가')
|
||
return False
|
||
text = f'[{DOMAIN}] 채팅 로그인 인증\n코드: {code}\n\n@{DOMAIN} #{code}'
|
||
try:
|
||
# 나→나 iMessage 는 항상 imessage 서비스 강제(SMS 셀프발송은 자동입력이 깨진다)
|
||
subprocess.Popen(['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'],
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||
return True
|
||
except Exception as e:
|
||
log(f'imsg 발송 실패: {e}')
|
||
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:
|
||
return (_read_json(KEYS_FILE, {}) or {}).get(agent, 'web')
|
||
|
||
|
||
def _rotate_session(agent: str) -> str:
|
||
keys = _read_json(KEYS_FILE, {}) or {}
|
||
key = f'web-{now_kst():%Y%m%d%H%M%S}'
|
||
keys[agent] = key
|
||
_write_json(KEYS_FILE, keys)
|
||
_append(agent, 'system', '새 대화를 시작했습니다')
|
||
return key
|
||
|
||
|
||
def _clear(agent: str) -> None:
|
||
"""/clear — 화면 기록을 archive/ 로 옮기고(삭제 X) 새 세션으로 시작."""
|
||
p = HIST_DIR / f'{agent}.jsonl'
|
||
if p.exists():
|
||
arc = HIST_DIR / 'archive'
|
||
arc.mkdir(parents=True, exist_ok=True)
|
||
with _hist_lock:
|
||
os.replace(p, arc / f'{agent}-{now_kst():%Y%m%d%H%M%S}.jsonl')
|
||
_rotate_session(agent)
|
||
|
||
|
||
def _append(agent: str, role: str, text: str) -> dict:
|
||
item = {'id': f'{time.time_ns()}', 'role': role, 'text': text,
|
||
'ts': now_kst().isoformat(timespec='seconds')}
|
||
with _hist_lock:
|
||
HIST_DIR.mkdir(parents=True, exist_ok=True)
|
||
with open(HIST_DIR / f'{agent}.jsonl', 'a', encoding='utf-8') as f:
|
||
f.write(json.dumps(item, ensure_ascii=False) + '\n')
|
||
return item
|
||
|
||
|
||
def _history(agent: str) -> list[dict]:
|
||
p = HIST_DIR / f'{agent}.jsonl'
|
||
if not p.exists():
|
||
return []
|
||
with _hist_lock:
|
||
lines = p.read_text(encoding='utf-8').splitlines()[-HISTORY_LIMIT:]
|
||
out = []
|
||
for ln in lines:
|
||
try:
|
||
out.append(json.loads(ln))
|
||
except Exception:
|
||
pass
|
||
return out
|
||
|
||
|
||
def _parse_reply(stdout: str) -> tuple[str, str | None]:
|
||
"""(본문, 오류). JSON 앞에 다른 출력이 섞여도 첫 '{' 부터 읽는다."""
|
||
i = stdout.find('{')
|
||
if i < 0:
|
||
return '', '응답을 해석할 수 없습니다'
|
||
try:
|
||
d = json.loads(stdout[i:])
|
||
except Exception:
|
||
return '', '응답을 해석할 수 없습니다'
|
||
if d.get('status') != 'ok':
|
||
return '', f"실행 실패: {d.get('summary') or d.get('status')}"
|
||
parts = []
|
||
for p in (d.get('result') or {}).get('payloads') or []:
|
||
if p.get('text'):
|
||
parts.append(p['text'])
|
||
if p.get('mediaUrl'):
|
||
parts.append(p['mediaUrl'])
|
||
return '\n\n'.join(parts) or '(빈 응답)', None
|
||
|
||
|
||
def _run_agent(agent: str, text: str) -> None:
|
||
with _run_locks[agent]:
|
||
_pending[agent] = time.time()
|
||
msg_file = STATE / 'tmp' / f'{agent}-{time.time_ns()}.txt'
|
||
try:
|
||
msg_file.parent.mkdir(parents=True, exist_ok=True)
|
||
msg_file.write_text(text if text.startswith('/') else text + WEB_HINT, encoding='utf-8') # 슬래시 명령은 본체가 처리하므로 그대로
|
||
r = subprocess.run(
|
||
[OPENCLAW, 'agent', '--agent', agent, '--session-key', _session_key(agent),
|
||
'--message-file', str(msg_file), '--json', '--timeout', str(RUN_TIMEOUT)],
|
||
capture_output=True, text=True, timeout=RUN_TIMEOUT + 60, cwd=str(ROOT))
|
||
body, err = _parse_reply(r.stdout)
|
||
if err:
|
||
log(f'{agent} 실패 rc={r.returncode} err={r.stderr[-500:]!r}')
|
||
_append(agent, 'error', err)
|
||
else:
|
||
_append(agent, 'agent', body)
|
||
except subprocess.TimeoutExpired:
|
||
_append(agent, 'error', f'{RUN_TIMEOUT // 60}분 안에 응답이 없어 중단했습니다')
|
||
except Exception as e:
|
||
log(f'{agent} 예외: {e}')
|
||
_append(agent, 'error', f'호출 오류: {e}')
|
||
finally:
|
||
msg_file.unlink(missing_ok=True)
|
||
_pending.pop(agent, None)
|
||
|
||
|
||
# ---------------- HTML ----------------
|
||
|
||
CSS = """
|
||
:root{--bg:#f4f5f7;--panel:#fff;--fg:#1c1e21;--muted:#7a7f87;--line:#e3e5e8;--me:#d9f3c9;--them:#fff;
|
||
--accent:#2a7ae2;--err:#c62828;--chip:#eef1f5}
|
||
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--panel:#181b21;--fg:#e6e8eb;--muted:#8a9099;
|
||
--line:#2a2e36;--me:#2b4a2a;--them:#22262e;--accent:#5b9cf0;--err:#ef6b6b;--chip:#232831}}
|
||
*{box-sizing:border-box}html,body{margin:0;height:100%}
|
||
body{background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",sans-serif}
|
||
button,input,textarea{font:inherit;color:inherit}
|
||
"""
|
||
|
||
LOGIN_CSS = CSS + """
|
||
.box{max-width:340px;margin:12vh auto 0;padding:24px 20px;background:var(--panel);border:1px solid var(--line);border-radius:14px}
|
||
h1{font-size:18px;margin:0 0 4px}p{color:var(--muted);font-size:13px;margin:0 0 16px}
|
||
input{width:100%;font-size:17px;padding:11px 12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);margin-bottom:12px}
|
||
button{width:100%;padding:11px;border:0;border-radius:10px;background:var(--accent);color:#fff;font-weight:600}
|
||
.err{color:var(--err);font-size:13px;margin:-4px 0 12px}
|
||
"""
|
||
|
||
|
||
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,maximum-scale=1,viewport-fit=cover">'
|
||
f'<title>{html.escape(title)}</title>'
|
||
# 홈 화면 아이콘 — 없으면 iOS 가 제목 첫 글자('채')로 아이콘을 만든다
|
||
f'<link rel="apple-touch-icon" href="/apple-touch-icon.png">'
|
||
f'<meta name="apple-mobile-web-app-capable" content="yes">'
|
||
f'<meta name="apple-mobile-web-app-title" content="채팅">'
|
||
f'<style>{css}</style>{extra_head}</head>'
|
||
f'<body>{body}</body></html>').encode()
|
||
|
||
|
||
def _login_page(err: str = '') -> bytes:
|
||
e = f'<div class="err">{html.escape(err)}</div>' if err else ''
|
||
return _page('채팅 로그인', f'''<form class="box" method="post" action="/login/request">
|
||
<h1>💬 채팅 로그인</h1><p>클로 · 레이 · 골디 — 관리자님 iMessage로 인증 코드를 보냅니다</p>{e}
|
||
<button>인증 코드 받기</button></form>''')
|
||
|
||
|
||
def _otp_page(err: str = '') -> bytes:
|
||
e = f'<div class="err">{html.escape(err)}</div>' if err else ''
|
||
return _page('인증 코드', f'''<form class="box" method="post" action="/login/otp">
|
||
<h1>📱 인증 코드</h1><p>iMessage로 보낸 6자리 코드를 입력하세요 (5분 유효)</p>{e}
|
||
<input name="otp" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus required>
|
||
<button>로그인</button></form>
|
||
<form method="post" action="/login/request" style="text-align:center;margin-top:14px"><button style="width:auto;background:none;color:var(--muted);font-size:13px">코드 다시 받기</button></form>''')
|
||
|
||
|
||
APP_CSS = CSS + """
|
||
/* 페이지 자체는 움직이지 않고 대화 영역(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)}
|
||
.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;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}
|
||
.msg.error{background:none;border:1px dashed var(--err);color:var(--err);font-size:13px}
|
||
.msg .t{display:block;font-size:11px;color:var(--muted);margin-top:2px;text-align:right}
|
||
.sys{text-align:center;color:var(--muted);font-size:12px;margin:14px 0}
|
||
.msg pre{background:var(--chip);padding:8px;border-radius:8px;overflow-x:auto;font-size:13px;margin:6px 0}
|
||
.msg code{background:var(--chip);padding:1px 4px;border-radius:4px;font-size:13px}.msg pre code{background:none;padding:0}
|
||
.msg a{color:var(--accent)}
|
||
.typing{color:var(--muted);font-size:13px;margin:8px 4px;display:none}.typing.on{display:block}
|
||
footer{display:flex;gap:8px;align-items:flex-end;padding:8px 10px;padding-bottom:max(8px,env(safe-area-inset-bottom));background:var(--panel);border-top:1px solid var(--line)}
|
||
textarea{flex:1;min-width:0;resize:none;max-height:40vh;font-size:16px;padding:9px 12px;border:1px solid var(--line);border-radius:20px;background:var(--bg)}
|
||
#send{border:0;background:var(--accent);color:#fff;border-radius:50%;width:40px;height:40px;flex:none;font-size:18px;cursor:pointer}
|
||
#send:disabled{opacity:.4}
|
||
.empty{color:var(--muted);text-align:center;margin-top:30vh;font-size:14px}
|
||
.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="title" id="title"></div>
|
||
<button class="menu-btn" id="menuBtn" aria-label="메뉴">⋯</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><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)
|
||
|
||
|
||
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;
|
||
try{lastSeen=JSON.parse(localStorage.getItem('cw_seen')||'{}')}catch(e){}
|
||
if(!A.some(a=>a.id===cur))cur='main';
|
||
|
||
function api(path,opt){opt=opt||{};opt.headers=Object.assign({'X-CW':'1'},opt.headers||{});
|
||
return fetch(path,opt).then(r=>{if(r.status===401){location.href='/';throw new Error('auth')}return r.json()})}
|
||
function esc(s){return s.replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
|
||
function md(s){
|
||
const blocks=[];s=esc(s).replace(/\n?```[\w-]*\n?([\s\S]*?)\n?```\n?/g,(m,c)=>{blocks.push(c);return '\u0000'+(blocks.length-1)+'\u0000'});
|
||
s=s.replace(/`([^`\n]+)`/g,'<code>$1</code>').replace(/\*\*([^*\n]+)\*\*/g,'<b>$1</b>')
|
||
.replace(/(https?:\/\/[^\s<]+)/g,'<a href="$1" target="_blank" rel="noopener">$1</a>').replace(/\n/g,'<br>');
|
||
return s.replace(/\u0000(\d+)\u0000/g,(m,i)=>'<pre><code>'+blocks[i]+'</code></pre>');
|
||
}
|
||
// 답변 속 "수정해줘"라고 / '네'라고 같은 '따옴표 + (이)라고' 표현을 선택지 버튼으로 뽑는다
|
||
const BTN=/\[\[\s*버튼\s*:\s*([^\]\n]+)\]\]/;
|
||
function strip(t){return t.replace(new RegExp(BTN.source,'g'),'').replace(/\n{3,}/g,'\n\n').trim()}
|
||
// 1순위: 에이전트가 지정한 [[버튼: A | B]] / 없으면 답변 속 "수정해줘"라고 같은 표현에서 추출
|
||
function choices(t){const g=BTN.exec(t);if(g)return [...new Set(g[1].split('|').map(x=>x.trim()).filter(x=>x&&x.length<=30))].slice(0,6);
|
||
const out=[],re=/\*{0,2}[“"‘'「]([^”"’'」\n]{1,20})[”"’'」]\*{0,2}\s*(?:이)?라고/g;let m;
|
||
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(){
|
||
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);
|
||
if(!items.length){log.innerHTML='<div class="empty">'+a.emoji+' '+a.name+'에게 말을 걸어보세요</div>';return}
|
||
log.innerHTML=items.map(m=>m.role==='system'?'<div class="sys">— '+esc(m.text)+' · '+m.ts.slice(5,16).replace('T',' ')+' —</div>'
|
||
:'<div class="msg '+m.role+'">'+(m.role==='user'?esc(m.text).replace(/\n/g,'<br>'):md(strip(m.text)))+'<span class="t">'+hm(m.ts)+'</span></div>').join('');
|
||
const last=items[items.length-1], ch=last.role==='agent'?choices(last.text):[];
|
||
if(ch.length)log.insertAdjacentHTML('beforeend','<div class="choices">'+ch.map(c=>'<button class="choice" data-choice="'+esc(c)+'"'+(send.disabled?' disabled':'')+'>'+esc(c)+'</button>').join('')+'</div>');
|
||
log.scrollTop=log.scrollHeight;
|
||
}
|
||
function load(){return api('/api/history?agent='+cur).then(d=>{renderLog(d.items);
|
||
if(d.items.length){lastSeen[cur]=d.items[d.items.length-1].id;localStorage.setItem('cw_seen',JSON.stringify(lastSeen))}
|
||
setTyping(d.pending);renderTabs()})}
|
||
function setTyping(p){const a=A.find(x=>x.id===cur);typing.textContent=a.emoji+' '+a.name+' 입력 중…';typing.classList.toggle('on',!!p);send.disabled=!!p;log.querySelectorAll('.choice').forEach(b=>b.disabled=!!p)}
|
||
function poll(){
|
||
api('/api/status').then(d=>{const prev=status;status=d;
|
||
if(prev[cur]&&prev[cur].last!==d[cur].last)load();else{setTyping(d[cur].pending);renderTabs()}
|
||
const any=Object.values(d).some(s=>s.pending);clearTimeout(timer);timer=setTimeout(poll,any?2000:20000)}).catch(()=>{clearTimeout(timer);timer=setTimeout(poll,5000)});
|
||
}
|
||
function doSend(choice){
|
||
const fromInput=typeof choice!=='string',t=fromInput?input.value.trim():choice;if(!t||send.disabled)return;send.disabled=true;
|
||
log.querySelectorAll('.choice').forEach(b=>b.disabled=true);
|
||
api('/api/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:cur,text:t})})
|
||
.then(d=>{if(d.error){alert(d.error);send.disabled=false;return}if(fromInput){input.value='';autosize()}load().then(()=>{clearTimeout(timer);timer=setTimeout(poll,1500)})})
|
||
.catch(()=>{send.disabled=false});
|
||
}
|
||
function autosize(){input.style.height='auto';input.style.height=Math.min(input.scrollHeight,window.innerHeight*0.4)+'px'}
|
||
$('#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');$('#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()})});
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden){load();poll()}});
|
||
load();poll();
|
||
})();
|
||
"""
|
||
|
||
|
||
# ---------------- HTTP ----------------
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = 'chat-web'
|
||
|
||
def log_message(self, *a):
|
||
pass
|
||
|
||
# --- 공용 ---
|
||
def _ip(self) -> str:
|
||
xf = self.headers.get('X-Forwarded-For', '')
|
||
return xf.split(',')[0].strip() if xf else self.client_address[0]
|
||
|
||
def _cookie(self, name: str) -> str | None:
|
||
for part in (self.headers.get('Cookie') or '').split(';'):
|
||
k, _, v = part.strip().partition('=')
|
||
if k == name:
|
||
return v
|
||
return None
|
||
|
||
def _send(self, code: int, body: bytes, ctype: str = 'text/html; charset=utf-8',
|
||
cookies: list[str] | None = None, location: str | None = None) -> None:
|
||
self.send_response(code)
|
||
self.send_header('Content-Type', ctype)
|
||
self.send_header('Content-Length', str(len(body)))
|
||
self.send_header('Cache-Control', 'no-store')
|
||
self.send_header('X-Frame-Options', 'DENY')
|
||
self.send_header('X-Content-Type-Options', 'nosniff')
|
||
self.send_header('Referrer-Policy', 'no-referrer')
|
||
self.send_header('Content-Security-Policy',
|
||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; "
|
||
"img-src 'self' data:; frame-ancestors 'none'; form-action 'self'")
|
||
for c in cookies or []:
|
||
self.send_header('Set-Cookie', c)
|
||
if location:
|
||
self.send_header('Location', location)
|
||
self.end_headers()
|
||
if self.command != 'HEAD':
|
||
self.wfile.write(body)
|
||
|
||
def _json(self, data, code: int = 200) -> None:
|
||
self._send(code, json.dumps(data, ensure_ascii=False).encode(), 'application/json; charset=utf-8')
|
||
|
||
def _redirect(self, to: str, cookies: list[str] | None = None) -> None:
|
||
self._send(303, b'', cookies=cookies, location=to)
|
||
|
||
@staticmethod
|
||
def _ck(name: str, val: str, max_age: int, samesite: str = 'Lax') -> str:
|
||
return f'{name}={val}; Path=/; Max-Age={max_age}; HttpOnly; Secure; SameSite={samesite}'
|
||
|
||
def _authed(self) -> bool:
|
||
return _valid_session(self._cookie('cw_sess'))
|
||
|
||
def _body(self) -> bytes:
|
||
n = int(self.headers.get('Content-Length') or 0)
|
||
return self.rfile.read(min(n, 64 * 1024)) if n > 0 else b''
|
||
|
||
def _form(self) -> dict:
|
||
return {k: v[0] for k, v in urllib.parse.parse_qs(self._body().decode('utf-8', 'replace')).items()}
|
||
|
||
# --- GET ---
|
||
def do_GET(self):
|
||
path, _, qs = self.path.partition('?')
|
||
q = {k: v[0] for k, v in urllib.parse.parse_qs(qs).items()}
|
||
if path == '/':
|
||
if self._authed():
|
||
return self._send(200, _app_page())
|
||
if self._cookie('cw_pre') in _otp:
|
||
return self._send(200, _otp_page())
|
||
return self._send(200, _login_page())
|
||
if path in ('/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'):
|
||
# 인증 밖 — iOS 가 홈 화면 추가 시 쿠키 없이 가져갈 수 있다
|
||
return self._send(200, ICON_FILE.read_bytes(), 'image/png')
|
||
if not self._authed():
|
||
return self._json({'error': 'auth'}, 401) if path.startswith('/api/') else self._redirect('/')
|
||
if path == '/app.js':
|
||
return self._send(200, APP_JS.encode(), 'application/javascript; charset=utf-8')
|
||
if path == '/api/history':
|
||
ag = q.get('agent', '')
|
||
if ag not in AGENT_IDS:
|
||
return self._json({'error': 'agent'}, 400)
|
||
return self._json({'items': _history(ag), 'pending': ag in _pending})
|
||
if path == '/api/status':
|
||
out = {}
|
||
for ag in AGENT_IDS:
|
||
h = _history(ag)
|
||
out[ag] = {'pending': ag in _pending, 'last': h[-1]['id'] if h else None}
|
||
return self._json(out)
|
||
self._send(404, b'not found', 'text/plain')
|
||
|
||
do_HEAD = do_GET
|
||
|
||
# --- POST ---
|
||
def do_POST(self):
|
||
path = self.path.partition('?')[0]
|
||
ip = self._ip()
|
||
|
||
if path == '/login/request':
|
||
has_pre = self._cookie('cw_pre') in _otp
|
||
page = _otp_page if has_pre else _login_page
|
||
left = _locked()
|
||
if left:
|
||
return self._send(429, _login_page(f'잠겨 있습니다. {int(left // 60) + 1}분 뒤 다시 시도하세요.'))
|
||
why = _take_send_slot()
|
||
if why:
|
||
log(f'code request refused ip={ip}')
|
||
return self._send(429, page(why))
|
||
code = f'{secrets.randbelow(10**6):06d}'
|
||
pre = secrets.token_urlsafe(24)
|
||
t = time.time()
|
||
for k in [k for k, v in _otp.items() if v['exp'] < t]:
|
||
_otp.pop(k, None)
|
||
_otp.pop(self._cookie('cw_pre') or '', None) # 다시 받기 — 이전 코드는 무효
|
||
_otp[pre] = {'otp': code, 'exp': t + OTP_TTL, 'tries': 0}
|
||
if not _send_otp(code):
|
||
_otp.pop(pre, None)
|
||
return self._send(503, _login_page('인증 코드를 보내지 못했습니다.'))
|
||
log(f'otp sent ip={ip}')
|
||
return self._redirect('/', [self._ck('cw_pre', pre, OTP_TTL, 'Strict')])
|
||
|
||
if path == '/login/otp':
|
||
f = self._form()
|
||
pre = self._cookie('cw_pre')
|
||
ent = _otp.get(pre or '')
|
||
clear = [self._ck('cw_pre', '', 0, 'Strict')]
|
||
if _locked():
|
||
_otp.pop(pre or '', None)
|
||
return self._redirect('/', clear)
|
||
if not ent or ent['exp'] < time.time():
|
||
_otp.pop(pre or '', None)
|
||
return self._send(401, _login_page('코드가 만료됐습니다. 다시 로그인하세요.'), cookies=clear)
|
||
if not hmac.compare_digest(f.get('otp', '').strip(), ent['otp']):
|
||
ent['tries'] += 1
|
||
_record_fail(ip, 'otp')
|
||
if ent['tries'] >= OTP_MAX_TRIES:
|
||
_otp.pop(pre, None)
|
||
return self._send(401, _login_page('코드를 여러 번 틀렸습니다. 다시 로그인하세요.'), cookies=clear)
|
||
return self._send(401, _otp_page('코드가 맞지 않습니다.'))
|
||
_otp.pop(pre, None)
|
||
tok = _new_session(ip, self.headers.get('User-Agent', ''))
|
||
log(f'login ok ip={ip}')
|
||
return self._redirect('/', clear + [self._ck('cw_sess', tok, SESSION_TTL)])
|
||
|
||
if path == '/logout':
|
||
_drop_session(self._cookie('cw_sess'))
|
||
return self._redirect('/', [self._ck('cw_sess', '', 0)])
|
||
|
||
# 이하 API — 세션 + 커스텀 헤더(교차 출처 요청은 이 헤더를 붙이려면 CORS 사전요청을 거쳐야 해 차단된다)
|
||
if not self._authed() or self.headers.get('X-CW') != '1':
|
||
return self._json({'error': 'auth'}, 401)
|
||
try:
|
||
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)
|
||
|
||
if path == '/api/send':
|
||
text = (data.get('text') or '').strip()
|
||
if not text:
|
||
return self._json({'error': '빈 메시지'}, 400)
|
||
if len(text) > MAX_MSG:
|
||
return self._json({'error': f'{MAX_MSG}자 이하로 보내주세요'}, 400)
|
||
if ag in _pending:
|
||
return self._json({'error': '이전 답을 기다리는 중입니다'}, 409)
|
||
if text.lower() == '/clear': # 웹 전용 명령 — 에이전트로 보내지 않는다
|
||
_clear(ag)
|
||
return self._json({'ok': True, 'cleared': True})
|
||
item = _append(ag, 'user', text)
|
||
_pending[ag] = time.time() # 스레드 시작 전 표시 — 연속 클릭 이중 발송 방지
|
||
threading.Thread(target=_run_agent, args=(ag, text), daemon=True).start()
|
||
return self._json({'ok': True, 'item': item})
|
||
|
||
if path == '/api/reset':
|
||
if ag in _pending:
|
||
return self._json({'error': '답을 기다리는 중에는 새로 시작할 수 없습니다'}, 409)
|
||
return self._json({'ok': True, 'session': _rotate_session(ag)})
|
||
|
||
self._json({'error': 'not found'}, 404)
|
||
|
||
|
||
def cmd_serve(port: int) -> None:
|
||
STATE.mkdir(parents=True, exist_ok=True)
|
||
srv = ThreadingHTTPServer(('', port), Handler)
|
||
log(f'chat_web serve :{port}')
|
||
srv.serve_forever()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
args = sys.argv[1:]
|
||
if args[:1] == ['serve']:
|
||
port = int(args[args.index('--port') + 1]) if '--port' in args else DEFAULT_PORT
|
||
cmd_serve(port)
|
||
else:
|
||
print(__doc__)
|