From 114a1ef8168ca4a8c2d0c852a181fd1537777208 Mon Sep 17 00:00:00 2001 From: hyowons Date: Wed, 7 Oct 2026 12:41:08 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=20=EC=B1=84=ED=8C=85:=20=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=84=A0=ED=83=9D=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=C2=B7=ED=99=94=EB=A9=B4=20=EA=B3=A0=EC=A0=95=C2=B7?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=B4=EA=B8=B0=C2=B7=EC=BD=94=EB=94=94?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=97=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하단 탭 → 입력창 왼쪽 에이전트 선택 버튼(목록 팝업, 안 읽은 답 빨간 점) - 모바일 약 80% 크기(대화 본문 13.5px), iOS 입력 확대 방지 - 상단·하단 프레임 고정(페이지 스크롤 차단, 키보드 시 visualViewport 맞춤) - ⋯ 메뉴에 상태 보기(/status) - 🛠 코디: remote-control 세션 기동/재사용 후 Claude 앱 링크 제공 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- workspace/scripts/chat_web.py | 138 ++++++++++++++++++++++++++++++---- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6785f7ab..cad1209c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Stock agent skills (`agents/stock/workspace/skills/`): Main workspace (`workspace/scripts/`), run with `python3`: - `briefing_mail.py` — Gmail/Calendar/YouTube 뉴스 브리핑 composer (네이버 지수 KOSPI/KOSDAQ/나스닥 조회 포함, 월요일 오전엔 stock agent의 `ipo_calendar_sync.py` 호출). **오전 브리핑은 `youtube_briefing_digest`를 import해 상단에 🌎 해외 증시·이슈 + 🇰🇷 국내 증시·이슈 카드 2개 삽입**. `prepare morning [--final]`이 `us_market`(@futuresnow)·`behive_market`(비하이브 주식시황) 블록(available_today·recap_expected_today·video·transcript)을 산출. ⚠️ **비하이브 주식시황이 미국증시도 다뤄 @futuresnow와 해외부분 겹침** → LLM이 두 자막을 합쳐 중복제거 후 **해외(`overseas_summary`)/국내(`domestic_summary`)로 재정리**(해외=두 소스 병합, 미국수치는 @futuresnow 우선 / 국내=비하이브 국내부분만). 발송 게이트 `us_send_directive`(proceed/wait): **둘 중 하나라도 available이면 proceed**, 평일(둘 중 하나라도 게시예정)인데 둘 다 미게시면 wait(다음 폴백 재시도), 주말/`--final`은 proceed. cron이 directive==wait면 발송 보류. 카드 요약은 LLM이 카테고리 그룹 배열(`[{label,items[]}]`)로 작성, compose의 `_video_summary_card`가 `overseas_summary`/`domestic_summary` 있을 때만 렌더(해외=파랑·국내=초록 팔레트, 그룹 라벨에 안 맞는 내용은 '기타' 그룹). **오후 브리핑은 `behive_close`(비하이브 '마감시황')로 📕 마감시황 카드**(갈색 팔레트, `close_summary` 그룹배열, best-effort) — 시장 체크 다음·뉴스 앞에 렌더. **요약·뉴스 본문의 `**강조**` 마크다운은 `_emphasize`가 굵게+진한 강조색(글자색 #c62828, 배경 안 건드림)으로 변환** — LLM이 핵심 수치·키워드만 절제해서 표시(시황 카드·뉴스 요약 공통). **국내 증시에 큰 영향 줄 뉴스는 LLM이 article에 `kr_impact:true` → `_article_card`가 🔥'국내증시 영향' 배지+빨강 강조 테두리**로 부각(반도체·환율·미 금리·외국인 수급·정책 등, 남발 금지). **해외/국내/마감시황 카드 불릿도 국내 영향 큰 항목은 LLM이 맨 앞에 `🔥 ` 접두 → `_us_items_html`이 강조 배경 밴드(#fff7f6)로 표시**. **시장 체크의 코스피 야간선물·MSCI는 비하이브 자막 언급값으로 오버라이드** — LLM이 `kospi_futures_override`/`msci_override`(라인 문자열) 작성 시 build_html_body가 스크랩값(investing.com, 전날 것일 수 있음) 대신 사용, 미작성·불명확이면 스크랩 유지. ⚠️ @futuresnow '오늘의 요약'엔 코스피/MSCI 언급 없음(미국 전용) — 이 값 출처는 비하이브뿐. **토요일 = 해외 전용 브리핑**(2026-08-12): `build_prepare_payload`가 `now.weekday()==5`면 `overseas_only=True` → 뉴스는 `OVERSEAS_NEWS_FEEDS`('국제' 2개, 국내경제·증시 제외)만 쓰고 **일정·IPO·내일상장은 수집 자체를 생략**한다. 부수효과 둘: `gog` 3콜이 사라져 유일한 단일 실패점(`get_today_events`는 try/except가 없어 실패 시 prepare 전체 중단)을 토요일엔 회피하고, IPO를 안 읽으니 `mark_ipo_changes_seen`이 seen을 선점하지 않아 **금요일 sync 결과가 월요일 아침에 정상 노출**된다. ⚠️ `build_html_body`에서 일정 카드를 **명시적으로 스킵**해야 한다 — `events`가 빈 배열이어도 `_list_card`는 '일정 없습니다' 카드를 렌더한다. 국내 카드는 `domestic_summary` 조건부라 자동 제외되고, `_morning_missing_summary_notes`의 '비하이브 요약 없음' 각주도 `overseas_only`면 억제(의도된 부재라 노이즈). **뉴스 중복제거 3단**: ①실행 내 정규화 제목 완전일치 ②실행 내 토큰 min-overlap ≥`NEWS_DUP_THRESHOLD`(0.40) ③48h 발송이력 — ③은 오래 **완전일치뿐**이라 '같은 사안 다른 제목'이 오후에 재등장했고, 2026-08-12 `recent_history_token_sets()`로 이력 제목의 토큰 집합을 만들어 `collect_news_articles(exclude_token_sets=)`에 넘겨 ②의 fuzzy를 이력에도 적용했다(실측: 오전 발송분과 같은 사안 2건이 저녁 후보에서 걸러지고 그 자리에 새 기사가 채워짐). ⚠️ **한국어 조사·복합명사 때문에 같은 사안이 0.40 아래로 떨어지는 쌍이 남아 있다**(실측 '트럼프 골프장에 이동식 방공미사일' vs '골프장에 방공 미사일…트럼프의 암살 노이로제' = 0.29). 문턱을 낮추면 오탐이 늘어 손대지 않았다 — 더 줄이려면 토크나이저(조사 절단·문자 n-gram)를 고치는 쪽. ⚠️ `prepare`는 `already_sent`면 `briefing_pending_selection.json`을 **덮어쓰지 않는다**(2026-08-12) — 그전엔 08:30 폴백이 스킵 직전에 기사를 재선별해 발송본과 무관한 목록을 남겼고(실측 발송 7건 vs 파일 4건, 교집합 0), 이 파일이 '같은 뉴스가 또 와?' 디버깅 진입점이라 틀린 증거를 보게 됐다 - `gmail_label_classify.py` — Gmail 라벨 자동 분류 (조회 전용 Gmail API, 계정 `mini.snowoyh@gmail.com`). self-sent 메일을 제목 룰로 라벨링: `[오전/오후 브리핑]`→`브리핑`, `[비하이브 종목분석]`→`종목분석`(`주식` 라벨 제거), `[주식 리포트]`→`주식브리핑`(`주식` 제거) + 24h 지난 테스트메일 휴지통 이동. 로그 `state/gmail_label_classify.log`. launchd `ai.openclaw.gmail-label-classify` 매일 01:00(LLM 미경유, 2026-06-26 cron에서 이관). CLI: `python3 gmail_label_classify.py [--dry-run]` -- `chat_web.py` — **웹 채팅**(2026-10-07). `chat.hyowons.net`에서 클로·레이·골디와 텔레그램처럼 대화하는 자체 웹(표준 라이브러리 단일 파일, 포트 18793, launchd `ai.openclaw.chat-web` 상시). **Tailscale 없이** NAS 리버스 프록시로 공개되므로(와일드카드 DNS라 프록시 규칙만 추가) 로그인은 **본인 iMessage OTP 단독**(`[인증 코드 받기]` → `@chat.hyowons.net #코드` iOS 자동입력, 5분) → 30일 세션 쿠키(HttpOnly·Secure·Lax, 서버엔 sha256만 `state/chat_web/auth.json`). ⚠️ **비밀번호는 없다**(2026-10-07 관리자님 결정 — 처음엔 비밀번호+OTP 2단계로 만들었다가 제거). 그래서 누구나 '코드 받기'를 누를 수 있어 **발송 제한**이 핵심 방어다 — 1분 1회·1시간 5회, 넘으면 15분 잠금. 코드를 15분 안에 5회 틀려도(**IP 무관 전역**) 15분 잠금. 둘 다 잠길 때 + 클로 텔레그램 알림(`codex_fallback_monitor._send_telegram` 재사용). API POST는 세션 + `X-CW: 1` 헤더 필수(교차 출처 요청 차단). 에이전트 호출은 `openclaw agent --agent --session-key web… --message-file … --json`(게이트웨이 경유 — 게이트웨이 자체는 외부에 안 연다). ⚠️ **세션 키가 텔레그램과 달라 웹 대화는 별도 맥락**이고, `--deliver` 미지정이라 답이 텔레그램으로 나가지 않는다(관리자님 선택). '새 대화 시작'은 세션 키를 `web-<시각>`으로 바꾼다(`state/chat_web/session_keys.json`). **명령어**: 슬래시 명령은 그대로 에이전트에 전달돼 OpenClaw 본체가 처리한다(`/new`=본체가 세션 리셋 `✅ New session started.`, `/status`=LLM이 session_status 도구로 답함 — 실측). **`/clear`만 웹 전용**으로 서버가 가로채 화면 기록을 `history/archive/`로 옮기고(삭제 X) 세션 키를 새로 바꾼다(에이전트 호출 없음). 에이전트당 동시 1건(답 기다리는 중 전송은 409). 화면 기록 `state/chat_web/history/.jsonl`(최근 300줄 표시). 클라이언트는 답을 기다릴 때만 2초, 평소엔 20초마다 폴링 — 에이전트가 먼저 말을 거는 경로는 없다(크론 알림은 여전히 텔레그램). ⚠️ OTP·로그인 대기 상태는 메모리에만 있어 서버 재시작 시 로그인 중이던 사람은 처음부터 다시 한다. **선택지 버튼**(2026-10-07, 두 방식 병행 — 관리자님 선택): ①**에이전트 지정** — 웹에서 보내는 메시지에만 `WEB_HINT`("고를 질문이면 마지막 줄에 `[[버튼: A | B]]`")를 덧붙여 에이전트에 전달하고, 클라이언트가 마커를 숨기고 버튼으로 그린다. 화면 기록엔 원문만 저장(안내는 안 보임). ⚠️ **슬래시 명령엔 안내를 붙이지 않는다**(`/new` 등 본체 명령이 깨진다). ⚠️ **AGENTS.md 에 넣지 않은 이유** — 텔레그램엔 버튼이 없어 마커가 날것으로 보이고, 텔레그램 메타 규칙은 부작용 전례가 있다. 실측 골디·클로 모두 형식을 지켰고 마커 위치는 `[답변완료]` 앞뒤가 섞여 나와 어디에 있어도 파싱한다 ②**문장 추출(폴백)** — 마커가 없을 때만, 답변 속 `"수정해줘"라고`·`'취소'라고` 같은 **따옴표+(이)라고** 표현을 `choices()` 가 뽑는다. 누르면 그 문구를 그대로 보낸다(입력창 초안은 안 건드림). ⚠️ **마지막 메시지가 에이전트 답변일 때만** 띄운다 — 지난 답변의 버튼은 이미 끝난 질문이다. 답을 기다리는 동안 버튼은 비활성. **홈 화면 아이콘**은 `chat_web_icon.png`(180px, 같은 폴더)를 `/apple-touch-icon.png` 로 **인증 없이** 서빙한다(iOS 는 이게 없으면 제목 첫 글자 '채'로 아이콘을 만든다). 아이콘을 바꾸면 iOS 는 캐시하므로 홈 화면에서 지우고 다시 추가해야 반영된다 +- `chat_web.py` — **웹 채팅**(2026-10-07). `chat.hyowons.net`에서 클로·레이·골디와 텔레그램처럼 대화하는 자체 웹(표준 라이브러리 단일 파일, 포트 18793, launchd `ai.openclaw.chat-web` 상시). **Tailscale 없이** NAS 리버스 프록시로 공개되므로(와일드카드 DNS라 프록시 규칙만 추가) 로그인은 **본인 iMessage OTP 단독**(`[인증 코드 받기]` → `@chat.hyowons.net #코드` iOS 자동입력, 5분) → 30일 세션 쿠키(HttpOnly·Secure·Lax, 서버엔 sha256만 `state/chat_web/auth.json`). ⚠️ **비밀번호는 없다**(2026-10-07 관리자님 결정 — 처음엔 비밀번호+OTP 2단계로 만들었다가 제거). 그래서 누구나 '코드 받기'를 누를 수 있어 **발송 제한**이 핵심 방어다 — 1분 1회·1시간 5회, 넘으면 15분 잠금. 코드를 15분 안에 5회 틀려도(**IP 무관 전역**) 15분 잠금. 둘 다 잠길 때 + 클로 텔레그램 알림(`codex_fallback_monitor._send_telegram` 재사용). API POST는 세션 + `X-CW: 1` 헤더 필수(교차 출처 요청 차단). 에이전트 호출은 `openclaw agent --agent --session-key web… --message-file … --json`(게이트웨이 경유 — 게이트웨이 자체는 외부에 안 연다). ⚠️ **세션 키가 텔레그램과 달라 웹 대화는 별도 맥락**이고, `--deliver` 미지정이라 답이 텔레그램으로 나가지 않는다(관리자님 선택). '새 대화 시작'은 세션 키를 `web-<시각>`으로 바꾼다(`state/chat_web/session_keys.json`). **명령어**: 슬래시 명령은 그대로 에이전트에 전달돼 OpenClaw 본체가 처리한다(`/new`=본체가 세션 리셋 `✅ New session started.`, `/status`=LLM이 session_status 도구로 답함 — 실측). **`/clear`만 웹 전용**으로 서버가 가로채 화면 기록을 `history/archive/`로 옮기고(삭제 X) 세션 키를 새로 바꾼다(에이전트 호출 없음). 에이전트당 동시 1건(답 기다리는 중 전송은 409). 화면 기록 `state/chat_web/history/.jsonl`(최근 300줄 표시). 클라이언트는 답을 기다릴 때만 2초, 평소엔 20초마다 폴링 — 에이전트가 먼저 말을 거는 경로는 없다(크론 알림은 여전히 텔레그램). ⚠️ OTP·로그인 대기 상태는 메모리에만 있어 서버 재시작 시 로그인 중이던 사람은 처음부터 다시 한다. **선택지 버튼**(2026-10-07, 두 방식 병행 — 관리자님 선택): ①**에이전트 지정** — 웹에서 보내는 메시지에만 `WEB_HINT`("고를 질문이면 마지막 줄에 `[[버튼: A | B]]`")를 덧붙여 에이전트에 전달하고, 클라이언트가 마커를 숨기고 버튼으로 그린다. 화면 기록엔 원문만 저장(안내는 안 보임). ⚠️ **슬래시 명령엔 안내를 붙이지 않는다**(`/new` 등 본체 명령이 깨진다). ⚠️ **AGENTS.md 에 넣지 않은 이유** — 텔레그램엔 버튼이 없어 마커가 날것으로 보이고, 텔레그램 메타 규칙은 부작용 전례가 있다. 실측 골디·클로 모두 형식을 지켰고 마커 위치는 `[답변완료]` 앞뒤가 섞여 나와 어디에 있어도 파싱한다 ②**문장 추출(폴백)** — 마커가 없을 때만, 답변 속 `"수정해줘"라고`·`'취소'라고` 같은 **따옴표+(이)라고** 표현을 `choices()` 가 뽑는다. 누르면 그 문구를 그대로 보낸다(입력창 초안은 안 건드림). ⚠️ **마지막 메시지가 에이전트 답변일 때만** 띄운다 — 지난 답변의 버튼은 이미 끝난 질문이다. 답을 기다리는 동안 버튼은 비활성. **에이전트 전환**은 탭이 아니라 입력창 왼쪽 원형 버튼(현재 에이전트 이모지) → 위로 뜨는 목록에서 고른다(2026-10-07 관리자님 지정 — 상단·하단 탭 모두 거쳐서 바꿈). 다른 에이전트에 안 읽은 답이 있으면 버튼과 목록 항목에 빨간 점. **모바일(≤600px)은 약 80% 크기**(본문 12px, 입력칸 13px) — 입력칸이 16px 미만이면 iOS 가 포커스 때 확대하므로 viewport 에 `maximum-scale=1` 을 넣었다. **🛠 코디 항목**(2026-10-07): 목록 맨 아래 코디는 이 웹의 대화 상대가 **아니다** — 누르면 `POST /api/cody` 가 `~/.openclaw` workdir 의 remote-control 세션을 찾아 **떠 있으면 재사용, 없으면 `session_tool.py session open openclaw` 로 기동**하고, 세션 로그(`logs/claude-session-.log`)의 **마지막 화면 프레임**에서 주소를 뽑아 `[Claude 앱에서 열기]` 버튼으로 준다(연결된 세션이 있으면 `claude.ai/code/session_…` 직통, 없으면 `?environment=env_…` 진입). ⚠️ 자동 이동이 아니라 버튼인 이유 — iOS 는 사용자가 직접 누른 링크여야 유니버설 링크로 Claude 앱을 연다. ⚠️ 웹에서 코디와 대화하지 않는 이유 — 승인 프롬프트를 못 띄워 읽기전용/무승인 전권 둘 중 하나가 되고, 무승인 전권이면 OTP 하나로 맥 전체를 조작하는 웹이 된다(관리자님과 검토 후 이 방식 선택). 실제 대화는 Claude 앱에서 하므로 Anthropic 로그인이 보호막이다. 실측 새 기동 3.5초. 세션은 자동으로 닫히지 않는다 — 닫기는 기존대로 클로에게 '세션 닫아줘'. **홈 화면 아이콘**은 `chat_web_icon.png`(180px, 같은 폴더)를 `/apple-touch-icon.png` 로 **인증 없이** 서빙한다(iOS 는 이게 없으면 제목 첫 글자 '채'로 아이콘을 만든다). 아이콘을 바꾸면 iOS 는 캐시하므로 홈 화면에서 지우고 다시 추가해야 반영된다 - `youtube_briefing_digest.py` — 오전 브리핑용 유튜브 시황 영상 감지+자막 (두 채널 공용). `get_us_summary()`=오선의 미국 증시 라이브(@futuresnow, `UC_JJ_NhRqPKcIOj5Ko3W_3w`) '오늘의 요약'(미국장 마감 후 07:20~08:30 KST, 예정=화~토) / `get_behive_market_summary()`=비하이브 투자자문(`UCHTRF5r154igU2gXjudUMzg`) '주식시황'(장전 시황, 05:40~06:50 KST, 예정=월~금) / `get_behive_close_summary()`=같은 채널 '마감시황'(장 마감 해설, 16:45~18:10 KST, 예정=월~금, 오후 브리핑용). 공통: 채널 영상 페이지 `lockupViewModel` 스크랩(RSS 피드 IP 404 잦아 미사용)→제목필터→최신영상→watch `publishDate`(PT→KST)로 오늘게시 판정(제목 날짜는 신뢰X)→자막. CLI: `python3 youtube_briefing_digest.py {latest|transcript} [us|behive]`. ⚠️ 레이의 `behive_youtube_digest.py`(종목분석→워치리스트)와 별개 — 같은 채널이지만 목적·소유자 다름 Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: diff --git a/workspace/scripts/chat_web.py b/workspace/scripts/chat_web.py index fc82e2a5..0195506c 100644 --- a/workspace/scripts/chat_web.py +++ b/workspace/scripts/chat_web.py @@ -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'' - f'' + f'' f'{html.escape(title)}' # 홈 화면 아이콘 — 없으면 iOS 가 제목 첫 글자('채')로 아이콘을 만든다 f'' @@ -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'''
+ body = f'''
-
+
🛠 코디
+Claude 앱에서 열기
-