Add MIDI editor + fix external synth instrument mapping

- web-ui/midi-editor.html: full piano roll MIDI editor (new tab)
  - all tracks simultaneously, color-coded per channel
  - note add/delete/move/resize, multi-select, copy/paste
  - velocity lane, track panel (mute/solo/instrument), undo/redo
  - GM reset + bank select on playback, MIDI output device select
  - export to .mid, open via ?url= param or file picker

- web-ui/index.html: add ✏ 편집 button to MIDI widget (opens editor)
  - GM SysEx reset + bank select before note playback
  - 200ms note delay for synth init time
  - getRenderFile: prefer original.midi over LilyPond output.midi

- src/gateway/music-renderer.ts: getRenderFile returns original.midi first
  - LilyPond SVG render overwrites output.midi (no Program Changes)
  - original.midi preserved from upload, served via /api/music/file/:id/midi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-21 13:54:15 +09:00
co-authored by Claude Sonnet 4.6
parent bc9fb4043b
commit 1bfadd6060
3 changed files with 1966 additions and 11 deletions
+615 -11
View File
@@ -2248,7 +2248,127 @@
.error-context-detail.visible {
display: block;
}
/* Music widget */
.music-widget {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
margin: 10px 0;
background: var(--surface);
}
.music-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--hover);
border-bottom: 1px solid var(--line);
}
.music-widget-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.music-render-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--brand);
background: var(--brand);
color: #fff;
cursor: pointer;
font-family: inherit;
}
.music-render-btn:disabled {
opacity: 0.5;
cursor: default;
}
.music-widget-body {
overflow: hidden;
transition: max-height 0.2s ease;
}
.music-widget-body.collapsed {
max-height: 0 !important;
}
.music-output {
padding: 12px;
}
.music-output img {
width: 100%;
height: auto;
display: block;
}
midi-player {
display: block;
width: 100%;
max-width: 420px;
margin-top: 6px;
--player-row-height: 16px;
--player-font-size: 11px;
}
.music-actions {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
align-items: center;
}
.music-dl-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
font-family: inherit;
}
.music-dl-btn:hover { background: var(--hover); }
.music-page-nav {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
color: var(--muted);
}
.music-page-btn {
padding: 2px 10px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
cursor: pointer;
font-size: 13px;
font-family: inherit;
line-height: 1.4;
}
.music-page-btn:disabled { opacity: 0.35; cursor: default; }
.music-page-btn:not(:disabled):hover { background: var(--hover); }
.midi-router-panel {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
width: 100%;
}
.midi-router-sel {
font-size: 11px;
padding: 2px 6px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
color: var(--text);
max-width: 200px;
flex: 1;
}
.midi-router-status { font-size: 11px; color: var(--muted); }
</style>
<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>
</head>
<body>
@@ -2335,6 +2455,12 @@
<span>Skills</span>
<button class="sidebar-action" onclick="openCreateSkillModal()">+ Create</button>
</div>
<div id="skills-user-selector" style="display:none;padding:5px 10px 6px;border-bottom:1px solid var(--line);background:var(--panel-2)">
<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:3px">대상 유저</label>
<select id="skills-target-user" onchange="onSkillsUserChange()" style="width:100%;font-size:12px;padding:3px 6px;background:var(--bg2);color:var(--text);border:1px solid var(--line);border-radius:6px">
<option value="">글로벌 기본값</option>
</select>
</div>
<div id="skills-list" style="flex:1;min-height:0;overflow-y:auto;padding:10px">
<div class="empty-state" style="padding:16px">스킬 로딩 중...</div>
</div>
@@ -2381,6 +2507,12 @@
<input type="checkbox" id="tts-mode-toggle" onchange="updateTTSMode()">
<span class="toggle-track"></span>
</label>
<span style="color:var(--line);margin:0 6px;user-select:none">|</span>
<span class="agent-toggle-label">바로보내기</span>
<label class="toggle-switch" title="음성 인식 후 자동 전송">
<input type="checkbox" id="mic-autosend-toggle" onchange="updateMicAutosend()">
<span class="toggle-track"></span>
</label>
<div class="agent-toggle-right" style="display:flex;align-items:center;gap:6px">
<button class="quick-mode-btn" id="context-pin-btn" onclick="toggleContextPinMode()" title="메시지를 컨텍스트에 고정">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
@@ -2429,7 +2561,7 @@
<!-- 첨부 파일 카드 영역 (전송 전 표시) -->
<div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div>
<div class="chat-input-row">
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx" style="display:none" onchange="handleFileUpload(this)" />
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx,.mid,.midi" style="display:none" onchange="handleFileUpload(this)" />
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
<button class="send-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력 (클릭: 시작/중지)" style="font-size:18px;width:44px;height:44px" aria-label="음성 입력">🎤</button>
<textarea
@@ -3123,6 +3255,7 @@
<option value="lm_studio">LM Studio (로컬)</option>
<option value="openai">OpenAI — API 키</option>
<option value="openai_codex">OpenAI — GPT Plus / Codex (OAuth)</option>
<option value="anthropic">Anthropic — Claude API 키</option>
</select>
</div>
@@ -3196,6 +3329,23 @@
<div id="provider-status-msg-openai" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
</div>
<!-- Anthropic -->
<div id="prov-fields-anthropic" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">Anthropic API 키</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">API 키</label>
<input id="settings-anthropic-key" type="password" placeholder="sk-ant-api03-..." style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">모델</label>
<select id="settings-anthropic-model" class="settings-input" style="font-size:13px">
<option value="claude-sonnet-4-6">claude-sonnet-4-6 (권장)</option>
<option value="claude-opus-4-7">claude-opus-4-7</option>
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001 (빠름)</option>
</select>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="testProviderConnection()" style="background:#fff;border:1px solid var(--line);color:var(--text)">연결 테스트</button>
</div>
<div id="provider-status-msg-anthropic" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
</div>
<!-- OpenAI Codex OAuth -->
<div id="prov-fields-openai_codex" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">OpenAI — GPT Plus / Codex</div>
@@ -3954,6 +4104,7 @@ let currentUser = null;
setAgentSessionId(generateSessionId()); // always fresh session on page load
checkAuth(); // Check authentication on page load
initTTSToggle();
initMicAutosendToggle();
// ── Server log panel ──────────────────────────────────────────────────────────
let logScrollLocked = true;
@@ -4461,7 +4612,7 @@ function setSidebarTab(tab) {
document.getElementById('tab-skills').classList.toggle('active', tab === 'skills');
document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none';
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
if (tab === 'skills') loadInstalledSkills();
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
}
// ---- Agent mode toggle ----
@@ -4754,6 +4905,9 @@ function renderChatMessages() {
// Also show green for confirmed pins even outside pin mode
const confirmedClass = (!contextPinMode && confirmedPins.some(p => p.content === msg.content && p.role === (msg.role === 'user' ? 'user' : 'assistant'))) ? ' pin-selected' : '';
const clickHandler = contextPinMode ? ` onclick="togglePinMessage(${idx})"` : '';
if (msg.role === 'widget') {
return `<div class="msg widget${pinClass}${selectedClass}${confirmedClass}"${clickHandler}><div class="msg-content">${msg.content}</div></div>`;
}
return `
<div class="msg ${msg.role}${pinClass}${selectedClass}${confirmedClass}"${clickHandler}>
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
@@ -6267,7 +6421,7 @@ function toggleCredVis(inputId, btn) {
// --- Provider-aware Model Settings ------------------------------------------
const PROVIDER_IDS = ['ollama', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex'];
const PROVIDER_IDS = ['ollama', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic'];
function onProviderChange() {
const provider = document.getElementById('settings-llm-provider').value;
@@ -6307,6 +6461,8 @@ async function loadModelSettings() {
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
v('settings-lmstudio-model', pc.lm_studio?.model);
v('settings-openai-key', pc.openai?.api_key);
v('settings-anthropic-key', pc.anthropic?.api_key);
if (pc.anthropic?.model) { const s = document.getElementById('settings-anthropic-model'); if (s) s.value = pc.anthropic.model; }
// Set primary model from saved config before refreshing the list
const savedPrimaryModel = pc[prov]?.model || '';
@@ -6358,7 +6514,7 @@ async function refreshProviderModels() {
async function testProviderConnection() {
const provider = document.getElementById('settings-llm-provider').value;
const statusIds = { ollama: 'provider-status-msg', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status' };
const statusIds = { ollama: 'provider-status-msg', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status', anthropic: 'provider-status-msg-anthropic' };
const msgId = statusIds[provider] || 'provider-status-msg';
const msg = document.getElementById(msgId);
if (msg) msg.textContent = '테스트 중…';
@@ -6423,6 +6579,7 @@ function buildProviderPayload() {
providers.lm_studio = { endpoint: document.getElementById('settings-lmstudio-endpoint')?.value || 'http://localhost:1234', model: document.getElementById('settings-lmstudio-model')?.value || '' };
providers.openai = { api_key: document.getElementById('settings-openai-key')?.value || '', model: document.getElementById('settings-openai-model')?.value || 'gpt-4o' };
providers.openai_codex = { model: document.getElementById('settings-codex-model')?.value || 'gpt-5.3-codex' };
providers.anthropic = { api_key: document.getElementById('settings-anthropic-key')?.value || '', model: document.getElementById('settings-anthropic-model')?.value || 'claude-sonnet-4-6' };
return { provider, providers };
}
@@ -8843,6 +9000,17 @@ function initLuckyModalInteraction() {
// ── Voice: STT (Web Speech API — Chrome 내장 Google STT) ────────────────
var _speechRecog = null;
var _isRecording = false;
var _micAutosend = localStorage.getItem('micAutosend') !== 'false'; // default on
function updateMicAutosend() {
_micAutosend = document.getElementById('mic-autosend-toggle').checked;
localStorage.setItem('micAutosend', String(_micAutosend));
}
function initMicAutosendToggle() {
var toggle = document.getElementById('mic-autosend-toggle');
if (toggle) toggle.checked = _micAutosend;
}
function toggleMicRecording() {
if (_isRecording) stopMicRecording();
@@ -8874,7 +9042,7 @@ function startMicRecording() {
input.value = (input.value ? input.value + ' ' : '') + transcript;
input.focus();
input.dispatchEvent(new Event('input'));
setTimeout(function() { handleSendStop(); }, 50);
if (_micAutosend) setTimeout(function() { handleSendStop(); }, 50);
}
};
_speechRecog.onerror = function(e) {
@@ -9055,7 +9223,26 @@ function renderMarkdown(text, opts) {
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
return '<pre class="md-code-block"><code>' + code.trim() + '</code></pre>';
var trimmed = code.trim();
if (lang === 'abc' || lang === 'lilypond') {
var encoded = encodeURIComponent(trimmed);
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + widgetId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + (lang === 'abc' ? 'ABC Notation' : 'LilyPond') + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderMusicBlock(\'' + widgetId + '\', decodeURIComponent(\'' + encoded + '\'))">악보 렌더링</button>' +
'<button onclick="toggleMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + widgetId + '\')" 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">' +
'<pre class="md-code-block music-source" style="margin:0;border-radius:0;border:none;border-bottom:1px solid var(--line);max-height:120px;overflow-y:auto;font-size:11px"><code>' + escHtml(trimmed) + '</code></pre>' +
'<div class="music-output" style="display:none"></div>' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
// Inline code
@@ -9088,6 +9275,376 @@ function renderMarkdown(text, opts) {
return html;
}
function toggleMusicWidget(widgetId) {
var widget = document.getElementById(widgetId);
if (!widget) return;
// widgetId may point to either .music-widget or .music-widget-body
var body = widget.classList.contains('music-widget-body')
? widget
: widget.querySelector('.music-widget-body');
var btn = widget.classList.contains('music-widget-body')
? widget.closest('.music-widget')?.querySelector('[title="접기/펼치기"]')
: widget.querySelector('[title="접기/펼치기"]');
if (!body) return;
var collapsed = body.classList.toggle('collapsed');
if (btn) btn.textContent = collapsed ? '▸' : '▾';
}
function closeMusicWidget(widgetId) {
var el = document.getElementById(widgetId);
if (!el) return;
var widget = el.closest('.music-widget') || el;
widget.remove();
}
// ── Web MIDI router ──────────────────────────────────────────────────────────
const midiRouter = {
access: null,
timers: [],
isPlaying: false,
stop() {
this.timers.forEach(t => clearTimeout(t));
this.timers = [];
this.isPlaying = false;
if (this.access) {
this.access.outputs.forEach(out => {
for (let ch = 0; ch < 16; ch++) {
try { out.send([0xB0 | ch, 123, 0]); } catch {} // All Notes Off
try { out.send([0xB0 | ch, 121, 0]); } catch {} // Reset All Controllers
}
});
}
},
// Parse raw MIDI bytes → scheduled events list
// Returns [{timeMs, msg:[...bytes]}, ...]
_parseMidiRaw(buf) {
const u8 = new Uint8Array(buf);
const dv = new DataView(buf);
if (dv.getUint32(0) !== 0x4D546864) return []; // not MThd
const tpq = dv.getUint16(12);
const ntrk = dv.getUint16(10);
let tempo = 500000;
const events = [];
const readVarLen = (pos) => {
let val = 0, b;
do { b = u8[pos++]; val = (val << 7) | (b & 0x7F); } while (b & 0x80);
return { val, pos };
};
const ticksToMs = (ticks) => (ticks / tpq) * (tempo / 1000);
let p = 14;
for (let t = 0; t < ntrk; t++) {
if (p + 8 > u8.length || dv.getUint32(p) !== 0x4D54726B) break;
const tlen = dv.getUint32(p + 4);
p += 8;
const end = p + tlen;
let tick = 0, rs = 0;
while (p < end) {
const dl = readVarLen(p); p = dl.pos; tick += dl.val;
let b = u8[p];
if (b & 0x80) { rs = b; p++; } else { b = rs; }
const typ = b & 0xF0, ch = b & 0x0F;
const ms = ticksToMs(tick);
if (typ === 0x90 || typ === 0x80) {
const note = u8[p++], vel = u8[p++];
events.push({ ms, msg: [typ | ch, note, vel] });
} else if (typ === 0xA0 || typ === 0xB0 || typ === 0xE0) {
events.push({ ms, msg: [b, u8[p], u8[p+1]] }); p += 2;
} else if (typ === 0xC0) {
events.push({ ms, msg: [0xC0 | ch, u8[p++]] });
} else if (typ === 0xD0) {
events.push({ ms, msg: [b, u8[p++]] });
} else if (b === 0xFF) {
const mt = u8[p++];
const ll = readVarLen(p); p = ll.pos;
if (mt === 0x51 && ll.val >= 3) {
// Tempo change: recalculate from current tick
tempo = (u8[p] << 16) | (u8[p+1] << 8) | u8[p+2];
}
p += ll.val;
} else if (b === 0xF0 || b === 0xF7) {
const ll = readVarLen(p); p = ll.pos + ll.val;
} else { p++; }
}
p = end;
}
events.sort((a, b) => a.ms - b.ms);
return events;
},
async play(midiUrl, outputId, onStatus) {
this.stop();
const output = this.access.outputs.get(outputId);
if (!output) throw new Error('기기를 찾을 수 없습니다');
if (onStatus) onStatus('로딩 중...');
const resp = await fetch(midiUrl);
const buf = await resp.arrayBuffer();
// Parse raw MIDI for exact Program Change + all events
const events = this._parseMidiRaw(buf);
if (!events.length) throw new Error('MIDI 파싱 실패');
// GM Reset SysEx → 신디를 GM 모드로 초기화
try { output.send(new Uint8Array([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7])); } catch {}
// Send all PC + Bank Select (CC#0=0, CC#32=0) events upfront
const initPc = new Set();
events.forEach(ev => {
if ((ev.msg[0] & 0xF0) === 0xC0 && !initPc.has(ev.msg[0] & 0x0F)) {
initPc.add(ev.msg[0] & 0x0F);
const ch = ev.msg[0] & 0x0F;
try { output.send(new Uint8Array([0xB0 | ch, 0, 0])); } catch {} // Bank Select MSB=0
try { output.send(new Uint8Array([0xB0 | ch, 32, 0])); } catch {} // Bank Select LSB=0
try { output.send(new Uint8Array(ev.msg)); } catch {}
}
});
const NOTE_DELAY = 200; // ms — give synth time to process GM reset + PCs
const totalMs = events[events.length - 1].ms + NOTE_DELAY + 500;
this.isPlaying = true;
if (onStatus) onStatus('재생 중...');
events.forEach(ev => {
this.timers.push(setTimeout(() => {
if (!this.isPlaying) return;
try { output.send(new Uint8Array(ev.msg)); } catch {}
}, ev.ms + NOTE_DELAY));
});
this.timers.push(setTimeout(() => {
this.isPlaying = false;
if (onStatus) onStatus('완료');
}, totalMs));
}
};
async function midiRouterToggle(routerId, btnId) {
const panel = document.getElementById(routerId);
if (!panel) { alert('패널을 찾을 수 없습니다: ' + routerId); return; }
const isHidden = panel.style.display === 'none' || panel.style.display === '';
if (isHidden) {
panel.style.display = 'flex';
panel.style.flexWrap = 'wrap';
panel.style.gap = '6px';
panel.style.alignItems = 'center';
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
try {
if (!navigator.requestMIDIAccess) throw new Error('Web MIDI 미지원 — Chrome/Edge 필요');
if (!midiRouter.access) {
midiRouter.access = await navigator.requestMIDIAccess();
}
if (sel) {
sel.innerHTML = '<option value="">-- MIDI 기기 선택 --</option>';
midiRouter.access.outputs.forEach((out, id) => {
const opt = document.createElement('option');
opt.value = id; opt.textContent = out.name; sel.appendChild(opt);
});
if (sel.options.length <= 1 && st) st.textContent = 'MIDI 출력 기기 없음';
}
} catch (e) {
if (st) st.textContent = '오류: ' + e.message;
}
} else {
panel.style.display = 'none';
midiRouter.stop();
}
}
async function midiRouterPlay(routerId, midiUrl) {
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
const outputId = sel ? sel.value : '';
if (!outputId) { if (st) st.textContent = '기기를 선택하세요'; return; }
try {
await midiRouter.play(midiUrl, outputId, msg => { if (st) st.textContent = msg; });
} catch (e) { if (st) st.textContent = '오류: ' + e.message; }
}
function midiRouterStop(routerId) {
midiRouter.stop();
const st = document.getElementById(routerId + '-st');
if (st) st.textContent = '정지';
}
function buildMusicActions(data, downloadName) {
var midiUrl = data.midi_url;
var pdfUrl = data.pdf_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var html = '<div class="music-actions">';
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>'
+ '<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>'
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>';
html += '<button class="music-dl-btn" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">🎛 외부 신디</button>';
html += '<button class="music-dl-btn" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">✏ 편집</button>';
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>';
html += '</div>';
return html;
}
// ─────────────────────────────────────────────────────────────────────────────
function buildSvgPagesHtml(data) {
var pages = data.page_count || 1;
var baseId = data.id;
// Use a random suffix so duplicate renders in chat history never share the same navId
var navId = 'music-nav-' + Math.random().toString(36).slice(2, 10);
var src1 = pages === 1 ? data.svg_url : '/api/music/file/' + baseId + '/svg/1';
var nav = '';
if (pages > 1) {
nav = '<div class="music-page-nav" id="' + navId + '" data-page="1" data-total="' + pages + '" data-id="' + baseId + '">' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',-1)" id="' + navId + '-prev" disabled>◀</button>' +
'<span id="' + navId + '-label">1 / ' + pages + '</span>' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',1)" id="' + navId + '-next"' + (pages <= 1 ? ' disabled' : '') + '>▶</button>' +
'</div>';
}
return nav + '<img id="' + navId + '-img" src="' + src1 + '" alt="악보" style="width:100%;height:auto;background:#fff;border-radius:8px;padding:8px;display:block" />';
}
function musicPageNav(navId, delta) {
var nav = document.getElementById(navId);
if (!nav) return;
var cur = parseInt(nav.dataset.page, 10);
var total = parseInt(nav.dataset.total, 10);
var next = cur + delta;
if (next < 1 || next > total) return;
nav.dataset.page = String(next);
var baseId = nav.dataset.id;
var img = document.getElementById(navId + '-img');
var lbl = document.getElementById(navId + '-label');
var prev = document.getElementById(navId + '-prev');
var nxt = document.getElementById(navId + '-next');
var url = '/api/music/file/' + baseId + '/svg/' + next;
if (img) img.src = url;
if (lbl) lbl.textContent = next + ' / ' + total;
if (prev) prev.disabled = next <= 1;
if (nxt) nxt.disabled = next >= total;
}
async function renderMusicBlock(widgetId, source) {
var widget = document.getElementById(widgetId);
if (!widget) return;
var btn = widget.querySelector('.music-render-btn');
var output = widget.querySelector('.music-output');
if (btn) { btn.disabled = true; btn.textContent = '렌더링 중…'; }
try {
var data = await api('/api/music/render', {
method: 'POST',
body: JSON.stringify({ source: source }),
});
if (data.error) throw new Error(data.error);
var html = buildSvgPagesHtml(data);
html += buildMusicActions(data, 'score.midi');
if (output) {
output.innerHTML = html;
output.style.display = 'block';
// Update chatHistory so the rendered output survives page reload
persistActiveChat();
}
if (btn) { btn.textContent = '다시 렌더링'; btn.disabled = false; }
} catch (e) {
if (output) {
output.innerHTML = '<span style="color:var(--err);font-size:12px">렌더링 실패: ' + escHtml(e.message) + '</span>';
output.style.display = 'block';
}
if (btn) { btn.textContent = '악보 렌더링'; btn.disabled = false; }
}
}
async function uploadMidiAndRender(file) {
var msgId = 'midi-render-' + Date.now();
appendChatBubble('system',
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">' +
'🎵 <strong>' + escHtml(file.name) + '</strong> 악보 변환 중...' +
'<br><span id="' + msgId + '-prog" style="font-size:11px;opacity:0.7">준비 중...</span>' +
'</div>'
);
try {
var form = new FormData();
form.append('file', file);
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var buf = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split('\n\n');
buf = parts.pop() || '';
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
if (!part.startsWith('data: ')) continue;
var ev;
try { ev = JSON.parse(part.slice(6)); } catch (e) { continue; }
if (ev.type === 'progress') {
var progEl = document.getElementById(msgId + '-prog');
if (progEl) progEl.textContent = ev.message;
} else if (ev.type === 'done') {
var wid = msgId + '-widget';
var html = '<div class="music-widget" id="' + wid + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + escHtml(file.name) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button onclick="toggleMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + wid + '\')" 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 class="music-output">';
if (ev.svg_url) html += buildSvgPagesHtml(ev);
html += buildMusicActions(ev, file.name);
html += '</div></div></div>';
var placeholder = document.getElementById(msgId);
if (placeholder) placeholder.outerHTML = html;
// Persist to chatHistory so it survives page reload
chatHistory.push({ role: 'widget', content: html });
persistActiveChat();
} else if (ev.type === 'error') {
var pl2 = document.getElementById(msgId);
if (pl2) pl2.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(ev.message) + '</span>';
}
}
}
} catch (e) {
var pl3 = document.getElementById(msgId);
if (pl3) pl3.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(e.message) + '</span>';
}
}
function appendChatBubble(role, html) {
var wrap = document.getElementById('chat-messages');
if (!wrap) return;
var div = document.createElement('div');
div.className = 'msg msg-' + role;
div.innerHTML = '<div class="msg-content">' + html + '</div>';
wrap.appendChild(div);
wrap.scrollTop = wrap.scrollHeight;
}
function openImageZoom(src) {
var modal = document.getElementById('image-zoom-modal');
var img = document.getElementById('image-zoom-img');
@@ -9262,6 +9819,12 @@ function handleFileUpload(input) {
input.value = '';
var name = file.name.toLowerCase();
// MIDI → 악보 변환
if (/\.(mid|midi)$/.test(name) || file.type === 'audio/midi' || file.type === 'audio/x-midi') {
uploadMidiAndRender(file);
return;
}
// 이미지
if (file.type.startsWith('image/')) {
var imgForm = new FormData();
@@ -9677,11 +10240,44 @@ function openOllamaInfo() { window.open('http://localhost:11434','_blank'); }
let skillsCache = [];
let editingSkillId = null;
let skillsTargetUser = ''; // '' = global (admin) or own username (regular user)
async function initSkillsUserSelector() {
const selector = document.getElementById('skills-user-selector');
if (!selector) return;
if (!currentUser) { selector.style.display = 'none'; return; }
if (currentUser.role !== 'admin') {
selector.style.display = 'none';
skillsTargetUser = currentUser.username || '';
return;
}
selector.style.display = 'block';
try {
const data = await api('/api/users');
const sel = document.getElementById('skills-target-user');
if (!sel || !data.users) return;
const opts = ['<option value="">글로벌 기본값</option>'];
for (const u of data.users) {
const selected = u.username === skillsTargetUser ? ' selected' : '';
opts.push(`<option value="${escHtml(u.username)}"${selected}>${escHtml(u.username)}</option>`);
}
sel.innerHTML = opts.join('');
} catch {}
}
function onSkillsUserChange() {
const sel = document.getElementById('skills-target-user');
skillsTargetUser = sel ? sel.value : '';
loadInstalledSkills();
}
async function loadInstalledSkills() {
const el = document.getElementById('skills-list');
el.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px"><span class="spinner"></span> Loading...</div>';
try {
const data = await api('/api/skills');
const effectiveUser = skillsTargetUser || (currentUser?.role !== 'admin' ? currentUser?.username : null);
const url = effectiveUser ? `/api/skills?username=${encodeURIComponent(effectiveUser)}` : '/api/skills';
const data = await api(url);
const skills = data.skills || [];
skillsCache = skills;
if (skills.length === 0) {
@@ -9689,12 +10285,13 @@ async function loadInstalledSkills() {
} else {
el.innerHTML = skills.map(s => {
const skillEmoji = (s.emoji === '🧩' || s.emoji === '🦞' || s.emoji === '⚠️') ? s.emoji : '🧩';
const globalBadge = s.globalOnly ? '<span style="font-size:9px;opacity:.6;margin-left:4px">[전체]</span>' : '';
return `
<div class="skill-card ${s.enabled ? 'enabled' : ''}" data-skill-id="${escHtml(s.id)}" onclick="toggleSkill('${escHtml(s.id)}')">
<div class="skill-card-top">
<div class="skill-card-emoji">${escHtml(skillEmoji)}</div>
<div class="skill-card-info">
<div class="skill-card-name">${escHtml(s.name)}</div>
<div class="skill-card-name">${escHtml(s.name)}${globalBadge}</div>
<div class="skill-card-desc">${escHtml(s.description || 'No description')}</div>
</div>
<div class="skill-card-actions">
@@ -9715,18 +10312,25 @@ async function loadInstalledSkills() {
async function toggleSkill(id) {
const skillMeta = (skillsCache || []).find(s => s.id === id);
// Block only when trying to enable an ineligible skill.
if (skillMeta && skillMeta.eligible === false && !skillMeta.enabled) {
addProcessEntry('error', skillMeta.eligibleReason || 'This skill is currently unavailable.');
return;
}
// globalOnly 스킬은 per-user 모드에서 토글 불가
if (skillMeta?.globalOnly && (skillsTargetUser || currentUser?.role !== 'admin')) {
addProcessEntry('error', `"${skillMeta.name}" 스킬은 글로벌 전용입니다.`);
return;
}
try {
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST' });
const effectiveUser = skillsTargetUser || (currentUser?.role !== 'admin' ? currentUser?.username : null);
const body = effectiveUser ? JSON.stringify({ username: effectiveUser }) : undefined;
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST', body });
if (!res.success) {
addProcessEntry('error', res.error || 'Failed to toggle skill.');
return;
}
addProcessEntry('info', `Skill "${res.skill.name}": ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`);
const label = res.targetUser ? ` (${res.targetUser})` : '';
addProcessEntry('info', `Skill "${res.skill.name}"${label}: ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`);
loadInstalledSkills();
} catch (err) {
addProcessEntry('error', `Failed to toggle skill: ${err.message}`);