Add TradingView chart widget + MIDI/tab close fixes

- investor skill: TradingView 실시간 주가 차트 렌더링 지원
  - ```tradingview SYMBOL|INTERVAL``` 블록 → 차트 위젯 자동 생성
  - JSON 형식 출력 시 HTML 언이스케이프 후 파싱 (symbol/interval 추출)
  - renderTVChart(): tv.js 동적 로드, 다크/라이트 테마 연동, RSI·MACD 기본 포함
- musician skill: MIDI 다운로드 사이트 4개 추가 (freemidi, bitmidi, piano-midi, midiworld)
- MIDI 탭 닫기 버그: window.open에 noopener,noreferrer 추가
- beforeunload/pagehide 시 midiRouter.stop() 호출

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-21 23:26:28 +09:00
co-authored by Claude Sonnet 4.6
parent 00d508cc77
commit 6df1bcf5b3
3 changed files with 163 additions and 3 deletions
+41
View File
@@ -15,6 +15,47 @@ version: 1.0.0
--- ---
## ⚠️ 시스템 내장 주가 차트 렌더링 — 반드시 읽을 것
이 시스템에는 **TradingView 실시간 주가 차트** 기능이 내장되어 있습니다.
주가 차트 요청 시 아래 **단순 형식**의 코드블록만 출력하면 채팅창에 "📈 차트 불러오기" 버튼이 자동 생성됩니다:
```tradingview
KRX:005930|D
```
**⚠️ 절대 JSON 형식 사용 금지** — `{...}` 객체 대신 반드시 `거래소:종목코드|봉주기` 한 줄만 출력
**형식**: `거래소:종목코드|봉주기`
| 봉주기 | 코드 |
|---|---|
| 1분봉 | `1` |
| 5분봉 | `5` |
| 15분봉 | `15` |
| 1시간봉 | `60` |
| 일봉 | `D` |
| 주봉 | `W` |
| 월봉 | `M` |
**거래소 코드 예시**:
- 한국 코스피: `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 사이트에서 확인하세요"
- 외부 링크 안내
---
### 사용 가능한 도구 ### 사용 가능한 도구
| 도구 | 활용 상황 | | 도구 | 활용 상황 |
+46 -1
View File
@@ -5,6 +5,29 @@ emoji: "🎵"
version: 1.0.0 version: 1.0.0
--- ---
## ⚠️ 시스템 내장 악보 렌더링 — 반드시 읽을 것
이 시스템에는 **ABC notation → SVG 악보 + MIDI 자동 변환** 기능이 서버에 내장되어 있습니다.
악보 요청 시 아래 형식의 코드블록만 출력하면 채팅창에 "악보 렌더링" 버튼이 자동 생성됩니다:
```abc
X:1
T:제목
M:3/4
L:1/8
Q:1/4=120
K:G
...음표...
```
**절대 금지 문구** (시스템이 처리하므로 언급 불필요):
- "직접 생성 불가" / "파일을 만들어 드릴 수 없습니다"
- "LilyBin", "온라인 편집기", "붙여넣으세요"
- "LilyPond 코드를 복사하세요"
---
## 음악 어시스턴트 모드 활성화 ## 음악 어시스턴트 모드 활성화
당신은 클래식 작곡·재즈 화성·대중음악 프로듀싱·악기 코칭에 두루 능통한 음악 어시스턴트입니다. 열정적이되 정확하게, 이론은 쉬운 말로, 실습은 구체적으로 안내하세요. 당신은 클래식 작곡·재즈 화성·대중음악 프로듀싱·악기 코칭에 두루 능통한 음악 어시스턴트입니다. 열정적이되 정확하게, 이론은 쉬운 말로, 실습은 구체적으로 안내하세요.
@@ -36,8 +59,14 @@ version: 1.0.0
| 국악 자료 | `site:gugak.go.kr` | 국립국악원 (국악 악보·음원·이론) | | 국악 자료 | `site:gugak.go.kr` | 국립국악원 (국악 악보·음원·이론) |
| 저작권 정보 | `site:komca.or.kr` | 한국음악저작권협회 | | 저작권 정보 | `site:komca.or.kr` | 한국음악저작권협회 |
| 재즈 리드시트 | `site:jazzstandards.com` | 재즈 스탠다드 코드·멜로디 분석 | | 재즈 리드시트 | `site:jazzstandards.com` | 재즈 스탠다드 코드·멜로디 분석 |
| MIDI 다운로드 (팝·록·클래식) | `site:freemidi.org` | 팝·록·클래식·게임 MIDI 무료 대형 라이브러리 |
| MIDI 다운로드 (대량) | `site:bitmidi.com` | 10만+ MIDI 파일 무료 검색·스트리밍·다운로드 |
| MIDI 다운로드 (고품질 클래식 피아노) | `site:piano-midi.de` | 베토벤·쇼팽·리스트 등 고품질 클래식 피아노 MIDI |
| MIDI 다운로드 (종합 아카이브) | `site:midiworld.com` | 클래식·팝·게임·영화 음악 MIDI 아카이브 |
예시: 베토벤 소나타 악보 → `web_search("site:imslp.org Beethoven piano sonata op.13")` 예시: 베토벤 소나타 악보 → `web_search("site:imslp.org Beethoven piano sonata op.13")`
예시: 쇼팽 야상곡 MIDI → `web_search("site:piano-midi.de Chopin nocturne")`
예시: 팝 MIDI 검색 → `web_search("site:freemidi.org 곡 제목")`
--- ---
@@ -50,10 +79,26 @@ version: 1.0.0
3. **화성 진행** — 기능화성 또는 재즈/모달 화성 기반 코드 제안 (로마 숫자 표기 병기) 3. **화성 진행** — 기능화성 또는 재즈/모달 화성 기반 코드 제안 (로마 숫자 표기 병기)
4. **멜로디 아이디어** — 음역·리듬 패턴·모티프 발전 방향 제시 4. **멜로디 아이디어** — 음역·리듬 패턴·모티프 발전 방향 제시
5. **편성 조언** — 음역대·음색·밸런스 고려한 악기 배치 5. **편성 조언** — 음역대·음색·밸런스 고려한 악기 배치
6. **악보/리드시트** — 요청 시 ABC notation 또는 텍스트 악보로 표현 6. **악보/리드시트** — 요청 시 반드시 ABC notation 코드블록으로 출력 (렌더링 버튼이 자동 생성됨)
**코드 표기 규칙**: 로마 숫자(I, IV, V7, ii, iii°) + 팝/재즈 표기(Cmaj7, Dm7, G7) 병기. **코드 표기 규칙**: 로마 숫자(I, IV, V7, ii, iii°) + 팝/재즈 표기(Cmaj7, Dm7, G7) 병기.
**악보 출력 규칙**: 멜로디·코드·리드시트를 출력할 때는 반드시 아래 형식으로 감싸세요. 이 블록은 자동으로 SVG 악보로 렌더링되고 MIDI 재생 버튼이 생성됩니다.
~~~
```abc
X:1
T:제목
M:4/4
L:1/8
Q:1/4=120
K:C
...음표...
```
~~~
> ABC notation 기본 규칙: `C` = 중간 C, `c` = 한 옥타브 위, `C,` = 한 옥타브 아래. 음표 길이: `C2`=2박, `C/2`=반박. 쉼표: `z`. 마디 구분: `|`. 코드: `"Am"A4`.
--- ---
### 악보 PDF 분석 방법 ### 악보 PDF 분석 방법
+76 -2
View File
@@ -9253,6 +9253,37 @@ function renderMarkdown(text, opts) {
'</div>' + '</div>' +
'</div>'; '</div>';
} }
if (lang === 'tradingview') {
var tvSymbol = '', tvInterval = 'D';
// code content is already HTML-escaped at this point — unescape before parsing
var tvRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'").trim();
if (tvRaw.startsWith('{')) {
try {
var tvJson = JSON.parse(tvRaw);
tvSymbol = (tvJson.symbol || tvJson.SYMBOL || '').toString().toUpperCase();
tvInterval = (tvJson.interval || tvJson.INTERVAL || 'D').toString().toUpperCase();
} catch(e) { tvSymbol = tvRaw.split('|')[0].trim().toUpperCase(); }
} else {
var parts = tvRaw.split('|');
tvSymbol = (parts[0] || '').trim().toUpperCase();
tvInterval = (parts[1] || 'D').trim().toUpperCase();
}
if (!tvSymbol) return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
var tvId = 'tv-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + tvId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">📈 ' + escHtml(tvSymbol) + ' · ' + escHtml(tvInterval) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderTVChart(this,\'' + tvId + '-c\',\'' + escHtml(tvSymbol) + '\',\'' + escHtml(tvInterval) + '\')">📈 차트 불러오기</button>' +
'<button onclick="toggleMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + tvId + '\')" 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">' +
'<div id="' + tvId + '-c" style="height:440px;width:100%"></div>' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>'; return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
}); });
@@ -9286,6 +9317,45 @@ function renderMarkdown(text, opts) {
return html; return html;
} }
function renderTVChart(btn, containerId, symbol, interval) {
btn.disabled = true;
btn.textContent = '로딩 중...';
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
function doInit() {
try {
new TradingView.widget({
container_id: containerId,
autosize: true,
symbol: symbol,
interval: interval || 'D',
timezone: 'Asia/Seoul',
theme: isDark ? 'dark' : 'light',
style: '1',
locale: 'kr',
enable_publishing: false,
allow_symbol_change: true,
save_image: false,
withdateranges: true,
hide_side_toolbar: false,
studies: ['RSI@tv-basicstudies', 'MACD@tv-basicstudies'],
});
btn.style.display = 'none';
} catch(e) {
btn.disabled = false;
btn.textContent = '📈 차트 불러오기';
}
}
if (window.TradingView && window.TradingView.widget) {
doInit();
} else {
var s = document.createElement('script');
s.src = 'https://s3.tradingview.com/tv.js';
s.onload = doInit;
s.onerror = function() { btn.disabled = false; btn.textContent = '📈 차트 불러오기'; };
document.head.appendChild(s);
}
}
function toggleMusicWidget(widgetId) { function toggleMusicWidget(widgetId) {
var widget = document.getElementById(widgetId); var widget = document.getElementById(widgetId);
if (!widget) return; if (!widget) return;
@@ -9495,7 +9565,7 @@ function buildMusicActions(data, downloadName) {
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>' + '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>'; + '</div>';
html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>'; html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">🎹 편집</button>'; html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>';
html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>'; html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>';
} }
if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>'; if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>';
@@ -9616,7 +9686,7 @@ function buildMidiPickerWidget(data, filename) {
savedLine + savedLine +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' + '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' + '<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">🎹 편집</button>' + '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' + '<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
'</div>' + '</div>' +
playerPanel + playerPanel +
@@ -11061,6 +11131,10 @@ function canvasRun() {
}); });
})(); })();
// Stop MIDI playback when tab is closed or navigated away
window.addEventListener('beforeunload', () => { try { midiRouter.stop(); } catch {} });
window.addEventListener('pagehide', () => { try { midiRouter.stop(); } catch {} });
// Resize xterm when window resizes // Resize xterm when window resizes
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
const shell = getActiveShell(); const shell = getActiveShell();