Add meteorologist skill with 9 weather/climate tools

New skill:
- .smallclaw/skills/meteorologist/SKILL.md — 기상 전문가 스킬 (Windy iframe 규칙 포함)

New weather tools (src/tools/weather.ts):
- weather_airpollution: OpenWeather 대기질 (AQI, PM2.5/PM10/O3/NO2)
- weather_openmeteo: Open-Meteo 시간별 예보 (ECMWF/GFS, 키 불필요)
- weather_kma: 기상청 공식 API (초단기실황·단기예보)
- weather_airkorea: 에어코리아 실시간 대기질
- weather_nasa_power: NASA POWER 기후 데이터 (MERRA-2, 키 불필요)
- weather_era5: ERA5 재분석 과거 데이터 (Open-Meteo Historical, 키 불필요)
- weather_cds: Copernicus CDS 정식 API (ERA5·CMIP6 SSP 시나리오)
- weather_cmip6: CMIP6 기후 모델 (Open-Meteo Climate API, 키 불필요)

Scripts:
- scripts/era5_cds_fetch.py — CDS/CMIP6 Python 헬퍼 (ZIP→NetCDF 처리 포함)

web-ui:
- Windy URL → iframe 자동 렌더링 (standalone URL + 마크다운 링크 모두 감지)
- windyToEmbedUrl: windy.com comma 형식 URL 파싱 개선

server-v2.ts:
- BROWSER RULE: "열어줘" 제거 → 오용 방지
- weather context: 9개 도구 선택 기준 추가
- JSON schema 8개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-26 14:22:49 +09:00
co-authored by Claude Sonnet 4.6
parent ed25f457df
commit d7eb0abc6d
18 changed files with 2545 additions and 126 deletions
+9 -1
View File
@@ -194,7 +194,15 @@
"Read(//usr/**)",
"Bash(apt-cache search *)",
"Bash(chmod +x /home/kim/homeclaw/scripts/install-image-deps.sh)",
"Bash(bash /home/kim/homeclaw/scripts/install-image-deps.sh)"
"Bash(bash /home/kim/homeclaw/scripts/install-image-deps.sh)",
"Bash(apt-cache show *)",
"Bash(npm info *)",
"Bash(lilypond --svg -o output input.ly)",
"Bash(lilypond -dpreview --svg -o preview input.ly)",
"Bash(midi2ly output.midi -o from_midi.ly)",
"Bash(lilypond --pdf -o pdf_test input.ly)",
"Bash(mktemp -d)",
"WebFetch(domain:pcsp.nationalregister.org)"
]
},
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
+18 -8
View File
@@ -120,7 +120,8 @@
"workspace_file": "HEARTBEAT.md"
},
"workspace": {
"path": "/home/kim/homeclaw/workspace"
"path": "/home/kim/homeclaw/workspace",
"codeDir": "code"
},
"agents": [],
"session": {
@@ -131,10 +132,14 @@
},
"channels": {
"telegram": {
"enabled": false,
"enabled": true,
"botToken": "",
"allowedUserIds": [],
"streamMode": "full"
"allowedUserIds": [
8540415328
],
"streamMode": "full",
"userMap": {},
"voiceEnabled": false
},
"discord": {
"enabled": false,
@@ -203,7 +208,8 @@
"max_preempts_per_turn": 1,
"max_preempts_per_session": 3,
"restart_mode": "detached_hidden"
}
},
"subagent_mode": false
},
"hooks": {
"enabled": false,
@@ -223,10 +229,14 @@
}
},
"telegram": {
"enabled": false,
"enabled": true,
"botToken": "",
"allowedUserIds": [],
"streamMode": "full"
"allowedUserIds": [
8540415328
],
"streamMode": "full",
"userMap": {},
"voiceEnabled": false
},
"search": {
"preferred_provider": "searxng",
+26 -2
View File
@@ -9,6 +9,30 @@
"/home/kim/.local/lib/node_modules/mcp-sqlite/mcp-sqlite-server.js",
"/home/kim/homeclaw/.smallclaw/databases/dental_dict.db"
],
"description": "치의학 사전 SQLite DB. 테이블: terms(id,korean,english,latin,abbreviation,definition,category,synonyms,related_ids,pmids,icd_code,notes,image_url,source_url,created_at,updated_at), categories(id,name,name_en), references_tb(id,term_id,pubmed_id,title,authors,year). 한국어 검색은 korean 컬럼, 영어는 english, 라틴어는 latin 컬럼 사용.\n\n**카테고리 목록** (category 컬럼 값 — 반드시 정확히 일치):\n- 구강악안면영상의학 (방사선, X선, CT, 촬영법 등)\n- 치주 (치주염, 잇몸, 치조골 등)\n- 보존 (우식, 근관치료, 수복 등)\n- 보철 (틀니, 크라운, 브릿지 등)\n- 교정 (부정교합, 브라켓 등)\n- 구강병리 (낭종, 종양, 조직병리 등)\n- 구강외과 (발치, 임플란트 수술 등)\n- 구강내과 (구강점막질환, 통증 등)\n- 소아치과, 예방치학, 임플란트, 치과마취학, 치과생체재료학, 노인치의학, 심미치의학, 디지털치의학, 기초치의학\n\n**퀴즈/플래시카드 생성 규칙**: 주제별 퀴즈를 낼 때 반드시 해당 category로 WHERE 절 필터를 걸 것. 예) '영상의학 퀴즈' → WHERE category='구강악안면영상의학', '치주 퀴즈' → WHERE category='치주'. 카테고리를 섞어서 선택하지 말 것. 이미지 있는 용어만 뽑을 때는 AND image_url IS NOT NULL AND image_url != '' 조건 추가.\n\n**이미지 표시 규칙**: 용어 조회 시 image_url 컬럼 값을 반드시 포함할 것. image_url은 위키미디어 직접 링크(https://upload.wikimedia.org/...) 또는 PMC CDN 링크(https://cdn.ncbi.nlm.nih.gov/pmc/blobs/...) 또는 로컬 파일 경로(/api/files/uploads/dental_images/clean/...) 형식. 이미지 표시 시 반드시 image_url 컬럼의 값을 그대로 사용할 것. 절대 직접 경로를 만들거나 임의의 URL을 생성하지 말 것. source_url은 참고문헌 URL.\n\n**퀴즈/플래시카드 이미지 출력 형식**: 퀴즈나 플래시카드를 출력할 때 image_url이 있는 용어는 반드시 마크다운 이미지 형식으로 표시할 것. 형식: ![용어명](image_url). SELECT 쿼리에 반드시 image_url 컬럼을 포함하고, 결과의 image_url 값이 비어있지 않으면 퀴즈 항목 아래에 이미지를 삽입할 것. 이미지가 없는 용어는 이미지 없이 표시."
"description": "치의학 사전 SQLite DB. 테이블: terms(id,korean,english,latin,abbreviation,definition,category,synonyms,related_ids,pmids,icd_code,notes,image_url,source_url,created_at,updated_at), categories(id,name,name_en), references_tb(id,term_id,pubmed_id,title,authors,year). 한국어 검색은 korean 컬럼, 영어는 english, 라틴어는 latin 컬럼 사용.\n\n**카테고리 목록** (category 컬럼 값 — 반드시 정확히 일치):\n- 구강악안면영상의학 (방사선, X선, CT, 촬영법 등)\n- 치주 (치주염, 잇몸, 치조골 등)\n- 보존 (우식, 근관치료, 수복 등)\n- 보철 (틀니, 크라운, 브릿지 등)\n- 교정 (부정교합, 브라켓 등)\n- 구강병리 (낭종, 종양, 조직병리 등)\n- 구강외과 (발치, 임플란트 수술 등)\n- 구강내과 (구강점막질환, 통증 등)\n- 소아치과, 예방치학, 임플란트, 치과마취학, 치과생체재료학, 노인치의학, 심미치의학, 디지털치의학, 기초치의학\n\n**퀴즈/플래시카드 생성 규칙**: 주제별 퀴즈를 낼 때 반드시 해당 category로 WHERE 절 필터를 걸 것. 예) '영상의학 퀴즈' → WHERE category='구강악안면영상의학', '치주 퀴즈' → WHERE category='치주'. 카테고리를 섞어서 선택하지 말 것. 이미지 있는 용어만 뽑을 때는 AND image_url IS NOT NULL AND image_url != '' 조건 추가.\n\n**이미지 표시 규칙**: 용어 조회 시 image_url 컬럼 값을 반드시 포함할 것. image_url은 위키미디어 직접 링크(https://upload.wikimedia.org/...) 또는 PMC CDN 링크(https://cdn.ncbi.nlm.nih.gov/pmc/blobs/...) 또는 로컬 경로(/api/files/uploads/dental_images/remote/숫자.jpg 또는 .png) 형식. 이미지 표시 시 반드시 image_url 컬럼의 값을 그대로 사용할 것. 절대 직접 경로를 만들거나 임의의 URL을 생성하지 말 것. **절대 read_file이나 다른 툴로 이미지 파일 존재 여부를 검증하지 말 것 — image_url 값이 있으면 무조건 바로 마크다운으로 출력할 것.** source_url은 참고문헌 URL.\n\n**퀴즈/플래시카드 이미지 출력 형식**: 퀴즈나 플래시카드를 출력할 때 image_url이 있는 용어는 반드시 마크다운 이미지 형식으로 표시할 것. 형식: ![용어명](image_url). SELECT 쿼리에 반드시 image_url 컬럼을 포함하고, 결과의 image_url 값이 비어있지 않으면 퀴즈 항목 아래에 이미지를 삽입할 것. 이미지가 없는 용어는 이미지 없이 표시."
},
{
"id": "psychotherapy-cases-sqlite",
"name": "심리치료 임상 사례 DB (PCSP)",
"enabled": true,
"transport": "stdio",
"command": "node",
"args": [
"/home/kim/.local/lib/node_modules/mcp-sqlite/mcp-sqlite-server.js",
"/home/kim/homeclaw/.smallclaw/databases/psychotherapy_cases.db"
],
"description": "PCSP(Pragmatic Case Studies in Psychotherapy) 저널 기반 심리치료 임상 사례 DB. 테이블: cases(id, article_id, issue_id, vol, num, year, title_en, title_ko, authors, article_type, category_en, category_ko, therapy_approach_en, therapy_approach_ko, keywords, abstract_en, abstract_ko, doi, url, source_url, created_at). 총 352개 기사(사례연구 161, 일반기사 166, 논평 23, 응답 2).\n\n**치료 접근법별 카테고리** (category_en, category_ko):\n- CBT (인지행동치료): 80건\n- Psychodynamic (정신역동치료): 37건\n- Exposure Therapy (노출치료): 28건\n- Integrative (통합치료): 13건\n- AEDP (가속적경험적역동치료): 13건\n- Humanistic (인간중심치료): 9건\n- ACT (수용전념치료): 7건\n- EFT (정서초점치료): 6건\n- DBT (변증법적행동치료): 5건\n- Schema Therapy (도식치료): 4건\n- Mindfulness (마음챙김치료): 4건\n- Hypnosis (최면치료): 4건\n- Narrative Therapy (내러티브치료): 3건\n- Other (기타): 139건\n\n**기사 유형** (article_type): case_study, article, commentary, response\n\n**쿼리 예시**:\n- CBT 사례연구: SELECT title_en, title_ko, authors, year FROM cases WHERE category_en='CBT' AND article_type='case_study'\n- 특정 치료법 사례: SELECT title_en, title_ko, abstract_en FROM cases WHERE category_en='DBT'\n- 연도별 검색: SELECT title_en, title_ko, year FROM cases WHERE year >= 2020\n- 키워드 검색: SELECT title_en, title_ko FROM cases WHERE keywords LIKE '%anxiety%'\n- 초상 포함 검색: SELECT title_en, title_ko, abstract_en FROM cases WHERE abstract_en != '' AND category_en='Exposure Therapy'\n\n**한국어 검색**: title_ko 컬럼 사용. 일부 기사는 [카테고리한국어] + 영어 원문 제목 형식.\n**출처**: Pragmatic Case Studies in Psychotherapy (PCSP) - https://pcsp.nationalregister.org/"
},
{
"id": "psychiatry-cases-sqlite",
"name": "정신과 임상 증례 DB",
"enabled": true,
"transport": "stdio",
"command": "node",
"args": [
"/home/kim/.local/lib/node_modules/mcp-sqlite/mcp-sqlite-server.js",
"/home/kim/homeclaw/.smallclaw/databases/psychiatry_cases.db"
],
"description": "DSM-5 기반 정신과 임상 증례 DB. 테이블: cases(id, section_num, case_title, diagnosis_en, diagnosis_ko, chapter_num, chapter_en, chapter_ko, content, source_url, created_at). 총 117개 증례, 19개 챕터(신경발달장애~성도착장애).\n\n**챕터 목록** (chapter_num, chapter_ko):\n1. 신경발달장애, 2. 조현병 스펙트럼, 3. 양극성 장애, 4. 우울장애, 5. 불안장애, 6. 강박 및 관련 장애, 7. 외상 및 스트레스 관련 장애, 8. 해리장애, 9. 신체증상 및 관련 장애, 10. 섭식장애, 11. 배설장애, 12. 수면-각성 장애, 13. 성기능 장애, 14. 성별 불쾌감, 15. 파괴적 충동조절 장애, 16. 물질관련 및 중독장애, 17. 신경인지장애, 18. 인격장애, 19. 성도착장애\n\n**쿼리 예시**:\n- 특정 장애 증례: SELECT case_title, diagnosis_en, content FROM cases WHERE diagnosis_en LIKE '%Schizophrenia%'\n- 챕터별 증례: SELECT case_title, diagnosis_en FROM cases WHERE chapter_num = 5\n- 한국어 챕터 검색: SELECT case_title, diagnosis_en FROM cases WHERE chapter_ko = '인격장애'\n- 증례 내용 검색: SELECT case_title, diagnosis_en FROM cases WHERE content LIKE '%bipolar%'\n\n**출력 규칙**: 증례를 설명할 때 case_title과 diagnosis_en을 함께 표시할 것. 위기 상황(자해·자살 언급)이 감지되면 즉시 자살예방상담전화 1393, 정신건강위기상담전화 1577-0199를 안내할 것."
}
]
]
+23 -32
View File
@@ -9,50 +9,41 @@ version: 1.0.0
당신은 CFA(공인재무분석사) 수준의 투자 분석·포트폴리오 관리·리스크 평가 전문 어시스턴트입니다. 데이터 기반으로 정확하고 균형 잡힌 분석을 제공하세요.
**[필수] 언어 규칙**: 사용자가 한국어로 말하면 **반드시 한국어로만** 답변하세요. 투자·금융 용어는 한국어를 먼저 쓰고 영어를 괄호에 병기하세요. (예: "주가수익비율(PER)", "잉여현금흐름(FCF)") 영어로 답변하는 것은 절대 금지입니다.
**[필수] 면책 고지**: 이 서비스는 정보 제공 목적이며 투자 권유 또는 공식 금융 자문이 아닙니다. 모든 투자는 원금 손실 가능성이 있으며, 중요한 투자 결정 전 반드시 공인 투자 전문가에게 확인하세요.
한국어로 질문하면 한국어로만 답변. 면책: 정보 제공 목적이며 투자 권유가 아닙니다.
---
## ⚠️ 시스템 내장 주가 차트 렌더링 — 반드시 읽을 것
## ⚠️ 주가 차트 렌더링 규칙 — 최우선 적용
이 시스템에는 **TradingView 실시간 주가 차트** 기능이 내장되어 있습니다.
### 한국 주식 → `kstock` 코드블록 (네이버금융)
주가 차트 요청 시 아래 **단순 형식**의 코드블록만 출력하면 채팅창에 "📈 차트 불러오기" 버튼이 자동 생성됩니다:
형식: 종목코드|기간 (기간 생략 시 day)
```tradingview
KRX:005930|D
삼성전자 예시:
```kstock
005930|day
```
**⚠️ 절대 JSON 형식 사용 금지** — `{...}` 객체 대신 반드시 `거래소:종목코드|봉주기` 한 줄만 출력
기간 옵션: day(일봉), week(주봉), month(월봉), 3month, year, 3year
**형식**: `거래소:종목코드|봉주기`
주요 종목코드: 005930(삼성전자), 000660(SK하이닉스), 035720(카카오), 005380(현대차), 035420(NAVER)
| 봉주기 | 코드 |
|---|---|
| 1분봉 | `1` |
| 5분봉 | `5` |
| 15분봉 | `15` |
| 1시간봉 | `60` |
| 일봉 | `D` |
| 주봉 | `W` |
| 월봉 | `M` |
### 해외 주식·지수·환율·원자재 → `tradingview` 코드블록
**거래소 코드 예시**:
- 한국 코스피: `KRX:005930` (삼성전자), `KRX:000660` (SK하이닉스)
- 한국 코스닥: `KOSDAQ:035720` (카카오)
- 미국 나스닥: `NASDAQ:AAPL`, `NASDAQ:NVDA`, `NASDAQ:TSLA`
- 미국 NYSE: `NYSE:BRK.B`, `NYSE:JPM`
- 환율: `FX:USDKRW`, `FX:EURUSD`
- 금/원자재: `TVC:GOLD`, `TVC:SILVER`, `NYMEX:CL1!` (원유)
- 암호화폐: `BINANCE:BTCUSDT`, `BINANCE:ETHUSDT`
- 지수: `KRX:KOSPI`, `FOREXCOM:SPXUSD` (S&P500), `NASDAQ:QQQ`
형식: 거래소:종목코드|봉주기
**주가 차트가 자동으로 렌더링되므로 절대 금지 문구**:
- "직접 차트를 보여드릴 수 없습니다"
- "TradingView 사이트에서 확인하세요"
- 외부 링크 안내
예시:
```tradingview
NASDAQ:AAPL|D
```
봉주기: 1/5/15/60=분봉, D=일봉, W=주봉, M=월봉
주요 심볼: NASDAQ:AAPL, NASDAQ:NVDA, NYSE:JPM, FX:USDKRW, TVC:GOLD, BINANCE:BTCUSDT, FOREXCOM:SPXUSD
**⚠️ 절대 금지**:
- 한국 주식에 tradingview 코드블록 사용 (KRX: 심볼은 지원 안 됨)
- JSON 형식(`{...}`) 사용
- "차트를 보여드릴 수 없습니다" / "TradingView에서만 제공됩니다" 등의 거절 문구
---
+304
View File
@@ -0,0 +1,304 @@
---
name: Meteorologist
description: 날씨 분석·기후 해설·기상 현상 설명·예보 해석을 제공하는 기상 전문가 어시스턴트. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 정확하고 쉬운 설명 제공.
emoji: "🌤️"
version: 1.0.0
---
## 기상 전문가 모드 활성화
당신은 기상학 전문가 어시스턴트입니다. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 날씨 예보 해석, 기상 현상 설명, 기후 분석, 자연재해 경보 해설을 제공하세요.
**[필수] 언어 규칙**: 사용자가 한국어로 말하면 **반드시 한국어로만** 답변하세요. 기상 용어는 한국어를 먼저 쓰고 영어를 괄호 안에 병기하세요. (예: "적란운(cumulonimbus)", "기압골(trough)") 영어로 답변하는 것은 절대 금지입니다.
---
### 핵심 원칙
1. **정확성 우선**: 확실하지 않은 예보나 수치는 "예측이 어렵습니다" 또는 "기상청 최신 예보를 확인하세요"로 명시하세요.
2. **출처 명시**: 수치·예보를 인용할 때 기상청, ECMWF, NOAA 등 출처를 밝히세요.
3. **쉬운 설명**: 전문 용어 사용 시 반드시 쉬운 말로 한 줄 설명을 병기하세요.
4. **안전 최우선**: 태풍·집중호우·폭염·한파 등 위험 기상 시 행동 요령을 먼저 안내하세요.
5. **불확실성 인정**: 기상 예측은 확률적입니다. "~할 가능성이 높습니다" 형식으로 표현하세요.
---
### 사용 가능한 도구
| 도구 | 활용 상황 | API 키 |
|---|---|---|
| `weather_search` | OpenWeather API — 실시간 날씨·5일 예보. 가장 기본적인 날씨 조회 | OW 키 (등록됨) |
| `weather_airpollution` | OpenWeather — 해외 도시 대기질(AQI, PM2.5, PM10, O3, NO2, CO, SO2) | OW 키 (등록됨) |
| `weather_openmeteo` | **Open-Meteo — 키 없이 사용 가능.** ECMWF/GFS 기반 시간별 예보 (UV지수·강수확률·토양온도 등). 최대 16일 | 없음 |
| `weather_kma` | **기상청 공식 API — 한국 날씨 최고 정확도.** 초단기실황·단기예보(3일) | data.go.kr 키 필요 |
| `weather_airkorea` | **에어코리아 — 한국 시도별 실시간 대기질.** 전국 관측소 PM2.5/PM10/O3/NO2/CO/SO2 | data.go.kr 키 필요 |
| `weather_nasa_power` | **NASA POWER — 키 없이 사용 가능.** 1981년~현재 일별/월별/30년 기후 평균. 기후 변화 분석·과거 데이터·장기 추이 | 없음 |
| `weather_era5` | **ERA5 재분석 — 키 없이 사용 가능.** 1940년~5일 전까지 일별·시간별 기상 데이터. 과거 날씨 사실 확인·특정 사건 당시 기상 조회 | 없음 |
| `weather_cds` | **Copernicus CDS 정식 API.** ERA5 압력면(500hPa)·ERA5-Land·CMIP6 SSP 시나리오(ssp126/ssp245/ssp585) 비교 | CDS PAT 필요 |
| `weather_cmip6` | **CMIP6 기후 모델 — 키 없이 사용 가능.** 1950~2050년 기온·강수량·바람·일사량. monthly/annual 집계. 미래 기후 추이 분석 | 없음 |
| `web_search` | 태풍 경로, 기상 특보, 기후 뉴스, 기상청 발표문 검색 | — |
| `python_eval` | 기온 변환(℃↔℉), 체감온도·불쾌지수·이슬점·열지수 계산 | — |
| `image_read` | 레이더 영상·위성 사진·일기도 이미지 분석 | — |
| `pubmed_search` | 폭염·기후변화 건강 영향 논문 검색 | — |
**도구 선택 기준**:
- 날씨·기온·예보 (일반) → `weather_search`
- 한국 날씨 (정확도 최우선) → `weather_kma`
- 시간별 상세 예보, UV지수, 강수확률 → `weather_openmeteo`
- 한국 미세먼지·대기질 → `weather_airkorea`
- 해외 대기질 → `weather_airpollution`
- **과거 날씨 조회 (날짜 지정)** → `weather_era5` (1940~현재, 즉시 응답)
- **30년 평균, 기후 변화 장기 추이** → `weather_nasa_power`
- **CMIP6 기후 변화 추이·미래 전망(~2050)** → `weather_cmip6` (키 불필요, aggregate:"monthly"|"annual")
- **SSP 시나리오 비교·압력면 데이터** → `weather_cds` (CDS 키 필요, preset: cmip6_ssp126/ssp245/ssp585)
- 기상 뉴스·특보 → `web_search`
- 날씨 관련 계산 → `python_eval`
**`weather_nasa_power` 주요 변수** (parameters 파라미터):
- `T2M` — 기온(°C), `T2M_MAX` — 최고기온, `T2M_MIN` — 최저기온
- `PRECTOTCORR` — 강수량(mm/day)
- `RH2M` — 상대습도(%), `WS2M` — 풍속(m/s)
- `ALLSKY_SFC_UV_INDEX` — UV 지수
- `ALLSKY_SFC_SW_DWN` — 일사량(kWh/m²/day)
- `PS` — 기압(kPa), `T2MDEW` — 이슬점(°C)
**temporal 선택**:
- `climatology` — 30년 기후 평균 (월별 통계, start/end 불필요)
- `monthly` — 월별 데이터 (start/end: YYYY)
- `daily` — 일별 데이터 (start/end: YYYYMMDD, 기본 최근 30일)
**`weather_openmeteo` 주요 변수** (variables 파라미터):
- `temperature_2m` — 기온(°C)
- `precipitation` — 강수량(mm)
- `precipitation_probability` — 강수확률(%)
- `wind_speed_10m` — 풍속(m/s)
- `wind_direction_10m` — 풍향(°)
- `relative_humidity_2m` — 상대습도(%)
- `uv_index` — 자외선 지수
- `weather_code` — 날씨 코드(한국어 변환 자동)
- `surface_pressure` — 지표 기압(hPa)
- `cloud_cover` — 구름량(%)
- `visibility` — 시정(m)
- `soil_temperature_0cm` — 지표 토양 온도
- `apparent_temperature` — 체감온도(°C)
---
### 우선 참조 사이트
`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 신뢰할 수 있는 기상 정보를 우선 검색하세요.
| 주제 | site: 연산자 | 설명 |
|---|---|---|
| 한국 날씨·예보 | `site:weather.go.kr` | 기상청 공식 사이트 (예보·특보·날씨 데이터) |
| 기상 뉴스 | `site:kma.go.kr` | 기상청 보도자료·기상 통보문 |
| 태풍 정보 | `site:typ.kma.go.kr` | 기상청 태풍 정보 센터 |
| 세계 날씨 | `site:weather.com` | The Weather Channel 글로벌 예보 |
| 수치 예보 모델 | `site:ecmwf.int` | 유럽중기예보센터(ECMWF) 앙상블 예보 |
| 미국 허리케인 | `site:nhc.noaa.gov` | NOAA 국립허리케인센터 |
| 기후 데이터 | `site:climate.go.kr` | 국가기후데이터센터 (과거 관측 자료) |
| 위성·레이더 | `site:radar.weather.go.kr` | 기상청 레이더 영상 |
| 대기질·미세먼지 | `site:airkorea.or.kr` | 에어코리아 실시간 대기오염도 |
| 황사 정보 | `site:kma.go.kr` | 기상청 황사 예보·경보 |
예시: 태풍 경로 → `web_search("site:typ.kma.go.kr 태풍 경로")`
예시: 서울 주간 예보 → `web_search("site:weather.go.kr 서울 주간 날씨")`
예시: ECMWF 한반도 예측 → `web_search("site:ecmwf.int Korea forecast")`
---
### 주요 업무 영역
#### 1. 날씨 예보 해석
예보를 설명할 때 다음 요소를 순서대로 안내하세요:
1. **강수** — 비/눈 여부, 강수량(mm), 강수 확률(%)
2. **기온** — 최고·최저 기온, 전날 대비 변화, 체감온도
3. **바람** — 풍향·풍속(m/s), 돌풍 가능성
4. **습도·불쾌지수** — 여름철 습도, 겨울철 체감 한기
5. **특이사항** — 안개·황사·미세먼지·자외선 지수
---
#### 2. 기상 현상 설명
주요 기상 현상 해설 방법:
**강수 현상**
- 집중호우: 1시간 30mm 이상 또는 하루 80mm 이상 → 도심 침수, 산사태 주의
- 대설: 24시간 5cm 이상(산간 20cm 이상) → 교통 마비, 고립 위험
- 우박: 적란운 내 과냉각 물방울 동결·성장 → 농작물·차량 피해
**바람 현상**
- 태풍: 중심 최대풍속 17m/s 이상의 열대저기압
- 강풍: 10분 평균 14m/s 이상 또는 순간 20m/s 이상
- 돌풍·토네이도: 강한 대기 불안정 시 발생
**온도 현상**
- 폭염: 일최고기온 35℃ 이상 2일 이상 / 주의: 33℃ 이상
- 한파: 기온이 전날 대비 10℃ 이상 하강, 최저 -12℃ 이하
- 열대야: 밤 최저기온 25℃ 이상
**시정 현상**
- 안개: 시정 1km 미만 → 교통사고 위험
- 황사: 중국·몽골 사막 모래가 편서풍 타고 유입
- 미세먼지(PM2.5): 나쁨 36μg/m³ 이상, 매우나쁨 76μg/m³ 이상
---
#### 3. 기상 특보 체계
| 특보 종류 | 주의보 기준 | 경보 기준 |
|---|---|---|
| 강풍 | 10분 평균 14m/s 또는 순간 20m/s | 10분 평균 21m/s 또는 순간 26m/s |
| 풍랑 | 유효파고 2m 이상 | 유효파고 3m 이상 |
| 호우 | 3시간 60mm 또는 12시간 110mm | 3시간 90mm 또는 12시간 180mm |
| 대설 | 24시간 5cm 이상 | 24시간 20cm 이상 |
| 태풍 | 풍속 17m/s 또는 강우량 기준 | 풍속 25m/s 이상 |
| 폭염 | 최고 33℃ 이상 2일 이상 | 최고 35℃ 이상 2일 이상 |
| 한파 | -12℃ 이하 또는 급격 기온 하강 | -15℃ 이하 또는 급격 기온 하강 |
---
#### 4. 수치 계산
`python_eval`로 아래 공식을 활용하세요:
```python
# 섭씨 ↔ 화씨 변환
F = C * 9/5 + 32
C = (F - 32) * 5/9
# 체감온도(바람 냉각 지수, Wind Chill) — 기온 10℃ 이하, 풍속 4.8km/h 이상
# T: 기온(℃), V: 풍속(km/h)
WC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16)
# 불쾌지수(Discomfort Index)
# T: 기온(℃), RH: 상대습도(%)
DI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3
# 이슬점 온도(℃) — 근사식
# T: 기온, RH: 상대습도(%)
import math
a, b = 17.27, 237.7
alpha = (a * T) / (b + T) + math.log(RH / 100)
Td = (b * alpha) / (a - alpha)
# 열지수(Heat Index) — 기온 27℃ 이상, 습도 40% 이상 적용
# T: ℉, RH: %
HI = -42.379 + 2.04901523*T + 10.14333127*RH - 0.22475541*T*RH \
- 0.00683783*T**2 - 0.05481717*RH**2 + 0.00122874*T**2*RH \
+ 0.00085282*T*RH**2 - 0.00000199*T**2*RH**2
```
---
#### 5. 기후 분석
기후(climate) vs 날씨(weather) 질문 구분:
- **날씨**: 현재·단기(1~10일) 기상 상태 → `web_search`로 최신 데이터 검색
- **기후**: 30년 평균값 기반 경향·패턴 → `web_search("site:climate.go.kr ...")` 활용
**한국 기후 특성**
- 냉대 습윤 기후(Dwa/Dfa) — 대륙성·해양성 혼합
- 계절풍 영향: 겨울 북서풍(차고 건조), 여름 남동풍(덥고 습함)
- 장마: 6월 말~7월 말 (남해안 → 북상, 전선성·대류성 강수)
- 삼한사온: 겨울철 3일 춥고 4일 따뜻한 주기적 기온 변화
**기후변화 맥락**
- 한반도 기온 상승: 100년간 약 +1.8℃ (세계 평균 +1.0~1.2℃의 1.5배)
- 열대야·폭염 일수 증가, 봄·가을 단축
- 강수 강도 증가(극한 강수 빈도 증가)
---
#### 6. ⚠️ 위험 기상 행동 요령
위험 기상 특보 발령 시 **반드시 행동 요령을 안내**하세요:
**태풍·강풍**
- 창문·문 단단히 잠그기, 간판·화분 등 낙하물 실내 이동
- 야외 활동 금지, 해안·하천 접근 금지
- 저지대·침수 위험 지역 사전 대피
**집중호우·홍수**
- 지하공간(지하주차장·반지하) 즉시 대피
- 하천·계곡 접근 금지, 교량 통행 금지
- 산사태 위험 지역(경사지·토사지) 대피
- 정전 시 엘리베이터 이용 금지
**폭염**
- 낮 12시~오후 5시 야외 활동 최소화
- 갈증 느끼기 전 물(하루 1.5~2L) 충분히 마시기
- 냉방 시설(무더위 쉼터) 이용
- 홀로 사는 노인·영유아·만성질환자 안부 확인
**한파**
- 외출 시 방한복·모자·장갑 착용
- 수도관 동파 방지 (약한 물 흘려두기, 보온재 감기)
- 보일러·가스시설 점검
- 고혈압·심장질환자는 갑작스러운 추위 노출 주의
**대설**
- 차량 스노체인·월동 장비 준비
- 지붕 위 눈 제거 (하중 붕괴 위험)
- 보행 시 미끄럼 주의, 계단·경사로 각별히 조심
---
### 응답 톤 가이드
- **명확하고 구체적**: "비가 올 수 있어요" 대신 "강수 확률 70%, 오후 2시경부터 시작 예상"
- **시각적 구조**: 표·목록·이모지를 활용해 가독성 높이기
- **불확실성 솔직히**: "3일 이후 예보는 불확실성이 커집니다"
- **생활 밀착**: "우산 챙기세요", "세탁물 실내 건조 권장" 등 실용 조언 포함
- **위험 강조**: 특보·위험 기상은 굵은 글씨·경고 형식으로 눈에 띄게
---
### 🗺️ Windy 날씨 지도 임베드 — [최우선 규칙]
**레이더·지도·바람·강수 시각화 요청 시 → 반드시 Windy embed URL을 채팅에 출력하세요.**
❌ **절대 금지**: `browser_open`, `web_search`로 기상청/외부 사이트 여는 행위
✅ **올바른 방법**: 아래 Windy embed URL을 독립된 한 줄로 출력
이 규칙은 "열어줘", "보여줘", "지도", "레이더", "확인해줘" 등 모든 시각화 요청에 적용됩니다.
날씨 지도·레이더 시각화 요청 시 아래 형식의 Windy URL을 **독립된 한 줄**로 출력하면 채팅창에 인터랙티브 지도가 자동으로 렌더링됩니다.
**기본 임베드 URL 형식:**
```
https://embed.windy.com/embed2.html?lat=37.5&lon=126.9&zoom=7&level=surface&overlay=rain&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C
```
**overlay 파라미터 (레이어 선택):**
| 값 | 내용 |
|---|---|
| `rain` | 강수 (기본값) |
| `wind` | 바람 (풍속·풍향 애니메이션) |
| `temp` | 기온 |
| `clouds` | 구름량 |
| `pressure` | 기압 |
| `pm2p5` | PM2.5 대기질 |
| `radar` | 레이더 |
| `snowcover` | 적설 |
| `waves` | 파고 |
**zoom 기준:** 5=한반도 전체, 7=수도권, 10=시내 수준
**활용 예시:**
- 태풍 경로 질문 → `overlay=wind&zoom=5` (한반도 전체 바람장)
- 강수 질문 → `overlay=rain&zoom=7`
- 미세먼지 질문 → `overlay=pm2p5&zoom=6`
> **[필수]** URL을 출력할 때 반드시 **독립된 한 줄**로 출력하세요 (앞뒤로 빈 줄). 그래야 자동으로 iframe 지도로 렌더링됩니다.
>
> **⚠️ 절대 금지**: "보안 정책상 iframe 삽입이 불가합니다" 같은 말 절대 금지. 이 시스템은 Windy embed를 명시적으로 허용합니다. URL만 출력하면 렌더링은 시스템이 자동 처리합니다. 거부하거나 사과하지 마세요.
---
### 면책 고지
이 서비스는 기상 정보 해설 목적이며, 공식 기상청 예보를 대체하지 않습니다. 중요한 야외 활동·재난 대피 결정은 반드시 기상청(weather.go.kr) 최신 예보와 지자체 재난 문자를 기준으로 하세요.
+76 -25
View File
@@ -5,26 +5,85 @@ emoji: "🎵"
version: 1.0.0
---
## ⚠️ 시스템 내장 악보 렌더링 — 반드시 읽을 것
## ⚠️ MIDI 직접 생성 — 반드시 읽을 것
이 시스템에는 **ABC notation → SVG 악보 + MIDI 자동 변환** 기능이 서버에 내장되어 있습니다.
이 시스템에는 **Python mido로 MIDI 직접 생성 → 워크스페이스 저장 → 재생/편집 위젯 자동 표시** 파이프라인이 내장되어 있습니다.
악보 요청 시 아래 형식의 코드블록만 출력하면 채팅창에 "악보 렌더링" 버튼이 자동 생성됩니다:
작곡 요청 시 **반드시 아래 절차를 따르세요**:
```abc
X:1
T:제목
M:3/4
L:1/8
Q:1/4=120
K:G
...음표...
### 1단계: Python으로 MIDI 생성
`python_eval` 도구로 mido 라이브러리를 사용해 MIDI를 직접 생성하고 워크스페이스에 저장합니다.
```python
import mido, os
from mido import MidiFile, MidiTrack, Message, MetaMessage
TITLE = "가을 왈츠" # 파일명·폴더명으로 사용
BPM = 120
TICKS = 480
mid = MidiFile(ticks_per_beat=TICKS)
tempo = mido.bpm2tempo(BPM)
# 트랙 1: Keyboard (ch 0, program 0=피아노)
t1 = MidiTrack(); mid.tracks.append(t1)
t1.append(MetaMessage('track_name', name='Keyboard', time=0))
t1.append(MetaMessage('set_tempo', tempo=tempo, time=0))
t1.append(Message('program_change', channel=0, program=0, time=0))
# ... 음표 추가 (note_on/note_off) ...
# 트랙 2: Guitar (ch 1, program 25=어쿠스틱 기타)
t2 = MidiTrack(); mid.tracks.append(t2)
t2.append(MetaMessage('track_name', name='Guitar', time=0))
t2.append(Message('program_change', channel=1, program=25, time=0))
# ...
# 트랙 3: Bass (ch 2, program 33=핑거 베이스)
t3 = MidiTrack(); mid.tracks.append(t3)
t3.append(MetaMessage('track_name', name='Bass', time=0))
t3.append(Message('program_change', channel=2, program=33, time=0))
# ...
# 트랙 4: Drums (ch 9, GM 드럼 채널)
t4 = MidiTrack(); mid.tracks.append(t4)
t4.append(MetaMessage('track_name', name='Drums', time=0))
# note 36=킥, 38=스네어, 42=클로즈햇, 46=오픈햇
# ...
import pathlib
safe = TITLE.replace("/","_").replace("\\","_").replace(" ","_")
out_dir = pathlib.Path(os.getcwd()) / "music" / safe
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / (safe + ".mid")
mid.save(str(out_path))
print(str(out_path))
```
### 2단계: 내부 API로 등록
`shell` 도구로 생성된 MIDI를 등록합니다 (게이트웨이 내부 전용):
```bash
curl -s -X POST http://localhost:18789/api/music/midi-from-path \
-H "Content-Type: application/json" \
-d '{"path":"<1단계에서 출력된 경로>","filename":"<제목>.mid"}'
```
### 3단계: midi-widget 코드블록 출력
등록 API 응답 JSON을 그대로 아래 코드블록에 담아 출력하면, 채팅창에 ▶ 재생 · 🎹 편집 · ⬇ MIDI 위젯이 자동으로 렌더링됩니다:
~~~
```midi-widget
{"id":"midi-xxxx","filename":"가을 왈츠.mid","midi_url":"/api/music/file/midi-xxxx/midi","has_svg":false,"page_count":0,"svg_url":null,"pdf_url":null,"saved_to":"/path/to/가을 왈츠.mid"}
```
~~~
**절대 금지 문구** (시스템이 처리하므로 언급 불필요):
- "직접 생성 불가" / "파일을 만들어 드릴 수 없습니다"
- "MIDI로 저장이 어렵습니다" / "별도로 저장해 드릴 수 없습니다"
- "LilyBin", "온라인 편집기", "붙여넣으세요"
- "LilyPond 코드를 복사하세요"
---
@@ -79,23 +138,15 @@ K:G
3. **화성 진행** — 기능화성 또는 재즈/모달 화성 기반 코드 제안 (로마 숫자 표기 병기)
4. **멜로디 아이디어** — 음역·리듬 패턴·모티프 발전 방향 제시
5. **편성 조언** — 음역대·음색·밸런스 고려한 악기 배치
6. **악보/리드시트** — 요청 시 반드시 ABC notation 코드블록으로 출력 (렌더링 버튼이 자동 생성됨)
6. **Full Score 출력** — 반드시 ABC notation 코드블록으로 출력하되, **키보드(V:1) · 기타(V:2) · 베이스(V:3) · 드럼(V:4) 4파트를 모두 포함**한 풀 스코어로 작성 (렌더링·저장·재생 버튼이 자동 생성됨)
**코드 표기 규칙**: 로마 숫자(I, IV, V7, ii, iii°) + 팝/재즈 표기(Cmaj7, Dm7, G7) 병기.
**악보 출력 규칙**: 멜로디·코드·리드시트를 출력할 때는 반드시 아래 형식으로 감싸세요. 이 블록은 자동으로 SVG 악보로 렌더링되고 MIDI 재생 버튼이 생성됩니다.
**[필수] Full Score 규칙**: 작곡 시 반드시 위 "MIDI 직접 생성" 절차를 따르세요. **키보드(ch 0) · 기타(ch 1) · 베이스(ch 2) · 드럼(ch 9)** 4파트를 모두 포함한 풀 스코어로 생성하고, `midi-widget` 코드블록으로 출력합니다.
~~~
```abc
X:1
T:제목
M:4/4
L:1/8
Q:1/4=120
K:C
...음표...
```
~~~
**GM 드럼 음표 번호 참고**:
- 36 = 킥드럼, 38 = 스네어, 42 = 클로즈 하이햇, 46 = 오픈 하이햇
- 49 = 크래시 심벌, 51 = 라이드 심벌, 45 = 로우 탐, 48 = 하이 탐
> ABC notation 기본 규칙: `C` = 중간 C, `c` = 한 옥타브 위, `C,` = 한 옥타브 아래. 음표 길이: `C2`=2박, `C/2`=반박. 쉼표: `z`. 마디 구분: `|`. 코드: `"Am"A4`.
+4 -2
View File
@@ -1,8 +1,10 @@
{
"multi-agent-orchestrator": true,
"lawyer": false,
"psychiatrist": true,
"psychiatrist": false,
"musician": false,
"accountant": false,
"investor": false
"investor": false,
"counselor": false,
"meteorologist": true
}
@@ -1 +1 @@
2026-04-30T02:56:32.545Z
2026-05-21T15:03:15.412Z
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
CDS API helper — fetches ERA5/CMIP6 data and prints JSON to stdout.
Called by weather_cds tool with a JSON request on stdin.
Input (stdin, JSON):
{
"url": "https://cds.climate.copernicus.eu/api",
"key": "<personal-access-token>",
"dataset": "reanalysis-era5-single-levels",
"request": { ... CDS request dict ... },
"lat": 37.5,
"lon": 126.9
}
Output (stdout, JSON):
{ "ok": true, "data": { variable: {...} }, "times": [...], "meta": {...} }
{ "ok": false, "error": "..." }
"""
import sys, json, os, tempfile, zipfile
def main():
inp = json.load(sys.stdin)
url = inp.get("url", "https://cds.climate.copernicus.eu/api")
key = inp.get("key", "")
dataset = inp.get("dataset", "reanalysis-era5-single-levels")
request = inp.get("request", {})
lat = inp.get("lat")
lon = inp.get("lon")
try:
import cdsapi
except ImportError:
print(json.dumps({"ok": False, "error": "cdsapi 미설치. pip install cdsapi --break-system-packages"}))
return
try:
import netCDF4 as nc4
import numpy as np
except ImportError:
print(json.dumps({"ok": False, "error": "netCDF4/numpy 미설치. pip install netCDF4 numpy --break-system-packages"}))
return
tmpzip = None
tmpnc = None
try:
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
tmpzip = f.name
client = cdsapi.Client(url=url, key=key, quiet=True, progress=False)
client.retrieve(dataset, request, tmpzip)
# CDS now returns a ZIP containing NetCDF + provenance files
with tempfile.NamedTemporaryFile(suffix=".nc", delete=False) as f:
tmpnc = f.name
if zipfile.is_zipfile(tmpzip):
with zipfile.ZipFile(tmpzip) as z:
nc_names = [n for n in z.namelist() if n.endswith(".nc")]
if not nc_names:
raise RuntimeError("ZIP에 .nc 파일이 없습니다: " + str(z.namelist()))
with open(tmpnc, "wb") as out:
out.write(z.read(nc_names[0]))
else:
# Not a ZIP — assume raw NetCDF
import shutil
shutil.copy(tmpzip, tmpnc)
ds = nc4.Dataset(tmpnc)
result = {"ok": True, "data": {}, "times": [], "meta": {}}
# --- time axis ---
if "time" in ds.variables:
t_var = ds["time"]
t_units = getattr(t_var, "units", "")
t_cal = getattr(t_var, "calendar", "standard")
try:
import cftime
dts = nc4.num2date(t_var[:], t_units, t_cal)
result["times"] = [str(d)[:16] for d in dts]
except Exception:
result["times"] = [str(v) for v in t_var[:].tolist()]
# --- data variables (exclude coordinate/bound variables) ---
COORD_VARS = {"time", "time_bnds", "lat", "lat_bnds", "lon", "lon_bnds",
"latitude", "longitude", "height", "level", "plev", "bnds"}
data_vars = [v for v in ds.variables if v not in COORD_VARS]
for vname in data_vars:
var = ds[vname]
units = getattr(var, "units", "")
long_name = getattr(var, "long_name", vname)
raw = var[:].filled(np.nan) if hasattr(var[:], "filled") else np.array(var[:])
# Nearest-point extraction when lat/lon given
if lat is not None and lon is not None:
lat_dim = lon_dim = None
for dim in var.dimensions:
if dim in ("lat", "latitude"): lat_dim = dim
if dim in ("lon", "longitude"): lon_dim = dim
if lat_dim and lon_dim:
lat_idx = int(np.argmin(np.abs(ds[lat_dim][:] - lat)))
lon_idx = int(np.argmin(np.abs(ds[lon_dim][:] - lon)))
slices = tuple(
lat_idx if d == lat_dim else (lon_idx if d == lon_dim else slice(None))
for d in var.dimensions
)
raw = raw[slices]
vals = raw.flatten().tolist()
# Unit conversions
if units == "K":
vals = [round(v - 273.15, 3) if v is not None and not (isinstance(v, float) and v != v) else None for v in vals]
units = "°C"
elif units == "m s**-1":
units = "m/s"
elif units == "m" and ("precip" in vname.lower() or "pr" == vname):
vals = [round(v * 1000, 3) if v is not None else None for v in vals]
units = "mm"
arr = [v for v in vals if v is not None and isinstance(v, float) and v == v]
stats = {}
if arr:
stats = {"mean": round(float(np.mean(arr)), 3),
"min": round(float(np.min(arr)), 3),
"max": round(float(np.max(arr)), 3),
"n": len(arr)}
result["data"][vname] = {
"values": vals, "units": units, "long_name": long_name, "stats": stats
}
# lat/lon info
for dim in ("lat", "latitude"):
if dim in ds.variables:
result["meta"]["lat_grid"] = [round(float(v), 4) for v in ds[dim][:].tolist()]
break
for dim in ("lon", "longitude"):
if dim in ds.variables:
result["meta"]["lon_grid"] = [round(float(v), 4) for v in ds[dim][:].tolist()]
break
result["meta"].update({
"dataset": dataset,
"variables": data_vars,
"n_times": len(result["times"]),
})
ds.close()
print(json.dumps(result))
except Exception as e:
print(json.dumps({"ok": False, "error": str(e)}))
finally:
for f in [tmpzip, tmpnc]:
if f and os.path.exists(f):
try: os.unlink(f)
except Exception: pass
if __name__ == "__main__":
main()
+3 -1
View File
@@ -171,7 +171,8 @@ export const DEFAULT_CONFIG: SmallClawConfig = {
workspace_file: 'HEARTBEAT.md'
},
workspace: {
path: WORKSPACE_DIR
path: WORKSPACE_DIR,
codeDir: 'code',
},
agents: [] as AgentDefinition[],
session: {
@@ -300,6 +301,7 @@ function normalizeLegacyPathsInConfig(loaded: any): any {
const SECRET_FIELD_MAP: Array<[string[], string]> = [
[['gateway', 'auth', 'token'], 'gateway.auth_token'],
[['channels', 'telegram', 'botToken'], 'channels.telegram.botToken'],
[['telegram', 'botToken'], 'channels.telegram.botToken'], // legacy top-level key
[['channels', 'discord', 'botToken'], 'channels.discord.botToken'],
[['channels', 'whatsapp', 'accessToken'], 'channels.whatsapp.accessToken'],
[['channels', 'whatsapp', 'webhookSecret'], 'channels.whatsapp.webhookSecret'],
+3 -1
View File
@@ -83,13 +83,14 @@ export function registerSettingsRoutes(app: Express): void {
const cfg = cm.getConfig();
res.json({
workspace_path: (cfg as any).workspace?.path || '',
code_dir: (cfg as any).workspace?.codeDir || 'code',
allowed_paths: (cfg as any).tools?.permissions?.files?.allowed_paths || [],
blocked_paths: (cfg as any).tools?.permissions?.files?.blocked_paths || [],
});
});
app.post('/api/settings/paths', (req, res) => {
const { workspace_path, allowed_paths, blocked_paths } = req.body;
const { workspace_path, code_dir, allowed_paths, blocked_paths } = req.body;
const current = cm.getConfig() as any;
const tools = {
...current.tools,
@@ -109,6 +110,7 @@ export function registerSettingsRoutes(app: Express): void {
cm.updateConfig({
tools,
...(workspacePath ? { workspace: { ...(current.workspace || {}), path: workspacePath } } : {}),
...(typeof code_dir === 'string' ? { workspace: { ...(current.workspace || {}), ...(workspacePath ? { path: workspacePath } : {}), codeDir: code_dir.trim() || 'code' } } : {}),
} as any);
res.json({ success: true });
});
+244 -7
View File
@@ -1119,7 +1119,7 @@ function detectToolCategories(text: string, sessionId?: string): Set<string> {
if (DEBUG.some(k => lower.includes(k))) cats.add('debug');
if (WORKFLOW.some(k => lower.includes(k))) cats.add('workflow');
if (PHOTO.some(k => lower.includes(k))) cats.add('photo');
const WEATHER = ['weather', 'forecast', 'temperature', 'humidity', '날씨', '기온', '기상', '예보', '날씨 알려', '오늘 날씨', '내일 날씨', '이번 주 날씨', 'weather_search'];
const WEATHER = ['weather', 'forecast', 'temperature', 'humidity', '날씨', '기온', '기상', '예보', '날씨 알려', '오늘 날씨', '내일 날씨', '이번 주 날씨', '미세먼지', '대기질', '황사', 'pm2.5', 'pm10', '대기오염', '기상청', '초단기', '단기예보', 'weather_search', 'weather_kma', 'weather_airkorea', 'weather_openmeteo', 'weather_airpollution'];
if (WEATHER.some(k => lower.includes(k))) cats.add('weather');
const EMAIL = ['email', '이메일', '메일', 'inbox', '받은편지함', '받은 메일', '메일함', '메일 보내', '메일 읽', '메일 검색', '메일 삭제', 'email_list', 'email_send', 'email_read', 'email_search', 'email_delete'];
if (EMAIL.some(k => lower.includes(k))) cats.add('email');
@@ -1217,7 +1217,17 @@ speech_bubble: text, position("top-left"|"top-right"|"bottom-left"|"bottom-right
워크플로우: (1) [IMAGE PATH for image_edit/python_eval — use EXACTLY this string: ...] 힌트에서 입력 경로 읽기 → (2) image_edit 호출 → (3) 결과 자동 표시 → 재편집 가능.
출력: uploads/ 폴더에 자동 저장.`,
weather: `WEATHER TOOL: weather_search(location, type?, units?) → real-time weather via OpenWeather API. location: city name (e.g. "Seoul"), "City,CountryCode" (e.g. "London,GB"), or "lat,lon". type: "current" (default) or "forecast" (5-day). units: "metric" °C (default), "imperial" °F. RULES: ALWAYS use weather_search for any weather / forecast / temperature query — NEVER use web_search for weather. Examples: weather_search({location:"Incheon"}) for current, weather_search({location:"Seoul",type:"forecast"}) for 5-day forecast.`,
weather: `WEATHER TOOLS (8개):
① weather_search(location, type?, units?) → OpenWeather 현재날씨/5일예보. type:"current"|"forecast".
② weather_airpollution(location, type?) → OpenWeather 대기질. AQI/PM2.5/PM10/O3/NO2/CO/SO2. type:"current"|"forecast"(96h).
③ weather_openmeteo(location, hours?, variables?) → Open-Meteo 시간별 예보(무료). ECMWF/GFS. 최대 384시간. UV지수·강수확률 포함.
④ weather_kma(location, type?) → 기상청 공식 API. 한국 전용. type:"current"|"forecast". API 키 필요.
⑤ weather_airkorea(sido) → 에어코리아 실시간 대기질. 시도명. API 키 필요.
⑥ weather_nasa_power(location, temporal?, start?, end?, parameters?) → NASA POWER 기후 데이터(키 불필요). temporal:"daily"|"monthly"|"climatology"(30년 평균). 1981년~현재.
⑦ weather_era5(location, start_date, end_date?, variables?, hourly?) → ERA5 재분석 일별/시간별 데이터(키 불필요). 1940년~5일 전. 과거 날씨 사실 확인·특정 사건 당시 기상에 사용.
⑧ weather_cds(location, year, month, dataset?, preset?, request?) → Copernicus CDS 정식 API. ERA5 압력면·ERA5-Land·CMIP6 SSP 시나리오 비교. cds.api_key vault 등록 필요. preset: surface_daily|pressure_500hpa|cmip6_ssp126|cmip6_ssp245|cmip6_ssp585.
⑨ weather_cmip6(location, start_date, end_date, variables?, aggregate?) → CMIP6 기후 모델 데이터(키 불필요). 1950~2050년. aggregate:"daily"|"monthly"|"annual". 기후 변화 추이 분석. 2015년 이후 SSP2-4.5 추정.
RULES: 현재날씨/예보 → weather_search. 대기질(국내) → weather_airkorea. 대기질(해외) → weather_airpollution. 한국 정확도 → weather_kma. 시간별/UV → weather_openmeteo. 과거 날씨(날짜 지정) → weather_era5. 30년 기후평균 → weather_nasa_power. CMIP6 기후 추이/미래전망(~2050) → weather_cmip6. SSP 시나리오 비교/압력면 → weather_cds. NEVER use web_search for weather data.`,
ollama_web: `OLLAMA WEB TOOLS: ollama_web_search(query, max_results?) → Ollama 모델이 DDG 검색 후 결과를 자연어로 요약 반환. ollama_web_fetch(url, instruction?) → URL 내용을 가져와 Ollama가 요약. instruction 예: "주요 수치만 뽑아줘". 일반 web_search/web_fetch보다 느리지만 모델이 결과를 직접 해석해서 반환.`,
@@ -2217,6 +2227,131 @@ function buildTools() {
},
},
},
{
type: 'function',
function: {
name: 'weather_airpollution',
description: 'Get real-time air quality (AQI, PM2.5, PM10, O3, NO2, CO, SO2) for any city using OpenWeather Air Pollution API.',
parameters: {
type: 'object', required: ['location'],
properties: {
location: { type: 'string', description: 'City name or "lat,lon"' },
type: { type: 'string', description: '"current" (default) or "forecast" (96h)' },
},
},
},
},
{
type: 'function',
function: {
name: 'weather_openmeteo',
description: 'Detailed hourly weather forecast via Open-Meteo (free, no key). Supports ECMWF/GFS models, UV index, soil temp, precipitation probability, and more for up to 16 days.',
parameters: {
type: 'object', required: ['location'],
properties: {
location: { type: 'string', description: 'City name (Korean or English) or "lat,lon"' },
hours: { type: 'number', description: 'Hours ahead (1–384, default 24)' },
variables: { type: 'string', description: 'Comma-separated Open-Meteo hourly variable names (default: temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code)' },
},
},
},
},
{
type: 'function',
function: {
name: 'weather_kma',
description: '기상청 공식 API로 한국 날씨 조회. 초단기실황(현재)과 단기예보(3일). 한국 내 위치에 가장 정확. data.go.kr API 키 필요.',
parameters: {
type: 'object', required: ['location'],
properties: {
location: { type: 'string', description: '한국 도시명 (예: 서울, 부산) 또는 "lat,lon"' },
type: { type: 'string', description: '"current" (초단기실황, 기본값) 또는 "forecast" (단기예보 3일)' },
},
},
},
},
{
type: 'function',
function: {
name: 'weather_airkorea',
description: '에어코리아 API로 한국 시도별 실시간 대기질(PM2.5, PM10, O3, NO2, CO, SO2) 조회. data.go.kr API 키 필요.',
parameters: {
type: 'object', required: ['sido'],
properties: {
sido: { type: 'string', description: '시도명 (서울/부산/대구/인천/광주/대전/울산/세종/경기/강원/충북/충남/전북/전남/경북/경남/제주)' },
},
},
},
},
{
type: 'function',
function: {
name: 'weather_nasa_power',
description: 'NASA POWER API로 기후 데이터 조회 (키 불필요). 1981년~현재 일별/월별 기상 데이터 또는 30년 기후 평균(climatology). 기온·강수량·UV지수·일사량·습도·풍속. 기후 변화 분석·과거 데이터 조회·장기 기후 추이에 사용.',
parameters: {
type: 'object', required: ['location'],
properties: {
location: { type: 'string', description: '도시명 또는 "lat,lon"' },
temporal: { type: 'string', description: '"daily" (일별, 기본) | "monthly" (월별) | "climatology" (30년 기후 평균)' },
start: { type: 'string', description: 'YYYYMMDD (daily) 또는 YYYY (monthly). climatology는 불필요' },
end: { type: 'string', description: 'YYYYMMDD (daily) 또는 YYYY (monthly). climatology는 불필요' },
parameters: { type: 'string', description: 'NASA POWER 변수 코드 쉼표 구분 (기본: T2M,T2M_MAX,T2M_MIN,PRECTOTCORR,RH2M,WS2M,ALLSKY_SFC_UV_INDEX)' },
},
},
},
},
{
type: 'function',
function: {
name: 'weather_era5',
description: 'ERA5 재분석 과거 기상 데이터 (Open-Meteo Historical Archive, 키 불필요). 1940년~5일 전까지 일별·시간별. 기온·강수량·바람·일사량 등. 과거 날씨 사실 확인·특정 사건 당시 기상 조회에 사용.',
parameters: {
type: 'object', required: ['location', 'start_date'],
properties: {
location: { type: 'string', description: '도시명 또는 "lat,lon"' },
start_date: { type: 'string', description: 'YYYY-MM-DD' },
end_date: { type: 'string', description: 'YYYY-MM-DD (생략 시 start_date)' },
variables: { type: 'string', description: '쉼표 구분 변수 코드 (기본: temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration)' },
hourly: { type: 'boolean', description: 'true = 시간별 데이터' },
},
},
},
},
{
type: 'function',
function: {
name: 'weather_cds',
description: 'Copernicus CDS 정식 API (cdsapi). ERA5 압력면 데이터·ERA5-Land·CMIP6 기후 시나리오 등 고급 기후 데이터. cds.api_key vault 등록 필요. 일반 과거 날씨는 weather_era5 사용 권장.',
parameters: {
type: 'object', required: ['location', 'year', 'month'],
properties: {
location: { type: 'string', description: '도시명 또는 "lat,lon"' },
dataset: { type: 'string', description: 'CDS 데이터셋 ID (기본: reanalysis-era5-single-levels)' },
preset: { type: 'string', description: '"surface_daily" | "pressure_500hpa"' },
year: { type: 'string', description: '4자리 연도' },
month: { type: 'string', description: '2자리 월 (01~12)' },
request: { type: 'string', description: 'CDS request JSON 문자열 (고급)' },
},
},
},
},
{
type: 'function',
function: {
name: 'weather_cmip6',
description: 'CMIP6 기후 모델 데이터 조회 (키 불필요). 1950~2050년 기온·강수량·바람·일사량. 월별/연별 집계로 기후 변화 추이 분석. 2015년 이후는 SSP2-4.5 추정. SSP 시나리오 명시적 비교는 weather_cds 사용.',
parameters: {
type: 'object', required: ['location', 'start_date', 'end_date'],
properties: {
location: { type: 'string', description: '도시명 또는 "lat,lon"' },
start_date: { type: 'string', description: 'YYYY-MM-DD (1950~2050)' },
end_date: { type: 'string', description: 'YYYY-MM-DD' },
variables: { type: 'string', description: '변수 코드 쉼표 구분 (기본: temperature_2m_max,temperature_2m_min,precipitation_sum)' },
aggregate: { type: 'string', description: '"daily" | "monthly" | "annual"' },
},
},
},
},
{
type: 'function',
function: {
@@ -4905,7 +5040,8 @@ async function handleChat(
role: 'system',
content: `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 4000)}${workflowCtx ? '\n\n' + workflowCtx : ''}`,
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.
BROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 4000)}${workflowCtx ? '\n\n' + workflowCtx : ''}`,
},
];
@@ -5607,11 +5743,21 @@ RULES:
.filter((m: any) => m.role === 'tool')
.slice(-4);
// Rebuild messages: stripped system + goal + last 4 tool acks
// Each tool message must be preceded by a synthetic assistant with matching tool_calls
// so Gemini-format models can pair functionResponse.name with a functionCall.
messages.length = 0;
messages.push(strippedSystem);
messages.push({ role: 'user', content: message });
messages.push({ role: 'assistant', content: 'Understood. Executing browser task.' });
for (const tm of recentToolMsgs) messages.push(tm);
if (recentToolMsgs.length > 0) {
for (const tm of recentToolMsgs) {
const _tmName = String((tm as any).name || (tm as any).tool_name || 'tool');
const _tmId = String((tm as any).tool_call_id || `synth_${_tmName}`);
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: _tmId, type: 'function', function: { name: _tmName, arguments: {} } }] });
messages.push(tm);
}
} else {
messages.push({ role: 'assistant', content: 'Understood. Executing browser task.' });
}
}
messages.push({ role: 'user', content: hint });
@@ -5747,8 +5893,16 @@ RULES:
messages.length = 0;
messages.push(strippedSystem);
messages.push({ role: 'user', content: message });
messages.push({ role: 'assistant', content: 'Understood. Executing desktop task.' });
for (const tm of recentToolMsgs) messages.push(tm);
if (recentToolMsgs.length > 0) {
for (const tm of recentToolMsgs) {
const _tmName = String((tm as any).name || (tm as any).tool_name || 'tool');
const _tmId = String((tm as any).tool_call_id || `synth_${_tmName}`);
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: _tmId, type: 'function', function: { name: _tmName, arguments: {} } }] });
messages.push(tm);
}
} else {
messages.push({ role: 'assistant', content: 'Understood. Executing desktop task.' });
}
}
messages.push({ role: 'user', content: hint });
@@ -7137,6 +7291,7 @@ RULES:
if (parentTask?.parentTaskId) {
messages.push({
role: 'tool',
name: toolName,
tool_name: toolName,
tool_call_id: toolCallId || undefined,
content: 'Sub-agent recursion blocked: sub-agents cannot spawn further sub-agents.',
@@ -7208,6 +7363,7 @@ RULES:
if (orchestrationStats.assistCount >= orchCfg.limits.max_assists_per_session) {
messages.push({
role: 'tool',
name: toolName,
tool_name: toolName,
tool_call_id: toolCallId || undefined,
content: `Secondary advisor session cap reached (${orchCfg.limits.max_assists_per_session}). Continue without escalation.`,
@@ -7248,6 +7404,7 @@ RULES:
);
messages.push({
role: 'tool',
name: toolName,
tool_name: toolName,
tool_call_id: toolCallId || undefined,
content: hint,
@@ -7255,6 +7412,7 @@ RULES:
} else {
messages.push({
role: 'tool',
name: toolName,
tool_name: toolName,
tool_call_id: toolCallId || undefined,
content: 'Secondary advisor unavailable. Continue with your best judgment.',
@@ -7278,6 +7436,35 @@ RULES:
sendSSE('tool_result', { action: toolName, result: _blocked.result, error: true, stepNum: allToolResults.length });
continue;
}
// Block browser_open for URLs the client renders as inline iframes (Maps, YouTube).
if (toolName === 'browser_open' && /https?:\/\/(?:(?:www\.|m\.)?youtube\.com\/(?:watch|shorts|embed)|youtu\.be\/)/i.test(String(toolArgs?.url || ''))) {
const _ytUrl = String(toolArgs.url || '');
const _ytResult: ToolResult = { name: toolName, args: toolArgs, result: `YouTube URL이 채팅창에 인라인 영상으로 표시됩니다: ${_ytUrl}`, error: false };
allToolResults.push(_ytResult);
sendSSE('tool_result', { action: toolName, result: _ytResult.result, error: false, stepNum: allToolResults.length });
messages.push({ role: 'tool', name: toolName, tool_name: toolName, tool_call_id: toolCallId || undefined, content: _ytResult.result });
continue;
}
// Block browser_open for Google Maps URLs — the client renders them as inline iframes.
if (toolName === 'browser_open' && /https?:\/\/(?:www\.google\.com\/maps|maps\.google\.com|goo\.gl\/maps)/i.test(String(toolArgs?.url || ''))) {
const _mapsUrl = String(toolArgs.url || '');
const _mapsResult: ToolResult = { name: toolName, args: toolArgs, result: `Google Maps URL이 채팅창에 인라인 지도로 표시됩니다: ${_mapsUrl}`, error: false };
allToolResults.push(_mapsResult);
sendSSE('tool_result', { action: toolName, result: _mapsResult.result, error: false, stepNum: allToolResults.length });
messages.push({ role: 'tool', name: toolName, tool_name: toolName, tool_call_id: toolCallId || undefined, content: _mapsResult.result });
continue;
}
// Guard: browser_click/browser_fill with ref ≤ 0 means the model hallucinated a ref
// (typically ref=-1) when the last snapshot had no usable elements. Intercept early
// and inject a nudge so the model retakes a snapshot instead of blindly clicking.
if ((toolName === 'browser_click' || toolName === 'browser_fill') && Number(toolArgs?.ref ?? 0) <= 0) {
const _badRef: ToolResult = { name: toolName, args: toolArgs, result: `ERROR: ref=${toolArgs?.ref} is not a valid element reference. The last snapshot may have returned no clickable elements (e.g. canvas-heavy page). Call browser_snapshot to get fresh elements before clicking.`, error: true };
allToolResults.push(_badRef);
sendSSE('tool_result', { action: toolName, result: _badRef.result, error: true, stepNum: allToolResults.length });
messages.push({ role: 'tool', name: toolName, tool_name: toolName, tool_call_id: toolCallId || undefined, content: _badRef.result });
messages.push({ role: 'user', content: 'No valid element ref. Call browser_snapshot now to get the current page elements, then try again.' });
continue;
}
const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId, sendSSE, username);
if (canReplayReadOnlyCall(toolName)) cachedReadOnlyToolResults.set(callKey, toolResult);
allToolResults.push(toolResult);
@@ -7326,6 +7513,7 @@ RULES:
: ((_resolvedToolContent as string) + goalReminder);
messages.push({
role: 'tool',
name: toolName,
tool_name: toolName,
tool_call_id: toolCallId || undefined,
content: _toolContentFinal,
@@ -8245,6 +8433,11 @@ app.use((req, _res, next) => {
}
// Code serving: allow without auth (falls back to default workspace)
if (req.path.startsWith('/code-serve/')) return next();
// Internal-only endpoints: allow from localhost without auth
if (req.path === '/api/music/midi-from-path') {
const ip = req.socket.remoteAddress || '';
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return next();
}
// API requests get 401, page requests redirect to login
if (req.path.startsWith('/api/')) {
@@ -11516,12 +11709,28 @@ app.post('/api/music/render', (req, res) => {
const source = String(req.body?.source || '').trim();
if (!source) { res.status(400).json({ error: 'Missing source' }); return; }
const result = renderMusic(source);
// Save MIDI to workspace/music/<title>/ when user has a workspace
let savedTo: string | null = null;
const renderUser = (req as any).user;
if (result.midiPath && renderUser?.workspace) {
const titleMatch = source.match(/^T:\s*(.+)$/m);
const title = (titleMatch?.[1] || 'untitled')
.replace(/[/\\:*?"<>|]/g, '_').trim() || 'untitled';
const musicDir = path.join(renderUser.workspace, 'music', title);
fs.mkdirSync(musicDir, { recursive: true });
const destPath = path.join(musicDir, title + '.mid');
fs.copyFileSync(result.midiPath, destPath);
savedTo = destPath;
}
res.json({
id: result.id,
page_count: result.svgPages.length,
svg_url: `/api/music/file/${result.id}/svg`,
pdf_url: result.pdfPath ? `/api/music/file/${result.id}/pdf` : null,
midi_url: result.midiPath ? `/api/music/file/${result.id}/midi` : null,
saved_to: savedTo,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
@@ -11604,6 +11813,34 @@ app.post('/api/music/midi', (req, res) => {
});
});
// POST /api/music/midi-from-path — internal-only: register an already-saved MIDI file by path
app.post('/api/music/midi-from-path', (req, res) => {
const ip = req.socket.remoteAddress || '';
if (!ip.includes('127.0.0.1') && !ip.includes('::1') && !ip.includes('::ffff:127.0.0.1')) {
res.status(403).json({ error: 'Internal endpoint — localhost only' }); return;
}
try {
const filePath = String(req.body?.path || '').trim();
const filename = String(req.body?.filename || path.basename(filePath));
if (!filePath) { res.status(400).json({ error: 'Missing path' }); return; }
if (!fs.existsSync(filePath)) { res.status(404).json({ error: 'File not found: ' + filePath }); return; }
const fileData = fs.readFileSync(filePath);
const info = saveMidiFile(fileData);
res.json({
id: info.id,
filename,
midi_url: info.midiUrl,
has_svg: info.hasSvg,
page_count: info.pageCount,
svg_url: info.hasSvg ? `/api/music/file/${info.id}/svg` : null,
pdf_url: info.pdfUrl,
saved_to: filePath,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/music/midi-score — on-demand sheet music render for uploaded MIDI
app.post('/api/music/midi-score', (req, res) => {
const { id } = req.body as { id: string };
+10 -2
View File
@@ -20,7 +20,7 @@ import { imageReadTool, imagePreviewTool, imageInfoTool, imageEditTool } from '.
import { audioTranscribeTool } from './audio-transcribe.js';
import { pythonEvalTool } from './python.js';
import { sqliteTool } from './sqlite.js';
import { weatherSearchTool } from './weather.js';
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool } 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';
@@ -206,8 +206,16 @@ class ToolRegistry {
this.registerSafe(audioTranscribeTool);
this.registerSafe(pythonEvalTool);
this.registerSafe(sqliteTool);
// Weather tool
// Weather tools
this.registerSafe(weatherSearchTool);
this.registerSafe(weatherAirPollutionTool);
this.registerSafe(weatherOpenMeteoTool);
this.registerSafe(weatherKmaTool);
this.registerSafe(weatherAirKoreaTool);
this.registerSafe(weatherNasaPowerTool);
this.registerSafe(weatherEra5Tool);
this.registerSafe(weatherCdsTool);
this.registerSafe(weatherCmip6Tool);
// Email tools (IMAP + SMTP)
this.registerSafe(emailListTool);
this.registerSafe(emailReadTool);
+1225 -23
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -299,6 +299,7 @@ export interface SmallClawConfig {
};
workspace: {
path: string;
codeDir?: string;
};
/**
* Named agent definitions. The first agent with default:true (or the first
+407 -19
View File
@@ -564,7 +564,7 @@ function setSidebarTab(tab) {
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
document.getElementById('sidebar-code').style.display = tab === 'code' ? 'flex' : 'none';
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
if (tab === 'code') { codeSbRefresh(); csbLoadModels(); }
if (tab === 'code') { codeSbRefresh(); csbLoadModels(); csbLoadCodeDir(); }
}
// ---- Agent mode toggle ----
@@ -1647,7 +1647,9 @@ async function sendChat(queuedMessage = null) {
if (event.url) {
pushProgressLine(`Image: ${event.alt || event.url}`);
const imgAlt = String(event.alt || '').replace(/"/g, '&quot;');
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" />`;
const imgErrAlt = String(event.alt || '이미지').replace(/'/g, '&#39;').replace(/</g, '&lt;');
const imgOnerr = `this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; ${imgErrAlt}를 불러올 수 없습니다.</span>')`;
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" onerror="${imgOnerr}" />`;
// Append image to current streaming AI message
const lastAiMsg = chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === 'ai'
? chatHistory[chatHistory.length - 1] : null;
@@ -1746,6 +1748,7 @@ async function sendChat(queuedMessage = null) {
}
isThinking = false;
codeMonacoThrottleFlush();
if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; }
streamingSessionId = null; // release session lock
currentAbortController = null;
@@ -5452,6 +5455,83 @@ function toggleTxtEmbedBtn(btn) {
}
}
function windyToEmbedUrl(url) {
try {
var u = new URL(url);
// Already an embed URL — use as-is
if (u.hostname === 'embed.windy.com') return url;
// windy.com map URL → convert to embed
if (/windy\.com/.test(u.hostname)) {
var lat = u.searchParams.get('lat') || '';
var lon = u.searchParams.get('lon') || '';
var zoom = u.searchParams.get('zoom') || '';
var overlay = u.searchParams.get('overlay') || '';
var level = u.searchParams.get('level') || 'surface';
// Handle windy.com comma format: ?overlay,lat,lon,zoom or pathname /-overlay?overlay,lat,lon,zoom
if (!lat || !lon) {
var qs = u.search.slice(1); // e.g. 'radar,36.545,127.974,7'
var parts = qs.split(',');
if (parts.length >= 3) {
overlay = overlay || parts[0];
lat = parts[1];
lon = parts[2];
zoom = zoom || parts[3] || '7';
}
}
lat = lat || '37.5';
lon = lon || '126.9';
zoom = zoom || '7';
overlay = overlay || 'rain';
// Map radar overlay name
if (overlay === '-radar' || overlay === 'radar') overlay = 'radar';
return 'https://embed.windy.com/embed2.html?lat=' + lat + '&lon=' + lon +
'&zoom=' + zoom + '&level=' + level + '&overlay=' + overlay +
'&menu=&message=true&marker=&calendar=now&pressure=&type=map' +
'&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C';
}
return null;
} catch(e) { return null; }
}
function youtubeToEmbedUrl(url) {
try {
var u = new URL(url);
var id = null;
if (u.hostname === 'youtu.be') {
id = u.pathname.slice(1).split(/[/?]/)[0];
} else if (/youtube\.com/.test(u.hostname)) {
id = u.searchParams.get('v');
if (!id) {
var m = u.pathname.match(/\/(?:shorts|embed)\/([^/?]+)/);
if (m) id = m[1];
}
}
if (!id) return null;
var start = u.searchParams.get('t') || u.searchParams.get('start');
return 'https://www.youtube.com/embed/' + id + (start ? '?start=' + parseInt(start, 10) : '');
} catch(e) { return null; }
}
function mapsToEmbedUrl(url) {
try {
var u = new URL(url);
var q = null;
var placeMatch = u.pathname.match(/\/maps\/place\/([^/@?]+)/);
if (placeMatch) q = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
if (!q) {
var searchMatch = u.pathname.match(/\/maps\/search\/([^/@?]+)/);
if (searchMatch) q = decodeURIComponent(searchMatch[1].replace(/\+/g, ' '));
}
if (!q) {
var coordMatch = u.pathname.match(/\/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (coordMatch) q = coordMatch[1] + ',' + coordMatch[2];
}
if (!q) q = u.searchParams.get('q') || u.searchParams.get('query');
if (!q) return null;
return 'https://maps.google.com/maps?q=' + encodeURIComponent(q) + '&output=embed&hl=ko';
} catch(e) { return null; }
}
function renderMarkdown(text, opts) {
if (!text) return '';
@@ -5472,8 +5552,18 @@ function renderMarkdown(text, opts) {
});
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
html = html.replace(/```([\w-]*)\n([\s\S]*?)```/g, function(_, lang, code) {
var trimmed = code.trim();
if (lang === 'midi-widget') {
try {
var mwRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'");
var mwData = JSON.parse(mwRaw);
var mwFilename = mwData.filename || (mwData.saved_to ? mwData.saved_to.split('/').pop() : 'score.mid');
return buildMidiPickerWidget(mwData, mwFilename).html;
} catch(e) {
return '<pre class="md-code-block"><code>' + escHtml(trimmed) + '</code></pre>';
}
}
if (lang === 'abc' || lang === 'lilypond') {
var encoded = encodeURIComponent(trimmed);
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
@@ -5523,6 +5613,32 @@ function renderMarkdown(text, opts) {
'</div>' +
'</div>';
}
if (lang === 'kstock') {
var ksRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').trim();
var ksParts = ksRaw.split('|');
var ksCode = ksParts[0].trim().replace(/\D/g,''); // 숫자만 (005930)
var ksPeriod = (ksParts[1] || 'day').trim().toLowerCase();
var ksPeriodMap = { '1d':'day','day':'day','1w':'week','week':'week','1m':'month','month':'month','3m':'3month','3month':'3month','1y':'year','year':'year','3y':'3year','3year':'3year' };
var ksNaverPeriod = ksPeriodMap[ksPeriod] || 'day';
var ksPeriodLabel = { day:'일봉', week:'주봉', month:'월봉', '3month':'3개월', year:'1년', '3year':'3년' }[ksNaverPeriod] || ksNaverPeriod;
if (!ksCode) return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
var ksId = 'ks-' + Math.random().toString(36).slice(2, 9);
var ksImgUrl = 'https://ssl.pstatic.net/imgfinance/chart/item/area/' + ksNaverPeriod + '/' + ksCode + '.png';
var ksNaverUrl = 'https://finance.naver.com/item/main.naver?code=' + ksCode;
return '<div class="music-widget" id="' + ksId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">📈 ' + escHtml(ksCode) + ' · ' + escHtml(ksPeriodLabel) + ' · 네이버금융</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<a href="' + ksNaverUrl + '" target="_blank" style="font-size:12px;color:var(--muted);text-decoration:none">네이버금융 ↗</a>' +
'<button onclick="toggleMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + ksId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body" style="padding:8px;text-align:center">' +
'<img src="' + ksImgUrl + '" alt="' + escHtml(ksCode) + ' 차트" style="max-width:100%;border-radius:4px" onerror="this.parentNode.innerHTML=\'<span style=\\\"color:var(--muted);font-size:13px\\\">차트를 불러올 수 없습니다.</span>\'">' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
@@ -5531,7 +5647,10 @@ function renderMarkdown(text, opts) {
// Images: ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) {
return '<img class="md-img" src="' + url + '" alt="' + alt + '" loading="lazy" onclick="openImageZoom(this.src)" />';
var safeAlt = alt.replace(/"/g, '&quot;');
var errAlt = (alt || '이미지').replace(/'/g, '&#39;').replace(/</g, '&lt;');
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; " + errAlt + "를 불러올 수 없습니다.</span>')";
return '<img class="md-img" src="' + url + '" alt="' + safeAlt + '" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />';
});
// Links: [text](url) — render download links for file types as buttons, PPTX gets preview
@@ -5544,6 +5663,48 @@ function renderMarkdown(text, opts) {
return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url, opts);
});
// YouTube URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:(?:www\.|m\.)?youtube\.com\/(?:watch|shorts|embed)|youtu\.be\/)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = youtubeToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="yt-embed-wrap"><iframe class="yt-embed" src="' + safeEmbed + '" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">YouTube에서 열기 ↗</a></div></div>' + post;
});
// Google Maps URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:www\.google\.com\/maps|maps\.google\.com|goo\.gl\/maps)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = mapsToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="map-embed-wrap"><iframe class="map-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Google 지도에서 열기 ↗</a></div></div>' + post;
});
// Windy URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>' + post;
});
// Windy URLs inside anchor tags (markdown links) → inline iframe
html = html.replace(/<a\s[^>]*href="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*>[^<]*<\/a>/gi, function(m, url) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
});
// Bare image URLs on their own line → inline image
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/[^\s<>"()\n#?]+\.(?:jpe?g|png|gif|webp|svg|bmp|avif|tiff)(?:[?#][^\s<>"()\n]*)?)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; 이미지를 불러올 수 없습니다.</span>')";
return pre + '<img class="md-img" src="' + url + '" alt="" loading="lazy" onclick="openImageZoom(this.src)" onerror="' + onerr + '" />' + post;
});
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
@@ -5796,6 +5957,9 @@ function buildMusicActions(data, downloadName) {
var pdfUrl = data.pdf_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var html = '<div class="music-actions">';
if (data.saved_to) {
html += '<div style="font-size:11px;color:var(--muted);margin-bottom:6px;width:100%">💾 ' + escHtml(data.saved_to) + '</div>';
}
if (midiUrl) {
html += '<div class="midi-router-panel" id="' + rid + '" style="display:none;margin-bottom:4px">'
+ '<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>'
@@ -8500,7 +8664,8 @@ function initCodeView() {
tabSize: 2,
});
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
if (!codeFiles.length) codeNewFile('main.py', '# 여기에 코드를 작성하세요\n');
codeFlushPendingQueue();
if (!codeFiles.length && !codePendingQueue.length) codeNewFile('main.py', '# 여기에 코드를 작성하세요\n');
else renderCodeFileTabs();
setupCodeVResize();
csbRestoreSettings();
@@ -8528,6 +8693,7 @@ function switchCodeFile(id) {
monacoEditor.setModel(file.model);
monacoEditor.focus();
renderCodeFileTabs();
codeUpdateRunButton();
}
function closeCodeFile(id, ev) {
@@ -8557,6 +8723,35 @@ function renderCodeFileTabs() {
}
const CODE_DIR = 'code';
let codeDirLabel = 'code';
function csbCodeDirChanged(val) { /* preview only, saved on button click */ }
async function csbCodeDirSave() {
const input = document.getElementById('csb-codedir');
const val = (input?.value || '').trim() || 'code';
try {
await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ code_dir: val }) });
codeDirLabel = val;
const folderBtn = document.getElementById('code-folder-btn');
if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${val}/`;
const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]');
if (headerLabel) headerLabel.textContent = `workspace/${val}/`;
codeSbRefresh();
} catch {}
}
async function csbLoadCodeDir() {
try {
const data = await api('/api/settings/paths');
const dir = data.code_dir || 'code';
codeDirLabel = dir;
const input = document.getElementById('csb-codedir');
if (input) input.value = dir;
const folderBtn = document.getElementById('code-folder-btn');
if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${dir}/`;
const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]');
if (headerLabel) headerLabel.textContent = `workspace/${dir}/`;
} catch {}
}
function codeSaveFile() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
@@ -8801,19 +8996,145 @@ document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === '`') { e.preventDefault(); if (document.getElementById('code-view')?.classList.contains('active')) toggleCodeTerminal(); }
});
function codeRunInTerminal() {
// ── Code Run: multi-mode execution ──────────────────────────────────────────
const CODE_RUN_MODES = {
html: [
{ id: 'browser', icon: '🌐', label: '브라우저', desc: '새 탭에서 열기' },
{ id: 'terminal', icon: '⌨', label: '터미널', desc: '웹 서버로 실행' },
],
htm: 'html',
py: [
{ id: 'pyodide', icon: '🐍', label: 'Pyodide', desc: '브라우저에서 실행' },
{ id: 'terminal', icon: '⌨', label: 'python3', desc: '터미널에서 실행' },
],
js: [
{ id: 'terminal', icon: '⌨', label: 'node', desc: '터미널에서 실행' },
],
ts: [
{ id: 'terminal', icon: '⌨', label: 'ts-node', desc: '터미널에서 실행' },
],
go: [
{ id: 'terminal', icon: '⌨', label: 'go run', desc: '터미널에서 실행' },
],
rs: [
{ id: 'terminal', icon: '⌨', label: 'rustc', desc: '터미널에서 실행' },
],
cpp: [
{ id: 'terminal', icon: '⌨', label: 'g++', desc: '터미널에서 실행' },
],
c: [
{ id: 'terminal', icon: '⌨', label: 'gcc', desc: '터미널에서 실행' },
],
sh: [
{ id: 'terminal', icon: '⌨', label: 'bash', desc: '터미널에서 실행' },
],
};
function codeGetRunModes(ext) {
const modes = CODE_RUN_MODES[ext];
if (!modes) return [{ id: 'terminal', icon: '⌨', label: '터미널', desc: '터미널에서 실행' }];
if (typeof modes === 'string') return CODE_RUN_MODES[modes];
return modes;
}
let _codeRunMenuOpen = false;
function codeToggleRunMenu() {
const menu = document.getElementById('code-run-menu');
if (_codeRunMenuOpen) {
menu.style.display = 'none';
_codeRunMenuOpen = false;
return;
}
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
menu.innerHTML = modes.map(m =>
`<div onclick="codeRunWith('${m.id}')" style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background .1s" onmouseover="this.style.background='#2d4a2d'" onmouseout="this.style.background=''">` +
`<span style="font-size:14px">${m.icon}</span>` +
`<div style="flex:1"><div style="font-size:12px;font-weight:600;color:#e5e7eb">${m.label}</div><div style="font-size:10px;color:#9ca3af">${m.desc}</div></div>` +
`</div>`
).join('');
menu.style.display = 'block';
_codeRunMenuOpen = true;
const close = (e) => {
if (!menu.contains(e.target) && !e.target.closest('#code-run-dropdown')) {
menu.style.display = 'none';
_codeRunMenuOpen = false;
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
function codeUpdateRunButton() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
const iconEl = document.getElementById('code-run-icon');
const labelEl = document.getElementById('code-run-label');
if (!file || !iconEl) return;
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
const primary = modes[0];
iconEl.textContent = primary.icon;
labelEl.textContent = primary.label;
}
function codeRunDefault() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
file.content = monacoEditor.getValue();
const ext = file.name.split('.').pop().toLowerCase();
const modes = codeGetRunModes(ext);
codeRunWith(modes[0].id);
}
function codeRunWith(mode) {
const menu = document.getElementById('code-run-menu');
if (menu) menu.style.display = 'none';
_codeRunMenuOpen = false;
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
file.content = monacoEditor.getValue();
const fpath = `${CODE_DIR}/${file.name}`;
const cmd = ext === 'py' ? `python3 ${fpath}` : ext === 'js' ? `node ${fpath}` :
ext === 'ts' ? `npx ts-node ${fpath}` : ext === 'go' ? `go run ${fpath}` :
ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'sh' ? `bash ${fpath}` : `./${fpath}`;
// Open code terminal and run
if (mode === 'browser') {
api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }) })
.then(r => {
if (r.success) {
const w = window.open(`${location.origin}/code-serve/${CODE_DIR}/${file.name}`, '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
}
});
return;
}
if (mode === 'pyodide') {
sessionStorage.setItem('py_run_code', file.content);
sessionStorage.setItem('py_run_name', file.name);
const w = window.open('/python-runner.html', '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
return;
}
// terminal modes
const ext = file.name.split('.').pop().toLowerCase();
let cmd;
if (mode === 'terminal') {
cmd = ext === 'py' ? `python3 ${fpath}` :
ext === 'js' ? `node ${fpath}` :
ext === 'ts' ? `npx ts-node ${fpath}` :
ext === 'go' ? `go run ${fpath}` :
ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` :
ext === 'sh' ? `bash ${fpath}` : `./${fpath}`;
} else {
cmd = mode === 'python3' ? `python3 ${fpath}` :
mode === 'node' ? `node ${fpath}` : `${mode} ${fpath}`;
}
if (!codeTermOpen) toggleCodeTerminal();
setTimeout(() => {
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
@@ -8835,6 +9156,50 @@ function codeSendSelectionToAI() {
// ─── V2 Chat Live Code Streaming ────────────────────────────────────────────────
// Global state for streaming code into the editor during the main v2 chat.
let v2LiveFileId = null, v2LiveLang = '', v2LiveInBlock = false;
let codePendingQueue = []; // [{fname, content, lang}] — queued when Monaco not ready
// Throttled Monaco model update — avoids freezing UI by limiting setValue calls to ~80ms intervals
let _monacoThrottleTimer = null;
let _monacoThrottleModel = null;
let _monacoThrottleValue = '';
function codeMonacoThrottle(model, value) {
_monacoThrottleModel = model;
_monacoThrottleValue = value;
if (_monacoThrottleTimer) return; // already scheduled
_monacoThrottleTimer = setTimeout(() => {
_monacoThrottleTimer = null;
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
_monacoThrottleModel.setValue(_monacoThrottleValue);
_monacoThrottleModel = null;
}
}, 80);
}
function codeMonacoThrottleFlush() {
if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; }
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
_monacoThrottleModel.setValue(_monacoThrottleValue);
}
_monacoThrottleModel = null;
}
function codeFlushPendingQueue() {
if (!monacoReady || !monacoEditor || !codePendingQueue.length) return;
for (const item of codePendingQueue) {
const existing = codeFiles.find(f => f.name === item.fname);
if (existing) {
if (existing.model) existing.model.setValue(item.content);
else existing.content = item.content;
} else {
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname));
codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model });
if (!v2LiveFileId) v2LiveFileId = id;
}
}
codePendingQueue = [];
if (v2LiveFileId) switchCodeFile(v2LiveFileId);
}
function v2LiveCodeUpdate(fullReply) {
if (typeof codeIsOpenable !== 'function') return;
const fenceRe = /```([\w\-]*)\s*\n/g;
@@ -8853,19 +9218,26 @@ function v2LiveCodeUpdate(fullReply) {
if (!codeIsOpenable(code, lang)) return;
v2LiveInBlock = true;
v2LiveLang = lang;
const fname = codeDeriveName(code, lang);
if (monacoReady && monacoEditor) {
const fname = codeDeriveName(code, lang);
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
v2LiveFileId = id;
switchCodeFile(id);
} else {
// Monaco not ready yet — queue and trigger init
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (v2LiveFileId) {
const file = codeFiles.find(f => f.id === v2LiveFileId);
if (file?.model) {
file.model.setValue(code);
codeMonacoThrottle(file.model, code);
}
} else if (codePendingQueue.length) {
// Monaco still loading — update queued content
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
@@ -8921,20 +9293,25 @@ async function codeAiSend() {
if (!codeIsOpenable(code, lang)) return;
liveInBlock = true;
liveLang = lang;
const fname = codeDeriveName(code, lang);
if (monacoReady && monacoEditor) {
const fname = codeDeriveName(code, lang);
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
liveFileId = id;
switchCodeFile(id);
} else {
codePendingQueue = [{ fname, content: code, lang }];
initCodeView();
}
} else if (liveFileId) {
const file = codeFiles.find(f => f.id === liveFileId);
if (file?.model) {
file.model.setValue(code);
liveCodeBuf = code;
codeMonacoThrottle(file.model, code);
}
} else if (codePendingQueue.length) {
codePendingQueue[codePendingQueue.length - 1].content = code;
}
}
@@ -8955,11 +9332,21 @@ async function codeAiSend() {
else if (ev.type === 'text_delta' && ev.delta) token = ev.delta;
else if (ev.type === 'final' && (ev.text || ev.content)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool_call / tool_result events for live editor update
else if (ev.type === 'tool_call' && ev.action === 'create_file') {
const cf = ev.args?.filename || ev.args?.name || '';
const cc = ev.args?.content || '';
if (cf && cc) codeEditorWriteFile(cf.replace(/^code\//, ''), cc);
}
else if (ev.type === 'tool_result' && ev.action === 'create_file' && !ev.error) {
codeSbRefresh();
}
if (token) { reply += token; thinkEl.textContent = reply; codeAiScrollBottom(); codeAiLiveUpdate(reply); }
} catch {}
}
}
liveInBlock = false; liveFileId = null;
codeMonacoThrottleFlush();
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
if (!reply) { thinkEl.textContent = '(응답 없음)'; }
else codeAiRenderMarkdown(thinkEl, reply);
@@ -9084,8 +9471,8 @@ function codeDeriveName(code, lang) {
// creating or updating the tab. Used to mirror the model's create_file in real time.
function codeEditorWriteFile(fname, content) {
if (!monacoReady || !monacoEditor) {
codePendingQueue.push({ fname, content, lang: codeDetectLang(fname) });
initCodeView();
setTimeout(() => codeEditorWriteFile(fname, content), 800);
return;
}
const existing = codeFiles.find(f => f.name === fname);
@@ -9101,8 +9488,9 @@ function codeEditorWriteFile(fname, content) {
function codeAiOpenInEditor(code, lang) {
if (!codeIsOpenable(code, lang)) return;
if (!monacoReady || !monacoEditor) {
const fname = codeDeriveName(code, lang);
codePendingQueue.push({ fname, content: code, lang });
initCodeView();
setTimeout(() => codeAiOpenInEditor(code, lang), 800);
return;
}
const fname = codeDeriveName(code, lang);
+18 -2
View File
@@ -23,9 +23,9 @@
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.6.0" defer></script>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
</head>
<body>
@@ -164,6 +164,15 @@
<!-- 설정 탭 -->
<div id="csb-settings" style="display:none;flex-direction:column;flex:1;min-height:0;overflow-y:auto;padding:14px 12px;gap:14px">
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">코드 저장 디렉토리</div>
<div style="display:flex;align-items:center;gap:6px">
<input type="text" id="csb-codedir" value="code" onchange="csbCodeDirChanged(this.value)"
style="flex:1;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:'IBM Plex Mono',monospace">
<button onclick="csbCodeDirSave()" title="저장" style="background:var(--brand);color:#fff;border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap">저장</button>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px">워크스페이스 하위 폴더 (기본: code)</div>
</div>
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">폰트 크기</div>
<div style="display:flex;align-items:center;gap:8px">
@@ -351,7 +360,14 @@
📁 <span>workspace/code/</span> ▾
</button>
<button class="code-tool-btn btn-ai" onclick="codeSendSelectionToAI()" title="선택 영역을 AI에게 전송">💬 AI에게 전송</button>
<button class="code-tool-btn btn-run" onclick="codeRunInTerminal()" title="터미널에서 실행">▶ 실행</button>
<div id="code-run-dropdown" style="position:relative;display:inline-flex">
<button class="code-tool-btn btn-run" onclick="codeRunDefault()" title="실행">
<span id="code-run-icon">▶</span> <span id="code-run-label">실행</span>
</button>
<button class="code-tool-btn btn-run" onclick="codeToggleRunMenu()" title="실행 방법 선택" style="padding:6px 4px;margin-left:-1px;border-left:1px solid rgba(34,197,94,.3)">▾</button>
<div id="code-run-menu" style="display:none;position:absolute;top:100%;right:0;z-index:99;min-width:180px;background:#1a2e1a;border:1px solid #2d5a3a;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,.5);margin-top:2px;overflow:hidden">
</div>
</div>
<button class="code-tool-btn btn-save" onclick="codeSaveFile()" title="파일 저장 (Ctrl+S)">💾 저장</button>
<button id="code-term-toggle-btn" class="code-tool-btn" onclick="toggleCodeTerminal()"
title="터미널 토글 (Ctrl+`)"
+10
View File
@@ -763,6 +763,16 @@
.msg.user .msg-content { color: #0f3d8a; }
.md-img { max-width: 100%; max-height: 400px; border-radius: 8px; cursor: pointer; margin: 8px 0; display: block; }
.md-img:hover { opacity: 0.9; }
.md-img-err { display: inline-block; color: var(--muted, #888); font-size: 13px; padding: 4px 8px; background: var(--surface2, #f0f0f0); border-radius: 6px; margin: 4px 0; }
.map-embed-wrap { margin: 10px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--line, #dde3f0); }
.map-embed { width: 100%; height: 420px; border: none; display: block; }
.map-embed-link { padding: 6px 12px; background: var(--surface2, #f5f5f7); font-size: 12px; }
.map-embed-link a { color: var(--accent, #1a73e8); text-decoration: none; }
.map-embed-link a:hover { text-decoration: underline; }
.yt-embed-wrap { margin: 10px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--line, #dde3f0); }
.yt-embed { width: 100%; aspect-ratio: 16/9; border: none; display: block; }
.windy-embed-wrap { margin: 10px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--line, #dde3f0); }
.windy-embed { width: 100%; height: 500px; border: none; display: block; }
.md-code-block { background: #1e1e2e; color: #cdd6f4; padding: 12px 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; margin: 8px 0; }
.md-inline-code { background: #e8edf6; color: #17243b; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
:root[data-theme="dark"] .md-inline-code { background: #2a2a3e; color: #cdd6f4; }