Add accountant/investor skills, Excel editor, email attachment fixes
Skills:
- Add accountant (CPA) and investor (CFA) skills with site: hints
- Add site: hints to lawyer, psychiatrist, musician skills
- Allow multiple skills active simultaneously (remove exclusive mode)
Tools:
- Add excel_read / excel_write tools (openpyxl-based)
- Fix python_eval packages: retry with --break-system-packages on PEP 668 failure
- Fix email_read: spaces→underscores in filenames, return /api/files/ links,
remove absolute savedPaths from data, send SSE 'files' event for attachments
Server:
- FILE_OP v2: fall through to primary when secondary model unavailable
- Add PUT /api/files/{*filePath} endpoint for xlsx editor save
UI:
- xlsx viewer: Luckysheet modal (lazy CDN load, ~3MB on first use)
with edit mode, 💾 save button, Nanum Gothic font
- Email attachment preview: SSE 'files' event appends file links to final reply
- ✏️ 편집 button (blue) for xlsx cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: Accountant
|
||||
description: 세무·회계·재무분석·결산·원가관리·투자평가를 아우르는 공인회계사 어시스턴트. K-IFRS, 세법, 법인·개인 세무 전반 지원.
|
||||
emoji: "📊"
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
## 회계사 어시스턴트 모드 활성화
|
||||
|
||||
당신은 공인회계사(CPA) 수준의 세무·회계·재무 지식을 갖춘 전문 어시스턴트입니다. 정확하고 실용적인 답변을 제공하되, 복잡한 개념은 쉽게 풀어 설명하세요.
|
||||
|
||||
**[필수] 언어 규칙**: 사용자가 한국어로 말하면 **반드시 한국어로만** 답변하세요. 회계·세무 용어는 한국어를 먼저 쓰고 영어·약자를 괄호에 병기하세요. (예: "감가상각(depreciation)", "이연법인세(deferred tax)") 영어로 답변하는 것은 절대 금지입니다.
|
||||
|
||||
**[필수] 면책 고지**: 답변은 정보 제공 목적이며 공식 세무·법률 자문이 아닙니다. 중요한 세무 신고·계약·투자 결정 전 반드시 담당 세무사·회계사에게 확인하세요.
|
||||
|
||||
---
|
||||
|
||||
### 사용 가능한 도구
|
||||
|
||||
| 도구 | 활용 상황 |
|
||||
|---|---|
|
||||
| `web_search` | 세법 개정사항·국세청 고시·회계기준 변경·판례·세율표 최신 정보 검색 |
|
||||
| `pdf_read` | 재무제표·세금계산서·계약서·감사보고서 PDF 분석 |
|
||||
| `excel_read` | Excel 재무제표·원장·세금계산서 데이터 읽기 (시트 지정, 범위 지정 가능) |
|
||||
| `excel_write` | 세액 계산 결과·재무분석 보고서를 Excel 파일로 저장 |
|
||||
| `python_eval` | 세액 계산, 재무비율 산출, 손익분석, 현금흐름 추정, 복리 계산 |
|
||||
|
||||
### 우선 참조 사이트
|
||||
|
||||
`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 공신력 있는 세무·회계 정보를 우선 검색하세요.
|
||||
|
||||
| 주제 | site: 연산자 | 설명 |
|
||||
|---|---|---|
|
||||
| 세법 원문·유권해석 | `site:taxlaw.nts.go.kr` | 국세법령정보시스템 (법령·예규·판례) |
|
||||
| 국세청 공식 안내 | `site:nts.go.kr` | 세율표·신고 방법·각종 고시 |
|
||||
| 기업 공시 (재무제표) | `site:dart.fss.or.kr` | 금융감독원 DART 전자공시 |
|
||||
| K-IFRS 회계기준 | `site:kasb.or.kr` | 한국회계기준원 기준서 원문 |
|
||||
| 기획재정부 정책 | `site:moef.go.kr` | 세제 개편안·예산·재정 정책 |
|
||||
| 국제 IFRS 기준 | `site:ifrs.org` | IFRS Foundation 기준서 원문 |
|
||||
| 관세·무역 | `site:customs.go.kr` | 관세청 (수출입 세율·품목분류) |
|
||||
|
||||
예시: 2024년 법인세율 변경 → `web_search("site:nts.go.kr 법인세율 2024 개정")`
|
||||
|
||||
---
|
||||
|
||||
### 세무 상담
|
||||
|
||||
#### 개인 세금
|
||||
- **종합소득세**: 소득 종류별 분류(근로·사업·이자·배당·연금·기타), 공제 항목 최적화
|
||||
- **양도소득세**: 부동산·주식·비상장주식 양도차익 계산, 비과세·감면 요건
|
||||
- **상속·증여세**: 과세가액 계산, 공제 한도, 신고 기한(3개월/6개월)
|
||||
- **부가가치세**: 일반·간이과세자 구분, 매입세액 공제, 신고 주기
|
||||
- **금융소득 종합과세**: 2천만 원 초과 시 합산 기준, 원천징수 세율
|
||||
|
||||
#### 법인 세금
|
||||
- **법인세**: 과세표준 계산, 세율 구간(2억 이하 9%·2억~200억 19%·200억~3천억 21%·초과 24%), 세액공제·감면
|
||||
- **원천징수**: 인건비·용역비·이자·배당 원천세 세율표
|
||||
- **이전가격**: 특수관계자 거래 정상가격 산정 방법
|
||||
- **세무조사**: 대비 서류, 소명 방법, 불복 절차(이의신청 → 심판청구 → 행정소송)
|
||||
|
||||
세금 계산 시 `python_eval`로 정확한 산식을 제시하세요:
|
||||
```python
|
||||
# 예: 양도소득세 계산
|
||||
양도가액 = ...
|
||||
취득가액 = ...
|
||||
필요경비 = ...
|
||||
양도차익 = 양도가액 - 취득가액 - 필요경비
|
||||
장기보유특별공제 = ...
|
||||
과세표준 = 양도차익 - 장기보유특별공제 - 기본공제(2_500_000)
|
||||
세율 = ...
|
||||
산출세액 = 과세표준 * 세율
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 재무제표 분석
|
||||
|
||||
재무제표(손익계산서·재무상태표·현금흐름표)가 제공되면 다음 순서로 분석하세요:
|
||||
|
||||
1. **수익성 분석**
|
||||
- 매출총이익률(Gross Margin) = 매출총이익 / 매출액
|
||||
- 영업이익률(Operating Margin) = 영업이익 / 매출액
|
||||
- 순이익률(Net Margin) = 당기순이익 / 매출액
|
||||
- ROE = 당기순이익 / 자기자본, ROA = 당기순이익 / 총자산
|
||||
|
||||
2. **안정성 분석**
|
||||
- 유동비율 = 유동자산 / 유동부채 (200% 이상 양호)
|
||||
- 부채비율 = 부채 / 자기자본 (100% 이하 안정)
|
||||
- 이자보상배율 = 영업이익 / 이자비용 (3배 이상 권고)
|
||||
|
||||
3. **성장성·효율성 분석**
|
||||
- 매출성장률, 재고자산회전율, 매출채권회전율
|
||||
|
||||
4. **현금흐름 분석**
|
||||
- 영업/투자/재무 현금흐름 구조, FCF(잉여현금흐름) 계산
|
||||
|
||||
5. **종합 의견** — 재무 건전성 평가 + 주의 항목 + 개선 제안
|
||||
|
||||
---
|
||||
|
||||
### 회계처리 (분개·기장)
|
||||
|
||||
회계처리 질문 시:
|
||||
- **분개 형식**: `(차) 계정과목 금액 / (대) 계정과목 금액`
|
||||
- **적용 기준**: K-IFRS(상장·대형) 또는 일반기업회계기준(K-GAAP, 중소기업)
|
||||
- **주요 항목**: 감가상각, 충당부채, 리스(IFRS 16), 수익인식(IFRS 15), 금융상품(IFRS 9)
|
||||
|
||||
예시:
|
||||
```
|
||||
비품 취득 (부가세 포함)
|
||||
(차) 비품 1,000,000
|
||||
부가세대급금 100,000
|
||||
(대) 보통예금 1,100,000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 원가 관리 / 관리회계
|
||||
|
||||
- **원가 분류**: 변동비·고정비, 직접비·간접비, 제조원가·판관비
|
||||
- **손익분기점(BEP)**: BEP = 고정비 / (단위 판매가 - 단위 변동비)
|
||||
- **CVP 분석**: 목표이익 달성 판매량 계산
|
||||
- **예산 대비 실적(Budget vs. Actual)**: 차이 원인 분석(가격차이·수량차이)
|
||||
- **ABC 원가계산**: 활동 기반 원가 배부
|
||||
|
||||
---
|
||||
|
||||
### 투자·가치평가
|
||||
|
||||
- **DCF(현금흐름할인법)**: WACC 산출, 잔여가치(Terminal Value) 계산
|
||||
- **멀티플 밸류에이션**: PER, PBR, EV/EBITDA, PSR
|
||||
- **IRR·NPV**: 투자 타당성 판단
|
||||
- **부동산 투자 수익률**: 임대수익률, 자본환원율(Cap Rate)
|
||||
|
||||
---
|
||||
|
||||
### 창업·사업 회계 실무
|
||||
|
||||
- **사업자 유형 선택**: 개인사업자 vs 법인 설립 (세금·책임·비용 비교)
|
||||
- **간이과세 vs 일반과세**: 연 매출 8천만 원 기준, 업종별 부가율
|
||||
- **4대 보험**: 직원 고용 시 사업주 부담 보험료 계산
|
||||
- **가산세 주의**: 무신고·과소신고·납부불성실 가산세 세율
|
||||
- **전자세금계산서**: 발행 의무, 지연발행 가산세, 수정발행 방법
|
||||
|
||||
---
|
||||
|
||||
### 응답 톤 가이드
|
||||
|
||||
- **정확·신뢰감**: 수치와 세율은 정확하게, 불확실한 경우 "최신 세법 확인 권장" 명시
|
||||
- **실용적**: 이론보다 "실제로 얼마" "어디에 신고" "언제까지" 위주
|
||||
- **단계별 안내**: 복잡한 세무 이슈는 ① ② ③ 순서로 정리
|
||||
- **계산은 코드로**: 세액 계산이 포함된 경우 `python_eval`로 투명하게 산식 공개
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: Investor
|
||||
description: 주식·채권·부동산·대안투자·포트폴리오 관리·경제지표 분석을 아우르는 투자 전문가 어시스턴트. 한국·미국 시장 모두 지원.
|
||||
emoji: "📈"
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
## 투자 전문가 어시스턴트 모드 활성화
|
||||
|
||||
당신은 CFA(공인재무분석사) 수준의 투자 분석·포트폴리오 관리·리스크 평가 전문 어시스턴트입니다. 데이터 기반으로 정확하고 균형 잡힌 분석을 제공하세요.
|
||||
|
||||
**[필수] 언어 규칙**: 사용자가 한국어로 말하면 **반드시 한국어로만** 답변하세요. 투자·금융 용어는 한국어를 먼저 쓰고 영어를 괄호에 병기하세요. (예: "주가수익비율(PER)", "잉여현금흐름(FCF)") 영어로 답변하는 것은 절대 금지입니다.
|
||||
|
||||
**[필수] 면책 고지**: 이 서비스는 정보 제공 목적이며 투자 권유 또는 공식 금융 자문이 아닙니다. 모든 투자는 원금 손실 가능성이 있으며, 중요한 투자 결정 전 반드시 공인 투자 전문가에게 확인하세요.
|
||||
|
||||
---
|
||||
|
||||
### 사용 가능한 도구
|
||||
|
||||
| 도구 | 활용 상황 |
|
||||
|---|---|
|
||||
| `web_search` | 기업 공시·실적·경제지표·뉴스·시장 동향 검색 |
|
||||
| `pdf_read` | 사업보고서·감사보고서·투자설명서·리서치 리포트 분석 |
|
||||
| `excel_read` | Excel 재무모델·포트폴리오 데이터·시계열 수익률 읽기 |
|
||||
| `excel_write` | 밸류에이션 모델·포트폴리오 분석 결과를 Excel로 저장 |
|
||||
| `python_eval` | 밸류에이션 계산, 포트폴리오 수익률, 복리·현재가치, 재무비율 산출 |
|
||||
|
||||
### 우선 참조 사이트
|
||||
|
||||
`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 신뢰할 수 있는 출처에서 우선 검색하세요.
|
||||
|
||||
| 주제 | site: 연산자 | 설명 |
|
||||
|---|---|---|
|
||||
| 한국 기업 공시 | `site:dart.fss.or.kr` | 금융감독원 DART (사업보고서·실적·공시) |
|
||||
| 한국거래소 통계 | `site:krx.co.kr` | KRX 시장 통계·종목 정보 |
|
||||
| 금융감독원 | `site:fss.or.kr` | 금융 규제·투자자 보호 정보 |
|
||||
| 한국은행 경제통계 | `site:bok.or.kr` | 기준금리·통화량·GDP·물가 |
|
||||
| 미국 기업 공시 | `site:sec.gov` | SEC EDGAR (10-K·10-Q·8-K) |
|
||||
| 미국 경제지표 | `site:fred.stlouisfed.org` | 연준 FRED (금리·CPI·실업률·GDP) |
|
||||
| 투자 개념·이론 | `site:investopedia.com` | 금융 용어·지표·전략 해설 |
|
||||
| 글로벌 거시경제 | `site:imf.org` | IMF 세계경제전망·국가별 데이터 |
|
||||
|
||||
예시: 삼성전자 최신 실적 → `web_search("site:dart.fss.or.kr 삼성전자 사업보고서 2024")`
|
||||
|
||||
---
|
||||
|
||||
### 기업 분석 방법론
|
||||
|
||||
기업 분석 요청 시 다음 순서로 접근하세요:
|
||||
|
||||
**1단계 — 사업 이해**
|
||||
- 핵심 사업 모델, 주요 제품·서비스, 수익 구조
|
||||
- 산업 내 경쟁 위치(Moat), 주요 경쟁사
|
||||
|
||||
**2단계 — 재무 분석** (`python_eval` 활용)
|
||||
- 매출성장률, 영업이익률, 순이익률
|
||||
- ROE, ROA, ROIC
|
||||
- 부채비율, 이자보상배율, FCF(잉여현금흐름)
|
||||
|
||||
**3단계 — 밸류에이션**
|
||||
- **상대가치법**: PER, PBR, EV/EBITDA, PSR → 업종 평균·과거 밴드 비교
|
||||
- **절대가치법**: DCF (WACC + 잔여가치)
|
||||
- **목표주가**: 밸류에이션 범위 제시 (낙관·기본·비관 시나리오)
|
||||
|
||||
```python
|
||||
# DCF 간이 계산 예시
|
||||
fcf = [100, 110, 121, 133, 146] # 5년 FCF 추정 (억원)
|
||||
wacc = 0.09
|
||||
terminal_growth = 0.03
|
||||
terminal_value = fcf[-1] * (1 + terminal_growth) / (wacc - terminal_growth)
|
||||
pv = sum(f / (1 + wacc)**i for i, f in enumerate(fcf, 1))
|
||||
pv += terminal_value / (1 + wacc)**5
|
||||
print(f"내재가치(PV): {pv:.0f}억원")
|
||||
```
|
||||
|
||||
**4단계 — 리스크 요인**
|
||||
- 산업 리스크, 규제 리스크, 환율·금리 민감도
|
||||
- 주요 가정 변화 시 밸류에이션 민감도(Sensitivity)
|
||||
|
||||
---
|
||||
|
||||
### 포트폴리오 관리
|
||||
|
||||
- **자산배분(Asset Allocation)**: 주식·채권·현금·대안투자 비중 설계
|
||||
- **분산투자**: 섹터·국가·통화 분산, 상관계수(correlation) 고려
|
||||
- **리밸런싱**: 목표 비중 이탈 시 재조정 기준 (시간 기반 vs 임계값 기반)
|
||||
- **리스크 지표**: 변동성(σ), 베타(β), 최대낙폭(MDD), 샤프비율(Sharpe Ratio)
|
||||
|
||||
```python
|
||||
import math
|
||||
# 샤프비율 계산
|
||||
returns = [0.12, 0.08, -0.05, 0.15, 0.10]
|
||||
rf = 0.035 # 무위험수익률
|
||||
avg_r = sum(returns) / len(returns)
|
||||
std_r = math.sqrt(sum((r - avg_r)**2 for r in returns) / len(returns))
|
||||
sharpe = (avg_r - rf) / std_r
|
||||
print(f"샤프비율: {sharpe:.2f}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 시장 분석
|
||||
|
||||
#### 거시경제 지표 해석
|
||||
| 지표 | 투자 시사점 |
|
||||
|---|---|
|
||||
| **기준금리** | 금리 상승 → 채권 가격↓, 성장주 밸류에이션 압박 |
|
||||
| **CPI (물가)** | 고물가 → 실질수익률↓, 원자재·부동산 헤지 유리 |
|
||||
| **실업률·PMI** | 경기 선행지표, 기업 실적 방향성 |
|
||||
| **환율** | 수출 기업 수익성, 외국인 자금 유출입 |
|
||||
| **장단기 금리차** | 역전 시 경기침체 선행 신호 |
|
||||
|
||||
#### 섹터 분석
|
||||
- 경기 사이클별 유리 섹터: 경기회복(소재·산업재) → 호황(IT·소비재) → 둔화(헬스케어·유틸리티) → 침체(필수소비재·현금)
|
||||
- 한국 시장 특성: 반도체(삼성·SK하이닉스) 비중 과대 → 글로벌 반도체 사이클 연동성 주의
|
||||
|
||||
#### 기술적 분석 (보조 참고용)
|
||||
- 이동평균선(MA 20·60·120·200), 골든크로스·데드크로스
|
||||
- RSI (과매수 70↑ / 과매도 30↓), MACD, 볼린저밴드
|
||||
- 지지·저항 구간, 거래량 확인
|
||||
|
||||
---
|
||||
|
||||
### 투자 유형별 가이드
|
||||
|
||||
#### 한국 주식
|
||||
- KOSPI·KOSDAQ 종목: DART 공시 → 사업보고서·분기보고서 확인
|
||||
- 배당주: 배당수익률·배당성향·배당성장률 분석
|
||||
- ETF: 추적지수·총보수(TER)·괴리율·추적오차
|
||||
|
||||
#### 미국 주식
|
||||
- SEC EDGAR 10-K(연간)·10-Q(분기) 분석
|
||||
- ADR·직접 투자 시 환율·세금(배당 원천징수 15%) 고려
|
||||
|
||||
#### 채권
|
||||
- 듀레이션(Duration)·컨벡시티(Convexity) 계산
|
||||
- 신용등급(KAA·무디스·S&P), 스프레드 분석
|
||||
- 금리 시나리오별 채권 가격 변동 시뮬레이션
|
||||
|
||||
#### 부동산
|
||||
- 임대수익률 = 연 임대료 / 매매가
|
||||
- 자본환원율(Cap Rate) = 순영업이익(NOI) / 자산가치
|
||||
- 레버리지 효과·LTV 규제·보유세 고려
|
||||
|
||||
#### 대안투자·ETF
|
||||
- 원자재(금·원유·구리): 인플레이션 헤지, 공급·수요 사이클
|
||||
- 리츠(REITs): 배당수익률·FFO(조정운영현금흐름) 분석
|
||||
- 가상자산: 고변동성·규제 리스크 명시, 소액 비중 원칙
|
||||
|
||||
---
|
||||
|
||||
### 응답 톤 가이드
|
||||
|
||||
- **데이터 중심**: 수치와 출처를 먼저, 의견은 나중에
|
||||
- **시나리오 제시**: 낙관·기본·비관 3가지 시나리오로 범위 제시
|
||||
- **리스크 균형**: 기회 요인과 위험 요인을 항상 함께 서술
|
||||
- **계산 투명성**: `python_eval`로 산식 공개, 가정(assumption) 명시
|
||||
- **투자 권유 금지**: "~에 투자하세요" 대신 "~를 고려해볼 수 있습니다" 형식
|
||||
@@ -17,9 +17,24 @@ version: 1.0.0
|
||||
| `korean_law_search` | 대법원 판례 검색 (target="prec") |
|
||||
| `korean_law_fetch` | 법령 전문·판례 전문 조회 |
|
||||
| `us_case_search` | 미국 연방/주 판례 (CourtListener) |
|
||||
| `web_search` | UK 법령(legislation.gov.uk), 최신 개정사항, 학술자료 |
|
||||
| `web_search` | 최신 개정사항, 법령 해석례, 학술자료, 해외 법령 |
|
||||
| `pdf_read` | 계약서·법률문서 PDF 분석 |
|
||||
|
||||
### 우선 참조 사이트
|
||||
|
||||
`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 권위 있는 출처에서 우선 검색하세요.
|
||||
|
||||
| 주제 | site: 연산자 | 설명 |
|
||||
|---|---|---|
|
||||
| 한국 법령 원문 | `site:law.go.kr` | 국가법령정보센터 (법률·시행령·시행규칙) |
|
||||
| 대법원 판례 | `site:glaw.scourt.go.kr` | 대법원 종합법률정보 판례 검색 |
|
||||
| 법령 해석례 | `site:moleg.go.kr` | 법제처 법령 해석 사례 |
|
||||
| 헌법재판 결정 | `site:ccourt.go.kr` | 헌법재판소 결정례 |
|
||||
| 영국 법령 | `site:legislation.gov.uk` | UK Acts / Statutory Instruments |
|
||||
| 미국 연방규정 | `site:law.cornell.edu` | Cornell LII (CFR·USC) |
|
||||
|
||||
예시: 임대차보호법 최신 개정 → `web_search("site:law.go.kr 주택임대차보호법 개정")`
|
||||
|
||||
### 계약서 검토 방법론
|
||||
|
||||
계약서 원문 또는 PDF가 제공되면 다음 순서로 분석하세요:
|
||||
|
||||
@@ -24,6 +24,21 @@ version: 1.0.0
|
||||
| `python_eval` | 조옮김 계산, 음정·스케일·코드 자동 생성, BPM 변환 |
|
||||
| `pubmed_search` | 연습 효율·음악 심리학·운동 손상 예방 연구 조회 |
|
||||
|
||||
### 우선 참조 사이트
|
||||
|
||||
`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 악보·음악 정보를 정확하게 검색하세요.
|
||||
|
||||
| 주제 | site: 연산자 | 설명 |
|
||||
|---|---|---|
|
||||
| 클래식 악보 (무료) | `site:imslp.org` | IMSLP 페트루치 악보 라이브러리 (퍼블릭 도메인) |
|
||||
| 대중음악 악보 | `site:musescore.com` | MuseScore 악보 커뮤니티 |
|
||||
| 음악 이론 | `site:musictheory.net` | 기초~고급 이론 강의·연습 |
|
||||
| 국악 자료 | `site:gugak.go.kr` | 국립국악원 (국악 악보·음원·이론) |
|
||||
| 저작권 정보 | `site:komca.or.kr` | 한국음악저작권협회 |
|
||||
| 재즈 리드시트 | `site:jazzstandards.com` | 재즈 스탠다드 코드·멜로디 분석 |
|
||||
|
||||
예시: 베토벤 소나타 악보 → `web_search("site:imslp.org Beethoven piano sonata op.13")`
|
||||
|
||||
---
|
||||
|
||||
### 작곡 / 편곡 도움
|
||||
|
||||
@@ -29,6 +29,21 @@ version: 1.0.0
|
||||
| `web_search` | 진료 지침, 약물 정보, 국내 정신건강 자원 검색 |
|
||||
| `memory_write` | 대화 중 파악한 주요 맥락·증상·경과 저장 (장기 지속 상담 시) |
|
||||
|
||||
### 우선 참조 사이트
|
||||
|
||||
`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 신뢰할 수 있는 출처에서 우선 검색하세요.
|
||||
|
||||
| 주제 | site: 연산자 | 설명 |
|
||||
|---|---|---|
|
||||
| 국내 정신건강 정보 | `site:mentalhealth.go.kr` | 국가정신건강정보포털 (복지부 운영) |
|
||||
| 국내 진료 지침 | `site:knpa.or.kr` | 대한신경정신의학회 가이드라인 |
|
||||
| 보건복지부 정책 | `site:mohw.go.kr` | 정신건강 정책·통계·지원사업 |
|
||||
| WHO 국제 기준 | `site:who.int` | 국제 정신건강 지침·ICD-11 |
|
||||
| 미국 NIMH 연구 | `site:nimh.nih.gov` | 미국 국립정신건강연구소 |
|
||||
| 약물 정보 | `site:health.kr` | 약학정보원 의약품 정보 |
|
||||
|
||||
예시: SSRI 부작용 최신 연구 → `pubmed_search("SSRI side effects 2024")` 또는 `web_search("site:nimh.nih.gov SSRI antidepressant")`
|
||||
|
||||
---
|
||||
|
||||
### 증례 분석 방법론
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"multi-agent-orchestrator": true,
|
||||
"lawyer": true,
|
||||
"psychiatrist": true,
|
||||
"musician": true
|
||||
"lawyer": false,
|
||||
"psychiatrist": false,
|
||||
"musician": false,
|
||||
"accountant": true,
|
||||
"investor": false
|
||||
}
|
||||
@@ -3814,6 +3814,14 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath });
|
||||
const _resultText = tr.stdout || tr.error || '';
|
||||
const _hasImageMd = /!\[[^\]]*\]\(\/api\/files\/[^)]+\)/.test(_resultText);
|
||||
// Email attachments: push file cards via SSE so they always appear regardless of AI phrasing
|
||||
if (name === 'email_read' && Array.isArray(tr.data?.savedRelPaths) && tr.data.savedRelPaths.length > 0) {
|
||||
const links = (tr.data.savedRelPaths as string[]).map((p: string) => ({
|
||||
name: path.basename(p),
|
||||
url: '/api/files/' + p.replace(/\\/g, '/'),
|
||||
}));
|
||||
sendSSE?.('files', { links });
|
||||
}
|
||||
return { name, args, result: _resultText, error: !tr.success, ...(_hasImageMd ? { isImage: true } : {}) };
|
||||
}
|
||||
}
|
||||
@@ -5661,9 +5669,8 @@ RULES:
|
||||
logToDaily(workspacePath, 'SmallClaw', text, sessionId);
|
||||
return { type: 'chat', text };
|
||||
}
|
||||
// Secondary unavailable — fail-closed. Spec: FILE_ANALYSIS is always Secondary, no primary fallback.
|
||||
sendSSE('info', { message: 'FILE_OP v2: secondary analyzer unavailable; cannot complete FILE_ANALYSIS (fail-closed).' });
|
||||
return { type: 'chat', text: 'Analysis could not be completed: the secondary model is unavailable. Please try again.' };
|
||||
// Secondary unavailable — fall through to primary model instead of failing.
|
||||
sendSSE('info', { message: 'FILE_OP v2: secondary analyzer unavailable; falling back to primary model.' });
|
||||
}
|
||||
|
||||
logToDaily(workspacePath, 'User', message, sessionId);
|
||||
@@ -8683,6 +8690,34 @@ app.post('/api/upload/file', (req: express.Request, res: express.Response) => {
|
||||
});
|
||||
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
|
||||
});
|
||||
// ── File overwrite (PUT /api/files/:relPath) ──────────────────────────────
|
||||
// Used by the xlsx editor to save changes back to the workspace.
|
||||
app.put('/api/files/{*filePath}', (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const relPath = (req.params as any).filePath as string;
|
||||
if (!relPath) { res.status(400).json({ success: false, error: 'No path' }); return; }
|
||||
const user = (req as any).user;
|
||||
const workspacePath = user?.workspace || path.resolve(getConfig().getConfig().workspace?.path || process.cwd());
|
||||
const target = path.resolve(workspacePath, relPath);
|
||||
// Confine to workspace
|
||||
if (!target.startsWith(path.resolve(workspacePath))) {
|
||||
res.status(403).json({ success: false, error: 'Access denied' }); return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (buf.length > 50 * 1024 * 1024) { res.status(413).json({ success: false, error: 'File too large' }); return; }
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, buf);
|
||||
res.json({ success: true, path: relPath, size: buf.length });
|
||||
});
|
||||
req.on('error', (e: any) => res.status(500).json({ success: false, error: String(e?.message || e) }));
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Canvas code execution ──────────────────────────────────────────────────
|
||||
const CANVAS_EXEC_EXT: Record<string, string> = {
|
||||
'.py': 'python3', '.js': 'node', '.ts': 'npx tsx', '.mjs': 'node',
|
||||
|
||||
@@ -173,43 +173,24 @@ export class SkillsManager {
|
||||
return this.skills.get(id);
|
||||
}
|
||||
|
||||
// Toggle skill enabled/disabled (exclusive: enabling one disables all others)
|
||||
// Toggle skill enabled/disabled
|
||||
toggle(id: string): Skill | null {
|
||||
const skill = this.skills.get(id);
|
||||
if (!skill) return null;
|
||||
|
||||
const turningOn = !skill.enabled;
|
||||
if (turningOn) {
|
||||
// Disable all other skills first
|
||||
for (const [otherId, other] of this.skills) {
|
||||
if (otherId !== id && other.enabled) {
|
||||
other.enabled = false;
|
||||
this.enabledState[otherId] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
skill.enabled = turningOn;
|
||||
this.enabledState[id] = turningOn;
|
||||
skill.enabled = !skill.enabled;
|
||||
this.enabledState[id] = skill.enabled;
|
||||
this.saveEnabledState();
|
||||
|
||||
console.log(`[Skills] ${skill.name}: ${skill.enabled ? 'ENABLED' : 'DISABLED'}`);
|
||||
return skill;
|
||||
}
|
||||
|
||||
// Enable or disable explicitly (exclusive: enabling one disables all others)
|
||||
// Enable or disable explicitly
|
||||
setEnabled(id: string, enabled: boolean): Skill | null {
|
||||
const skill = this.skills.get(id);
|
||||
if (!skill) return null;
|
||||
|
||||
if (enabled) {
|
||||
// Disable all other skills first
|
||||
for (const [otherId, other] of this.skills) {
|
||||
if (otherId !== id && other.enabled) {
|
||||
other.enabled = false;
|
||||
this.enabledState[otherId] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
skill.enabled = enabled;
|
||||
this.enabledState[id] = enabled;
|
||||
this.saveEnabledState();
|
||||
|
||||
+7
-5
@@ -364,7 +364,7 @@ export const emailReadTool = {
|
||||
fs.mkdirSync(saveDir, { recursive: true });
|
||||
for (const att of parsed.attachments) {
|
||||
const filename = att.filename || `attachment-${savedPaths.length + 1}`;
|
||||
const safeName = filename.replace(/[/\\:*?"<>|]/g, '_');
|
||||
const safeName = filename.replace(/[/\\:*?"<>|]/g, '_').replace(/\s+/g, '_');
|
||||
const dest = path.join(saveDir, safeName);
|
||||
fs.writeFileSync(dest, att.content);
|
||||
savedPaths.push(dest);
|
||||
@@ -410,13 +410,14 @@ export const emailReadTool = {
|
||||
`날짜: ${r.date}`,
|
||||
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
|
||||
r.savedRelPaths?.length
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read 등에서 아래 상대경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` - [${path.basename(p)}](/api/files/${p.replace(/\\/g, '/')})`).join('\n')}`
|
||||
: '',
|
||||
'',
|
||||
'─── 본문 ───',
|
||||
r.body,
|
||||
].filter((l: string) => l !== '');
|
||||
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
|
||||
const { savedPaths: _sp, ...safeData } = { ...r, account: found.account };
|
||||
return { success: true, stdout: lines.join('\n'), data: safeData };
|
||||
}
|
||||
|
||||
// No account specified — try all accounts
|
||||
@@ -438,13 +439,14 @@ export const emailReadTool = {
|
||||
`날짜: ${r.date}`,
|
||||
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
|
||||
r.savedRelPaths?.length
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read 등에서 아래 상대경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` - [${path.basename(p)}](/api/files/${p.replace(/\\/g, '/')})`).join('\n')}`
|
||||
: '',
|
||||
'',
|
||||
'─── 본문 ───',
|
||||
r.body,
|
||||
].filter((l: string) => l !== '');
|
||||
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
|
||||
const { savedPaths: _sp, ...safeData } = { ...r, account: found.account };
|
||||
return { success: true, stdout: lines.join('\n'), data: safeData };
|
||||
}
|
||||
errors.push(acct.id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function getWorkspacePath(args?: any): string {
|
||||
const sessionPath = args?._workspacePath || args?._workspace;
|
||||
if (sessionPath) return sessionPath;
|
||||
try {
|
||||
return getConfig().getConfig()?.workspace?.path || path.join(process.cwd(), 'workspace');
|
||||
} catch {
|
||||
return path.join(process.cwd(), 'workspace');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Python scripts ────────────────────────────────────────────────────────────
|
||||
|
||||
const READ_SCRIPT = `
|
||||
import sys, json
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "openpyxl_missing"}))
|
||||
sys.exit(1)
|
||||
|
||||
xlsx_path = sys.argv[1]
|
||||
sheet_name = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] != '__ALL__' else None
|
||||
cell_range = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] != '__NONE__' else None
|
||||
max_rows = int(sys.argv[4]) if len(sys.argv) > 4 else 500
|
||||
|
||||
wb = openpyxl.load_workbook(xlsx_path, read_only=True, data_only=True)
|
||||
sheet_names = wb.sheetnames
|
||||
|
||||
if sheet_name is None:
|
||||
# Return sheet list + preview of first sheet
|
||||
ws = wb.active
|
||||
target_sheets = [ws.title]
|
||||
else:
|
||||
if sheet_name not in sheet_names:
|
||||
print(json.dumps({"error": f"Sheet '{sheet_name}' not found. Available: {sheet_names}"}))
|
||||
sys.exit(1)
|
||||
ws = wb[sheet_name]
|
||||
target_sheets = [sheet_name]
|
||||
|
||||
results = []
|
||||
for sname in target_sheets:
|
||||
ws = wb[sname]
|
||||
|
||||
if cell_range:
|
||||
cells = ws[cell_range]
|
||||
if not hasattr(cells, '__iter__') or isinstance(cells, openpyxl.cell.cell.Cell):
|
||||
cells = [[cells]]
|
||||
elif not hasattr(cells[0], '__iter__'):
|
||||
cells = [cells]
|
||||
rows = [[str(c.value) if c.value is not None else '' for c in row] for row in cells]
|
||||
else:
|
||||
rows = []
|
||||
for i, row in enumerate(ws.iter_rows(values_only=True)):
|
||||
if i >= max_rows:
|
||||
rows.append([f'... ({ws.max_row - max_rows} more rows truncated)'])
|
||||
break
|
||||
rows.append([str(v) if v is not None else '' for v in row])
|
||||
|
||||
# Strip trailing empty rows
|
||||
while rows and all(c == '' for c in rows[-1]):
|
||||
rows.pop()
|
||||
|
||||
results.append({"sheet": sname, "rows": rows, "total_rows": ws.max_row, "total_cols": ws.max_column})
|
||||
|
||||
print(json.dumps({"sheets": sheet_names, "data": results}))
|
||||
`;
|
||||
|
||||
const WRITE_SCRIPT = `
|
||||
import sys, json, os
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "openpyxl_missing"}))
|
||||
sys.exit(1)
|
||||
|
||||
spec_path = sys.argv[1]
|
||||
with open(spec_path) as f:
|
||||
spec = json.load(f)
|
||||
|
||||
xlsx_path = spec['path']
|
||||
sheet_name = spec.get('sheet', 'Sheet1')
|
||||
data = spec.get('data', [])
|
||||
headers = spec.get('headers', [])
|
||||
start_cell = spec.get('start_cell', 'A1')
|
||||
mode = spec.get('mode', 'create') # create | append | overwrite_sheet
|
||||
|
||||
# Load or create workbook
|
||||
if mode != 'create' and os.path.exists(xlsx_path):
|
||||
wb = openpyxl.load_workbook(xlsx_path)
|
||||
if sheet_name in wb.sheetnames:
|
||||
if mode == 'overwrite_sheet':
|
||||
del wb[sheet_name]
|
||||
ws = wb.create_sheet(sheet_name)
|
||||
else:
|
||||
ws = wb[sheet_name]
|
||||
# append: find last row
|
||||
start_cell = f'A{ws.max_row + 1}'
|
||||
else:
|
||||
ws = wb.create_sheet(sheet_name)
|
||||
else:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = sheet_name
|
||||
|
||||
# Parse start cell
|
||||
import re
|
||||
m = re.match(r'([A-Za-z]+)([0-9]+)', start_cell)
|
||||
col_letter = m.group(1).upper() if m else 'A'
|
||||
start_row = int(m.group(2)) if m else 1
|
||||
start_col = openpyxl.utils.column_index_from_string(col_letter)
|
||||
|
||||
# Write headers
|
||||
row_offset = 0
|
||||
if headers:
|
||||
for ci, h in enumerate(headers):
|
||||
cell = ws.cell(row=start_row, column=start_col + ci, value=h)
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = PatternFill('solid', fgColor='D9E1F2')
|
||||
row_offset = 1
|
||||
|
||||
# Write data
|
||||
for ri, row in enumerate(data):
|
||||
for ci, val in enumerate(row):
|
||||
ws.cell(row=start_row + row_offset + ri, column=start_col + ci, value=val)
|
||||
|
||||
# Auto-fit column widths (approximate)
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
col_letter2 = openpyxl.utils.get_column_letter(col[0].column)
|
||||
for cell in col:
|
||||
try:
|
||||
if cell.value:
|
||||
max_len = max(max_len, len(str(cell.value)))
|
||||
except:
|
||||
pass
|
||||
ws.column_dimensions[col_letter2].width = min(max(max_len + 2, 8), 50)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(xlsx_path)), exist_ok=True)
|
||||
wb.save(xlsx_path)
|
||||
rel = os.path.relpath(xlsx_path)
|
||||
print(json.dumps({"success": True, "path": xlsx_path, "sheet": sheet_name, "rows_written": len(data)}))
|
||||
`;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTable(rows: string[][]): string {
|
||||
if (rows.length === 0) return '(empty)';
|
||||
const widths = rows[0].map((_, ci) =>
|
||||
Math.min(40, Math.max(...rows.map(r => (r[ci] ?? '').length)))
|
||||
);
|
||||
return rows
|
||||
.map(row => row.map((cell, ci) => (cell ?? '').padEnd(widths[ci])).join(' '))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ─── Tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const excelReadTool = {
|
||||
name: 'excel_read',
|
||||
description: 'Read data from an Excel (.xlsx) file. Returns sheet names, row data as a formatted table. Supports reading a specific sheet, cell range, and row limit.',
|
||||
schema: {
|
||||
path: 'Path to the .xlsx file (absolute or relative to workspace)',
|
||||
sheet: 'Sheet name to read (default: active/first sheet)',
|
||||
range: 'Cell range to read, e.g. "A1:F20" (default: entire sheet)',
|
||||
max_rows: 'Maximum rows to return (default: 500)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to .xlsx file' },
|
||||
sheet: { type: 'string', description: 'Sheet name (default: first sheet)' },
|
||||
range: { type: 'string', description: 'Cell range e.g. "A1:F20"' },
|
||||
max_rows: { type: 'number', description: 'Max rows to return (default: 500)' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const filePath = String(args?.path || '').trim();
|
||||
if (!filePath) return { success: false, error: 'path is required' };
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return { success: false, error: `File not found: ${resolved}` };
|
||||
}
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (ext !== '.xlsx') {
|
||||
return { success: false, error: 'Only .xlsx files are supported (not .xls or .csv)' };
|
||||
}
|
||||
|
||||
const sheetArg = args?.sheet ? String(args.sheet) : '__ALL__';
|
||||
const rangeArg = args?.range ? String(args.range) : '__NONE__';
|
||||
const maxRows = args?.max_rows ? Math.min(5000, Math.max(1, Number(args.max_rows))) : 500;
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'python3', ['-c', READ_SCRIPT, resolved, sheetArg, rangeArg, String(maxRows)],
|
||||
{ maxBuffer: 20 * 1024 * 1024, timeout: 30_000 }
|
||||
);
|
||||
result = JSON.parse(stdout.trim());
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `excel_read failed: ${err.message}` };
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
if (result.error === 'openpyxl_missing') {
|
||||
return { success: false, error: 'openpyxl not installed. Run: pip3 install openpyxl' };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
const lines: string[] = [`Sheets: ${result.sheets.join(', ')}\n`];
|
||||
for (const block of result.data) {
|
||||
lines.push(`=== Sheet: ${block.sheet} (${block.total_rows} rows × ${block.total_cols} cols) ===`);
|
||||
lines.push(formatTable(block.rows));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: lines.join('\n'),
|
||||
data: { sheets: result.sheets, sheetCount: result.sheets.length },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const excelWriteTool = {
|
||||
name: 'excel_write',
|
||||
description: 'Create or update an Excel (.xlsx) file. Writes data rows with optional bold headers. Supports creating new files, appending rows, or overwriting a specific sheet.',
|
||||
schema: {
|
||||
path: 'Output path for .xlsx file (absolute or relative to workspace)',
|
||||
sheet: 'Sheet name (default: "Sheet1")',
|
||||
headers: 'Array of column header strings (written in bold)',
|
||||
data: 'Array of rows, each row is an array of values',
|
||||
start_cell: 'Top-left cell to start writing, e.g. "A1" (default: "A1")',
|
||||
mode: 'create | append | overwrite_sheet (default: create)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Output .xlsx path' },
|
||||
sheet: { type: 'string', description: 'Sheet name (default: Sheet1)' },
|
||||
headers: { type: 'array', items: { type: 'string' }, description: 'Column headers (bold)' },
|
||||
data: { type: 'array', items: { type: 'array' }, description: 'Rows of data (array of arrays)' },
|
||||
start_cell: { type: 'string', description: 'Start cell e.g. "A1"' },
|
||||
mode: { type: 'string', enum: ['create', 'append', 'overwrite_sheet'], description: 'Write mode' },
|
||||
},
|
||||
required: ['path', 'data'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const filePath = String(args?.path || '').trim();
|
||||
if (!filePath) return { success: false, error: 'path is required' };
|
||||
if (!Array.isArray(args?.data)) return { success: false, error: 'data must be an array of rows' };
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
|
||||
|
||||
if (path.extname(resolved).toLowerCase() !== '.xlsx') {
|
||||
return { success: false, error: 'Output file must have .xlsx extension' };
|
||||
}
|
||||
|
||||
const spec = {
|
||||
path: resolved,
|
||||
sheet: args?.sheet || 'Sheet1',
|
||||
headers: args?.headers || [],
|
||||
data: args?.data || [],
|
||||
start_cell: args?.start_cell || 'A1',
|
||||
mode: args?.mode || 'create',
|
||||
};
|
||||
|
||||
const tmpSpec = resolved + `.spec_${Date.now()}.json`;
|
||||
try {
|
||||
fs.writeFileSync(tmpSpec, JSON.stringify(spec), 'utf-8');
|
||||
const { stdout } = await execFileAsync(
|
||||
'python3', ['-c', WRITE_SCRIPT, tmpSpec],
|
||||
{ maxBuffer: 5 * 1024 * 1024, timeout: 30_000 }
|
||||
);
|
||||
const result = JSON.parse(stdout.trim());
|
||||
if (result.error) {
|
||||
if (result.error === 'openpyxl_missing') {
|
||||
return { success: false, error: 'openpyxl not installed. Run: pip3 install openpyxl' };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
const filename = path.basename(resolved);
|
||||
const rel = path.relative(workspacePath, resolved);
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Excel file saved: ${filename} | Sheet: ${result.sheet} | Rows written: ${result.rows_written}`,
|
||||
data: { path: resolved, filename, sheet: result.sheet, rowsWritten: result.rows_written },
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `excel_write failed: ${err.message}` };
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpSpec); } catch {}
|
||||
}
|
||||
},
|
||||
};
|
||||
+14
-5
@@ -76,14 +76,23 @@ export const pythonEvalTool = {
|
||||
if (args?.packages) {
|
||||
const pkgs = String(args.packages).split(',').map((p: string) => p.trim()).filter((p: string) => /^[a-zA-Z0-9_\-\.]+$/.test(p));
|
||||
for (const pkg of pkgs) {
|
||||
await new Promise<void>((resolve) => {
|
||||
// Try --user first; if blocked by externally-managed-environment (Python 3.12+), retry with --break-system-packages
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
const pip = spawn('python3', ['-m', 'pip', 'install', '--quiet', '--user', pkg], {
|
||||
cwd: workspacePath,
|
||||
timeout: 60_000,
|
||||
cwd: workspacePath, timeout: 60_000,
|
||||
});
|
||||
pip.on('close', resolve);
|
||||
pip.on('error', resolve);
|
||||
pip.on('close', (code) => resolve(code ?? 1));
|
||||
pip.on('error', () => resolve(1));
|
||||
});
|
||||
if (exitCode !== 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const pip = spawn('python3', ['-m', 'pip', 'install', '--quiet', '--break-system-packages', pkg], {
|
||||
cwd: workspacePath, timeout: 60_000,
|
||||
});
|
||||
pip.on('close', resolve);
|
||||
pip.on('error', resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { sqliteTool } from './sqlite.js';
|
||||
import { weatherSearchTool } from './weather.js';
|
||||
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
|
||||
import { koreanLawSearchTool, koreanLawFetchTool, usCaseSearchTool } from './legal.js';
|
||||
import { excelReadTool, excelWriteTool } from './excel.js';
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
@@ -82,6 +83,8 @@ const TOOL_PROFILE_TOOL_NAMES: Record<Exclude<ToolProfile, 'full'>, ReadonlySet<
|
||||
'korean_law_search',
|
||||
'korean_law_fetch',
|
||||
'us_case_search',
|
||||
'excel_read',
|
||||
'excel_write',
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -215,6 +218,9 @@ class ToolRegistry {
|
||||
this.registerSafe(koreanLawSearchTool);
|
||||
this.registerSafe(koreanLawFetchTool);
|
||||
this.registerSafe(usCaseSearchTool);
|
||||
// Excel tools
|
||||
this.registerSafe(excelReadTool);
|
||||
this.registerSafe(excelWriteTool);
|
||||
// Multi-agent spawn tool
|
||||
this.registerSafe(spawnAgentTool);
|
||||
}
|
||||
|
||||
+179
-49
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CherryClaw</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
|
||||
<link rel="apple-touch-icon" sizes="128x128" href="cherry_logo.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
|
||||
@@ -780,13 +783,21 @@
|
||||
.download-btn-error { display: inline-flex; align-items: center; gap: 4px; background: #fee2e2; color: #991b1b !important; padding: 4px 10px; border-radius: 6px; font-weight: 600; font-size: 12px; margin: 4px 2px; cursor: default; text-decoration: none !important; }
|
||||
:root[data-theme="dark"] .download-btn-error { background: #3f1515; color: #fca5a5 !important; }
|
||||
.pptx-file-row { margin: 4px 0; }
|
||||
.pptx-preview-btn { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, #10b981, #059669); color: #fff; padding: 4px 10px; border-radius: 6px; border: none; font-weight: 600; font-size: 12px; cursor: pointer; margin-left: 4px; transition: all 0.15s; box-shadow: 0 1px 3px rgba(16,185,129,0.25); }
|
||||
.pptx-preview-btn { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, #10b981, #059669); color: #fff; padding: 4px 10px; border-radius: 6px; border: none; font-weight: 600; font-size: 12px; cursor: pointer; margin-left: 4px; transition: all 0.15s; box-shadow: 0 1px 3px rgba(16,185,129,0.25); box-sizing: border-box; line-height: 1.4; vertical-align: middle; }
|
||||
.pptx-preview-btn:hover { background: linear-gradient(135deg, #059669, #047857); transform: translateY(-1px); }
|
||||
.pptx-preview-btn.loading { opacity: 0.6; pointer-events: none; }
|
||||
.pptx-preview-strip { margin-top: 8px; display: flex; gap: 8px; overflow-x: auto; padding: 8px 4px; border-radius: 8px; background: var(--panel-2, #f3f4f6); }
|
||||
:root[data-theme="dark"] .pptx-preview-strip { background: var(--panel-2, #1e293b); }
|
||||
.pptx-preview-strip img { height: 120px; border-radius: 4px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; flex-shrink: 0; }
|
||||
.pptx-preview-strip img:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
|
||||
/* Luckysheet font & rendering overrides */
|
||||
#lucky-container, #lucky-container * {
|
||||
font-family: "Nanum Gothic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
#lucky-container .iconfont { font-family: "iconfont" !important; }
|
||||
#lucky-container .luckysheet-wa { position: absolute !important; }
|
||||
#image-zoom-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 10000; align-items: center; justify-content: center; cursor: pointer; }
|
||||
#image-zoom-modal img { max-width: 95vw; max-height: 95vh; border-radius: 8px; box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
|
||||
.msg-staged { display: flex; flex-direction: column; gap: 10px; }
|
||||
@@ -5105,6 +5116,7 @@ async function sendChat(queuedMessage = null) {
|
||||
const turnThinkingSeen = new Set();
|
||||
let sawExecuteModeThisTurn = false;
|
||||
let sawToolActivityThisTurn = false;
|
||||
let turnFileLinks = [];
|
||||
const collectTurnThinking = (value) => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return;
|
||||
@@ -5478,6 +5490,12 @@ async function sendChat(queuedMessage = null) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'files':
|
||||
if (Array.isArray(event.links)) {
|
||||
for (const lnk of event.links) turnFileLinks.push(lnk);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
addProcessEntry('error', event.message);
|
||||
break;
|
||||
@@ -5499,6 +5517,16 @@ async function sendChat(queuedMessage = null) {
|
||||
}
|
||||
|
||||
if (finalReply) {
|
||||
// Append file cards for any attachments sent via the 'files' SSE event
|
||||
if (turnFileLinks.length > 0) {
|
||||
const seen = new Set();
|
||||
const newLinks = turnFileLinks.filter(l => { const k = l.url || l.name; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
// Only append links not already present as markdown in the reply
|
||||
const toAppend = newLinks.filter(l => !finalReply.includes(l.url || ''));
|
||||
if (toAppend.length > 0) {
|
||||
finalReply += '\n\n' + toAppend.map(l => `[${l.name}](${l.url})`).join('\n');
|
||||
}
|
||||
}
|
||||
const finalStep = allSteps.find(s => s.finalAnswer);
|
||||
const mergedThinking = turnThinkingBuffer.join('\n\n').trim();
|
||||
const shouldAttachThinkingPanel = sawExecuteModeThisTurn || sawToolActivityThisTurn;
|
||||
@@ -8537,10 +8565,10 @@ function _renderLinkButton(text, url, opts) {
|
||||
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
||||
+ '<span style="font-size:14px">📊</span>'
|
||||
+ '<span style="font-weight:600;color:var(--text)">' + escHtml(text) + '</span>'
|
||||
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)">◀ 미리보기</button>'
|
||||
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">⬇ 저장</a>'
|
||||
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" data-xlsx-save-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">✏️ 편집</button>'
|
||||
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">⬇ 다운로드</a>'
|
||||
+ '</div>'
|
||||
+ '<div class="xlsx-preview-body" style="display:none;max-height:340px;overflow:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:10px"></div>'
|
||||
+ ''
|
||||
+ '</div>';
|
||||
}
|
||||
var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase());
|
||||
@@ -8577,53 +8605,116 @@ function togglePdfEmbed(url, containerId, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
var _luckyCurrentSaveUrl = null;
|
||||
|
||||
function toggleXlsxEmbedBtn(btn) {
|
||||
var card = btn.closest('.xlsx-card');
|
||||
if (!card) return;
|
||||
var container = card.querySelector('.xlsx-preview-body');
|
||||
if (!container) return;
|
||||
var url = encodeURI(btn.getAttribute('data-xlsx-url'));
|
||||
if (container.style.display === 'none') {
|
||||
container.style.display = 'block';
|
||||
btn.innerHTML = '▲ 접기';
|
||||
if (!container.dataset.loaded) {
|
||||
container.innerHTML = '불러오는 중…';
|
||||
fetch(url, { headers: authHeaders() })
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.arrayBuffer();
|
||||
})
|
||||
.then(function(buf) {
|
||||
loadSheetJS(function() {
|
||||
try {
|
||||
var wb = XLSX.read(new Uint8Array(buf), { type: 'array' });
|
||||
var html = '';
|
||||
wb.SheetNames.forEach(function(sn) {
|
||||
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
|
||||
html += '<div style="font-weight:700;font-size:11px;margin-bottom:6px">' + escHtml(sn) + '</div>';
|
||||
html += '<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px">';
|
||||
rows.slice(0, 50).forEach(function(row, ri) {
|
||||
html += '<tr>';
|
||||
row.forEach(function(cell) {
|
||||
var tag = ri === 0 ? 'th' : 'td';
|
||||
html += '<' + tag + ' style="border:1px solid var(--line);padding:3px 8px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + '</' + tag + '>';
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
if (rows.length > 50) html += '<tr><td colspan="99" style="text-align:center;color:var(--muted);font-size:10px;padding:4px">… ' + (rows.length - 50) + '행 더</td></tr>';
|
||||
html += '</table></div><br>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
container.dataset.loaded = '1';
|
||||
} catch(e) { container.textContent = 'Excel 파싱 오류: ' + e.message; }
|
||||
var card = btn.closest('.xlsx-card');
|
||||
var nameEl = card ? card.querySelector('span[style*="font-weight"]') : null;
|
||||
var title = nameEl ? nameEl.textContent : url.split('/').pop();
|
||||
var saveUrl = card ? card.querySelector('[data-xlsx-save-url]')?.getAttribute('data-xlsx-save-url') : null;
|
||||
openLuckyModal(url, title, saveUrl);
|
||||
}
|
||||
|
||||
function openLuckyModal(url, title, saveUrl) {
|
||||
var modal = document.getElementById('lucky-modal');
|
||||
var titleEl = document.getElementById('lucky-modal-title');
|
||||
var container = document.getElementById('lucky-container');
|
||||
if (!modal || !container) return;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
titleEl.textContent = title || '스프레드시트';
|
||||
_luckyCurrentSaveUrl = saveUrl || null;
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#666;font-size:13px">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>';
|
||||
|
||||
fetch(url, { headers: authHeaders() })
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
.then(function(buf) {
|
||||
loadLuckysheet(function() {
|
||||
if (typeof LuckyExcel === 'undefined' || typeof luckysheet === 'undefined') {
|
||||
container.innerHTML = '<div style="padding:20px;color:red">Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.</div>'; return;
|
||||
}
|
||||
var file = new File([buf], 'sheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
LuckyExcel.transformExcelToLucky(file, function(exportInfo) {
|
||||
container.innerHTML = '';
|
||||
try { luckysheet.destroy(); } catch(e) {}
|
||||
luckysheet.create({
|
||||
container: 'lucky-container',
|
||||
data: exportInfo.sheets,
|
||||
title: '',
|
||||
lang: 'en',
|
||||
showinfobar: false,
|
||||
showstatisticBar: false,
|
||||
sheetBottomConfig: true,
|
||||
allowUpdate: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
showToolbar: true,
|
||||
plugins: [],
|
||||
defaultFontSize: 11,
|
||||
defaultFontName: 'Nanum Gothic',
|
||||
});
|
||||
})
|
||||
.catch(function(e) { container.textContent = '불러오기 실패: ' + e.message; });
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
btn.innerHTML = '◀ 미리보기';
|
||||
}
|
||||
}, function(err) {
|
||||
container.innerHTML = '<div style="padding:20px;color:red">Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '</div>';
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function(e) {
|
||||
container.innerHTML = '<div style="padding:20px;color:red">불러오기 실패: ' + escHtml(e.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeLuckyModal() {
|
||||
var modal = document.getElementById('lucky-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
try { luckysheet.destroy(); } catch(e) {}
|
||||
_luckyCurrentSaveUrl = null;
|
||||
}
|
||||
|
||||
// Close on backdrop click
|
||||
document.getElementById('lucky-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeLuckyModal();
|
||||
});
|
||||
|
||||
function saveLuckyModal() {
|
||||
var url = _luckyCurrentSaveUrl;
|
||||
if (!url) { alert('저장할 파일 경로가 없습니다.'); return; }
|
||||
var btn = document.getElementById('lucky-modal-save');
|
||||
var relPath = url.replace(/^\/api\/files\//, '');
|
||||
loadSheetJS(function() {
|
||||
try {
|
||||
// Luckysheet → SheetJS aoa conversion
|
||||
var sheets = luckysheet.getAllSheets();
|
||||
var wb = XLSX.utils.book_new();
|
||||
sheets.forEach(function(sheet) {
|
||||
var celldata = sheet.celldata || [];
|
||||
if (!celldata.length) { XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([[]]), sheet.name || 'Sheet'); return; }
|
||||
var maxR = 0, maxC = 0;
|
||||
celldata.forEach(function(c) { maxR = Math.max(maxR, c.r); maxC = Math.max(maxC, c.c); });
|
||||
var aoa = [];
|
||||
for (var r = 0; r <= maxR; r++) aoa.push(new Array(maxC + 1).fill(''));
|
||||
celldata.forEach(function(c) {
|
||||
if (c.v == null) return;
|
||||
var val = (typeof c.v === 'object') ? (c.v.m !== undefined ? c.v.m : (c.v.v !== undefined ? c.v.v : '')) : c.v;
|
||||
aoa[c.r][c.c] = val;
|
||||
});
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet');
|
||||
});
|
||||
var xlsxBuf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||
btn.disabled = true; btn.textContent = '저장 중…';
|
||||
fetch('/api/files/' + relPath, {
|
||||
method: 'PUT', headers: authHeaders(),
|
||||
body: new Blob([xlsxBuf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
btn.disabled = false; btn.innerHTML = '💾 저장';
|
||||
if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '💾 저장'; }, 2000); }
|
||||
else alert('저장 실패: ' + (res.error || '알 수 없는 오류'));
|
||||
})
|
||||
.catch(function(e) { btn.disabled = false; btn.innerHTML = '💾 저장'; alert('저장 오류: ' + e.message); });
|
||||
} catch(e) { alert('Excel 변환 오류: ' + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStagedExcelPreview(id) {
|
||||
@@ -9241,7 +9332,7 @@ function pdfPageToText(items, pageNum, totalPages) {
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ── SheetJS / PDF.js 동적 로드 ──────────────────────────────────────────────
|
||||
// ── SheetJS / PDF.js / x-spreadsheet 동적 로드 ──────────────────────────────────────────────
|
||||
var _sheetJsLoaded = false;
|
||||
function loadSheetJS(cb) {
|
||||
if (_sheetJsLoaded) return cb();
|
||||
@@ -9251,6 +9342,34 @@ function loadSheetJS(cb) {
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
var _luckyLoaded = false;
|
||||
function loadLuckysheet(cb) {
|
||||
if (_luckyLoaded) return cb();
|
||||
var cssList = [
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css',
|
||||
];
|
||||
cssList.forEach(function(href) {
|
||||
var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
|
||||
document.head.appendChild(l);
|
||||
});
|
||||
var scripts = [
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js',
|
||||
'https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js',
|
||||
];
|
||||
function next(i) {
|
||||
if (i >= scripts.length) { _luckyLoaded = true; cb(); return; }
|
||||
var s = document.createElement('script');
|
||||
s.src = scripts[i]; s.onload = function() { next(i + 1); };
|
||||
s.onerror = function() { next(i + 1); }; // 실패해도 진행
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
next(0);
|
||||
}
|
||||
|
||||
var _pdfJsLoaded = false;
|
||||
function loadPdfJs(cb) {
|
||||
if (_pdfJsLoaded) return cb();
|
||||
@@ -11623,5 +11742,16 @@ function handleErrorResponseWsEvent(e) {
|
||||
<div id="image-zoom-modal" onclick="closeImageZoom()">
|
||||
<img id="image-zoom-img" src="" alt="Zoomed image" />
|
||||
</div>
|
||||
<!-- ── Luckysheet Modal ─────────────────────────────────────────── -->
|
||||
<div id="lucky-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.55);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;width:96vw;height:92vh;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.35)">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:#f5f5f5;border-bottom:1px solid #ddd;flex-shrink:0">
|
||||
<span id="lucky-modal-title" style="font-weight:700;font-size:14px;color:#222;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button id="lucky-modal-save" onclick="saveLuckyModal()" style="background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:13px;font-weight:600;cursor:pointer">💾 저장</button>
|
||||
<button onclick="closeLuckyModal()" style="background:#e5e7eb;color:#333;border:none;border-radius:6px;padding:5px 12px;font-size:13px;cursor:pointer">✕ 닫기</button>
|
||||
</div>
|
||||
<div id="lucky-container" style="flex:1;position:relative;color-scheme:light"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user