diff --git a/src/gateway/music-renderer.ts b/src/gateway/music-renderer.ts index d2b3f3d..7e71fe5 100644 --- a/src/gateway/music-renderer.ts +++ b/src/gateway/music-renderer.ts @@ -377,8 +377,8 @@ export function renderMidiAsync( const originalMidi = path.join(workDir, 'original.midi'); const midiCopy = path.join(workDir, 'output.midi'); try { - fs.copyFileSync(midiFilePath, originalMidi); - fs.copyFileSync(midiFilePath, midiCopy); + if (path.resolve(originalMidi) !== path.resolve(midiFilePath)) fs.copyFileSync(midiFilePath, originalMidi); + if (path.resolve(midiCopy) !== path.resolve(midiFilePath)) fs.copyFileSync(midiFilePath, midiCopy); } catch (e: any) { onError(e); return; } // Step 1: midi2ly (sync, fast β€” typically <2s) @@ -448,3 +448,29 @@ export function renderMidiAsync( } }); } + +/** Save uploaded MIDI to render dir (no LilyPond). Returns metadata for the action picker. */ +export function saveMidiFile(fileData: Buffer): { + id: string; + midiUrl: string; + hasSvg: boolean; + pageCount: number; + pdfUrl: string | null; +} { + ensureRenderDir(); + const id = crypto.createHash('sha1').update(fileData).digest('hex').slice(0, 12); + const workDir = path.join(RENDER_DIR, 'midi-' + id); + fs.mkdirSync(workDir, { recursive: true }); + const origPath = path.join(workDir, 'original.midi'); + if (!fs.existsSync(origPath)) fs.writeFileSync(origPath, fileData); + + const pages = collectSvgPages(workDir); + const pdfPath = path.join(workDir, 'output.pdf'); + return { + id: 'midi-' + id, + midiUrl: `/api/music/file/midi-${id}/midi`, + hasSvg: pages.length > 0, + pageCount: pages.length, + pdfUrl: fs.existsSync(pdfPath) ? `/api/music/file/midi-${id}/pdf` : null, + }; +} diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 9a98754..9fa9f69 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -4856,7 +4856,7 @@ 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.buildPromptContext(500)}${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.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 500)}${workflowCtx ? '\n\n' + workflowCtx : ''}`, }, ]; @@ -8676,8 +8676,14 @@ app.post('/api/upload/image', (req: express.Request, res: express.Response) => { const header = part.substring(0, headerEnd); if (!header.includes('name="image"')) continue; - const fnMatch = header.match(/filename="([^"]+)"/); - if (fnMatch) filename = fnMatch[1]; + const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i) + ?? header.match(/filename="([^"]+)"/); + if (fnMatch) { + const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1] + ? Buffer.from(fnMatch[1], 'binary').toString('utf-8') + : decodeURIComponent(fnMatch[1]); + filename = raw8; + } const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i); if (ctMatch) filetype = ctMatch[1].trim(); @@ -8755,8 +8761,15 @@ app.post('/api/upload/file', (req: express.Request, res: express.Response) => { const header = part.substring(0, headerEnd); if (!header.includes('name="file"')) continue; - const fnMatch = header.match(/filename="([^"]+)"/); - if (fnMatch) filename = fnMatch[1]; + const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i) + ?? header.match(/filename="([^"]+)"/); + if (fnMatch) { + // Body decoded as 'binary' (Latin-1) β€” recover original UTF-8 bytes + const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1] + ? Buffer.from(fnMatch[1], 'binary').toString('utf-8') + : decodeURIComponent(fnMatch[1]); + filename = raw8; + } const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i); if (ctMatch) filetype = ctMatch[1].trim(); @@ -8852,7 +8865,7 @@ app.post('/api/voice/tts', async (req: express.Request, res: express.Response) = py.stdin.write(text.slice(0, 2000), 'utf8'); py.stdin.end(); let stderr = ''; - py.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + py.stderr.on('data', (d: Buffer) => { stderr += d.toString('utf8'); }); py.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`))); py.on('error', reject); }); @@ -9063,7 +9076,7 @@ app.post('/api/chat', async (req, res) => { lastMainSessionId = String(sessionId || 'default'); - res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); @@ -9223,9 +9236,23 @@ app.post('/api/clear-history', async (req, res) => { }); -app.get('/api/skills', async (_req, res) => { +app.get('/api/skills', async (req, res) => { recoverSkillsIfEmpty(); + const session = (req as any).user as { username: string; role: string } | undefined; + const GLOBAL_ONLY = new Set(['multi-agent-orchestrator']); + + // ?username= : admin can query any user, regular user can only query themselves. + let targetUser = (req.query.username as string) || null; + if (targetUser && session?.role !== 'admin' && session?.username !== targetUser) { + targetUser = session?.username ?? null; + } + + let userState: Record | null = null; + if (targetUser) { + userState = skillsManager.getUserState(getUserWorkspace(targetUser)); + } + let orchestrationEligibility: { eligible: boolean; reason?: string } = { eligible: true }; try { orchestrationEligibility = await checkOrchestrationEligibility(); @@ -9233,21 +9260,27 @@ app.get('/api/skills', async (_req, res) => { const skills = skillsManager.getAll().map(s => { const isOrchestrator = s.id === 'multi-agent-orchestrator'; + const isGlobalOnly = GLOBAL_ONLY.has(s.id); + let enabled = s.enabled; + if (userState !== null && !isGlobalOnly && s.id in userState) { + enabled = userState[s.id]; + } return { id: s.id, name: s.name, description: s.description, emoji: s.emoji, version: s.version, - enabled: s.enabled, + enabled, createdAt: s.createdAt, + globalOnly: isGlobalOnly, eligible: isOrchestrator ? orchestrationEligibility.eligible : true, eligibleReason: isOrchestrator ? (orchestrationEligibility.eligible ? undefined : orchestrationEligibility.reason) : undefined, }; }); - res.json({ success: true, skills }); + res.json({ success: true, skills, targetUser: targetUser || null }); }); // β€”β€”β€” Skill Templates API β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” @@ -9321,7 +9354,25 @@ app.post('/api/skills/:id/toggle', async (req, res) => { const current = skillsManager.get(skillId); if (!current) { res.status(404).json({ success: false, error: 'Skill not found' }); return; } - // Guard enabling orchestration skill until config is eligible. + const session = (req as any).user as { username: string; role: string } | undefined; + const GLOBAL_ONLY = new Set(['multi-agent-orchestrator']); + const targetUser = (req.body?.username as string) || null; + + // Per-user toggle: admin can target any user; regular user can only target themselves. + if (targetUser && !GLOBAL_ONLY.has(skillId)) { + if (session?.role !== 'admin' && session?.username !== targetUser) { + res.status(403).json({ success: false, error: 'Forbidden' }); return; + } + const userDir = getUserWorkspace(targetUser); + const curState = skillsManager.getUserState(userDir); + const curEnabled = (curState && skillId in curState) ? curState[skillId] : current.enabled; + const skill = skillsManager.setUserSkillEnabled(userDir, skillId, !curEnabled); + if (!skill) { res.status(404).json({ success: false, error: 'Skill not found' }); return; } + res.json({ success: true, skill: { id: skill.id, name: skill.name, enabled: skill.enabled }, targetUser }); + return; + } + + // Global toggle (existing behavior). if (skillId === 'multi-agent-orchestrator' && !current.enabled) { const eligibility = await checkOrchestrationEligibility(); if (!eligibility.eligible) { @@ -9793,7 +9844,7 @@ app.post('/api/bg-tasks/:id/message', async (req: any, res: any) => { // SSE stream for live task updates app.get('/api/bg-tasks/:id/stream', (req, res) => { const taskId = req.params.id; - res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); @@ -11365,6 +11416,7 @@ import { getProvider, resetProvider, buildProviderForLLM } from '../providers/fa import { buildWebhookRouter, resolveHookConfig } from './webhook-handler'; import { getMCPManager } from './mcp-manager'; import { startOAuthFlow, isConnected, clearTokens, loadTokens, exchangeManualCodeFromPending } from '../auth/openai-oauth'; +import { renderMusic, renderMidi, getRenderFile, getRenderPageFile, renderMidiAsync, saveMidiFile } from './music-renderer'; function sanitizeLLMConfig(llm: any): any { if (!llm || typeof llm !== 'object') return llm; @@ -11431,7 +11483,18 @@ app.post('/api/settings/provider', (req, res) => { // POST /api/models/test β€” test connectivity for the active (or a given) provider app.post('/api/models/test', async (req, res) => { try { - const llmOverride = req.body?.llm ? sanitizeLLMConfig(req.body.llm) : null; + let llmOverride = req.body?.llm ? sanitizeLLMConfig(req.body.llm) : null; + // Replace redacted keys (β€’β€’β€’β€’β€’β€’β€’β€’) with the stored values so the UI can test + // without the user re-entering credentials after they've been saved. + if (llmOverride?.providers) { + const stored = ((getConfig().getConfig() as any)?.llm?.providers) || {}; + for (const provId of Object.keys(llmOverride.providers)) { + const p = llmOverride.providers[provId]; + if (p?.api_key === 'β€’β€’β€’β€’β€’β€’β€’β€’' && stored[provId]?.api_key) { + p.api_key = stored[provId].api_key; + } + } + } const provider = llmOverride ? buildProviderForLLM(llmOverride) : getProvider(); const ok = await provider.testConnection(); const models = ok ? await provider.listModels() : []; @@ -11441,6 +11504,172 @@ app.post('/api/models/test', async (req, res) => { } }); +// ─── Music rendering ─────────────────────────────────────────────────────────── + +// POST /api/music/render β€” render ABC/LilyPond source β†’ SVG + PDF + MIDI +app.post('/api/music/render', (req, res) => { + try { + const source = String(req.body?.source || '').trim(); + if (!source) { res.status(400).json({ error: 'Missing source' }); return; } + const result = renderMusic(source); + 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, + }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/music/midi β€” upload MIDI file β†’ save to workspace + render dir, return action picker data +app.post('/api/music/midi', (req, res) => { + const contentType = String(req.headers['content-type'] || ''); + if (!contentType.includes('multipart/form-data')) { + res.status(400).json({ error: 'Content-Type must be multipart/form-data' }); return; + } + const boundary = contentType.split('boundary=')[1]; + if (!boundary) { res.status(400).json({ error: 'Missing boundary' }); return; } + + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('binary'); + const parts = raw.split('--' + boundary); + let fileData: Buffer | null = null; + let originalFilename = 'upload.midi'; + + for (const part of parts) { + const headerEnd = part.indexOf('\r\n\r\n'); + if (headerEnd === -1) continue; + const header = part.substring(0, headerEnd); + if (!header.includes('name="file"') && !header.includes('name="midi"')) continue; + const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i) + ?? header.match(/filename="([^"]+)"/i); + if (fnMatch) { + const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1] + ? Buffer.from(fnMatch[1], 'binary').toString('utf-8') + : decodeURIComponent(fnMatch[1]); + originalFilename = raw8; + } + const bodyStart = headerEnd + 4; + const bodyEnd = part.lastIndexOf('\r\n'); + if (bodyEnd <= bodyStart) continue; + fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary'); + break; + } + + if (!fileData) { res.status(400).json({ error: 'No MIDI file found' }); return; } + if (fileData.length > 10 * 1024 * 1024) { res.status(400).json({ error: 'File too large (max 10MB)' }); return; } + + // Save to render dir (original.midi) β€” no LilyPond yet + const info = saveMidiFile(fileData); + + // Save to workspace/music// + let savedTo: string | null = null; + const uploadUser = (req as any).user; + if (uploadUser?.workspace) { + const songName = originalFilename + .replace(/\.midi?$/i, '') + .replace(/[/\\:*?"<>|]/g, '_') + .trim() || 'untitled'; + const musicDir = path.join(uploadUser.workspace, 'music', songName); + fs.mkdirSync(musicDir, { recursive: true }); + const safeName = originalFilename.replace(/[/\\:*?"<>|]/g, '_'); + const destPath = path.join(musicDir, safeName); + if (!fs.existsSync(destPath)) fs.writeFileSync(destPath, fileData); + savedTo = destPath; + } + + res.json({ + id: info.id, + filename: originalFilename, + 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: savedTo, + }); + } 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 }; + if (!id || !id.startsWith('midi-')) { res.status(400).json({ error: 'Invalid id' }); return; } + + const { getRenderFile: _rf, ..._ } = { getRenderFile }; + const origPath = getRenderFile(id, 'midi'); + if (!origPath) { res.status(404).json({ error: 'MIDI not found β€” νŒŒμΌμ„ λ‹€μ‹œ μ—…λ‘œλ“œν•˜μ„Έμš”' }); return; } + + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20_000); + const send = (data: object) => { try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}; }; + const finish = () => { clearInterval(hb); try { res.end(); } catch {} }; + + renderMidiAsync( + origPath, + (msg) => send({ type: 'progress', message: msg }), + (result) => { + send({ + type: 'done', + 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: `/api/music/file/${result.id}/midi`, + }); + finish(); + }, + (err) => { send({ type: 'error', message: err.message }); finish(); }, + ); +}); + +// GET /api/music/file/:id/svg β€” serve page 1 SVG +app.get('/api/music/file/:id/svg', (req, res) => { + const filePath = getRenderFile(req.params.id, 'svg'); + if (!filePath) { res.status(404).json({ error: 'Not found' }); return; } + res.setHeader('Content-Type', 'image/svg+xml'); + res.sendFile(filePath); +}); + +// GET /api/music/file/:id/svg/:page β€” serve specific page (1-indexed) +app.get('/api/music/file/:id/svg/:page', (req, res) => { + const page = parseInt(req.params.page, 10); + const filePath = isNaN(page) ? null : getRenderPageFile(req.params.id, page); + if (!filePath) { res.status(404).json({ error: 'Not found' }); return; } + res.setHeader('Content-Type', 'image/svg+xml'); + res.sendFile(filePath); +}); + +// GET /api/music/file/:id/pdf β€” serve rendered PDF +app.get('/api/music/file/:id/pdf', (req, res) => { + const filePath = getRenderFile(req.params.id, 'pdf'); + if (!filePath) { res.status(404).json({ error: 'Not found' }); return; } + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename="score.pdf"'); + res.sendFile(filePath); +}); + +// GET /api/music/file/:id/midi β€” serve rendered MIDI +app.get('/api/music/file/:id/midi', (req, res) => { + const filePath = getRenderFile(req.params.id, 'midi'); + if (!filePath) { res.status(404).json({ error: 'Not found' }); return; } + res.setHeader('Content-Type', 'audio/midi'); + res.sendFile(filePath); +}); + // GET /api/auth/openai/status β€” is the user connected via OAuth? app.get('/api/auth/openai/status', (_req, res) => { const configDir = CONFIG_DIR_PATH; @@ -11812,7 +12041,7 @@ wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => { ws.on('message', async (d) => { let msg: any; - try { msg = JSON.parse(d.toString()); } catch { return; } + try { msg = JSON.parse(d.toString('utf8')); } catch { return; } if (msg.type === 'pty_spawn') { const shellId = msg.shellId; diff --git a/web-ui/index.html b/web-ui/index.html index 7b91b2c..0e7be59 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -2324,8 +2324,19 @@ cursor: pointer; text-decoration: none; font-family: inherit; + display: inline-flex; + align-items: center; + gap: 5px; } .music-dl-btn:hover { background: var(--hover); } + .music-dl-btn.btn-green { background:#0d2b18; border-color:#2ea043; color:#3fb950; } + .music-dl-btn.btn-green:hover { background:#163d22; } + .music-dl-btn.btn-purple { background:#1e1040; border-color:#7c3aed; color:#a78bfa; } + .music-dl-btn.btn-purple:hover { background:#281552; } + .music-dl-btn.btn-amber { background:#2d1d04; border-color:#d97706; color:#fbbf24; } + .music-dl-btn.btn-amber:hover { background:#3d2806; } + .music-dl-btn.btn-blue { background:#071d3a; border-color:#2563eb; color:#60a5fa; } + .music-dl-btn.btn-blue:hover { background:#0a2848; } .music-page-nav { display: flex; align-items: center; @@ -9483,8 +9494,8 @@ function buildMusicActions(data, downloadName) { + '' + '' + ''; - html += ''; - html += ''; + html += ''; + html += ''; html += '⬇ MIDI'; } if (pdfUrl) html += '⬇ PDF'; @@ -9563,75 +9574,134 @@ async function renderMusicBlock(widgetId, source) { } } -async function uploadMidiAndRender(file) { - var msgId = 'midi-render-' + Date.now(); - appendChatBubble('system', - '
' + - '🎡 ' + escHtml(file.name) + ' 악보 λ³€ν™˜ 쀑...' + - '
μ€€λΉ„ 쀑...' + - '
' - ); +// ─── MIDI μ—…λ‘œλ“œ β†’ μ•‘μ…˜ 피컀 ────────────────────────────────────────────────── + +function buildMidiPickerWidget(data, filename) { + var wid = 'midi-' + Math.random().toString(36).slice(2, 10); + var midiUrl = data.midi_url; + var rid = 'mr-' + Math.random().toString(36).slice(2, 10); + + var savedLine = data.saved_to + ? '
πŸ’Ύ ' + escHtml(data.saved_to) + '
' + : ''; + + // MIDI player panel (hidden by default) β€” includes MIDI download + var playerPanel = + ''; + + // Score area β€” always hidden by default; content pre-filled if already rendered + var scoreInner = (data.has_svg && data.svg_url) + ? buildSvgPagesHtml(data) + buildMusicActions(data, filename) + : ''; + var scoreArea = ''; + + var scoreBtnLabel = '🎼 악보'; + + var html = + '
' + + '
' + + '🎡 ' + escHtml(filename) + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + savedLine + + '
' + + '' + + '' + + '' + + '
' + + playerPanel + + scoreArea + + '
' + + '
'; + return { wid, html }; +} + +async function midiPickerScore(wid, midiId, filename) { + var scoreDiv = document.getElementById(wid + '-score'); + var btn = document.getElementById(wid + '-score-btn'); + if (!scoreDiv) return; + + // If score already rendered, toggle visibility + if (scoreDiv.style.display !== 'none' && scoreDiv.innerHTML.trim()) { + scoreDiv.style.display = 'none'; + if (btn) btn.textContent = 'πŸ“„ 악보'; + return; + } + if (scoreDiv.innerHTML.trim()) { + scoreDiv.style.display = ''; + if (btn) btn.textContent = 'πŸ“„ λ‹«κΈ°'; + return; + } + + // Show progress + scoreDiv.style.display = ''; + scoreDiv.innerHTML = '
πŸ“„ 악보 λ³€ν™˜ 쀑...
'; + if (btn) btn.disabled = true; try { - var form = new FormData(); - form.append('file', file); - var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form }); + var resp = await fetch('/api/music/midi-score', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), + body: JSON.stringify({ id: midiId }), + }); if (!resp.ok || !resp.body) throw new Error('μ„œλ²„ 였λ₯˜ ' + resp.status); - - var reader = resp.body.getReader(); - var decoder = new TextDecoder(); - var buf = ''; + var reader = resp.body.getReader(), decoder = new TextDecoder(), 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() || ''; - + 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 (!parts[i].startsWith('data: ')) continue; + var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; } if (ev.type === 'progress') { - var progEl = document.getElementById(msgId + '-prog'); - if (progEl) progEl.textContent = ev.message; - + var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message; } else if (ev.type === 'done') { - var wid = msgId + '-widget'; - var html = '
' + - '
' + - '🎡 ' + escHtml(file.name) + '' + - '
' + - '' + - '' + - '
' + - '
' + - '
' + - '
'; - if (ev.svg_url) html += buildSvgPagesHtml(ev); - html += buildMusicActions(ev, file.name); - html += '
'; - - 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(); - + scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename); + if (btn) { btn.disabled = false; btn.textContent = 'πŸ“„ λ‹«κΈ°'; } } else if (ev.type === 'error') { - var pl2 = document.getElementById(msgId); - if (pl2) pl2.outerHTML = 'MIDI λ³€ν™˜ μ‹€νŒ¨: ' + escHtml(ev.message) + ''; + scoreDiv.innerHTML = '악보 λ³€ν™˜ μ‹€νŒ¨: ' + escHtml(ev.message) + ''; + if (btn) { btn.disabled = false; btn.textContent = 'πŸ“„ 악보'; } } } } } catch (e) { - var pl3 = document.getElementById(msgId); - if (pl3) pl3.outerHTML = 'MIDI λ³€ν™˜ μ‹€νŒ¨: ' + escHtml(e.message) + ''; + scoreDiv.innerHTML = '였λ₯˜: ' + escHtml(e.message) + ''; + if (btn) { btn.disabled = false; btn.textContent = 'πŸ“„ 악보'; } + } +} + +async function uploadMidiAndRender(file) { + var msgId = 'midi-upload-' + Date.now(); + appendChatBubble('system', + '
🎡 ' + escHtml(file.name) + ' μ—…λ‘œλ“œ 쀑...
' + ); + try { + var form = new FormData(); + form.append('file', file); + var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form }); + var data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'μ„œλ²„ 였λ₯˜ ' + resp.status); + + var result = buildMidiPickerWidget(data, file.name); + var placeholder = document.getElementById(msgId); + if (placeholder) placeholder.outerHTML = result.html; + chatHistory.push({ role: 'widget', content: result.html }); + persistActiveChat(); + } catch (e) { + var pl = document.getElementById(msgId); + if (pl) pl.outerHTML = 'μ—…λ‘œλ“œ μ‹€νŒ¨: ' + escHtml(e.message) + ''; } }