MIDI upload flow: save to workspace, action picker (재생/편집/악보)
- music-renderer: saveMidiFile export (JSON, no LilyPond), getRenderFile prefers original.midi, fix self-copy bug - server-v2: POST /api/music/midi now saves to workspace/music/<name>/ and returns JSON immediately; new SSE endpoint /api/music/midi-score for on-demand LilyPond - index.html: buildMidiPickerWidget with color-coded buttons (green/purple/amber), MIDI download moved inside player tab, score panel default closed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -377,8 +377,8 @@ export function renderMidiAsync(
|
|||||||
const originalMidi = path.join(workDir, 'original.midi');
|
const originalMidi = path.join(workDir, 'original.midi');
|
||||||
const midiCopy = path.join(workDir, 'output.midi');
|
const midiCopy = path.join(workDir, 'output.midi');
|
||||||
try {
|
try {
|
||||||
fs.copyFileSync(midiFilePath, originalMidi);
|
if (path.resolve(originalMidi) !== path.resolve(midiFilePath)) fs.copyFileSync(midiFilePath, originalMidi);
|
||||||
fs.copyFileSync(midiFilePath, midiCopy);
|
if (path.resolve(midiCopy) !== path.resolve(midiFilePath)) fs.copyFileSync(midiFilePath, midiCopy);
|
||||||
} catch (e: any) { onError(e); return; }
|
} catch (e: any) { onError(e); return; }
|
||||||
|
|
||||||
// Step 1: midi2ly (sync, fast — typically <2s)
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+243
-14
@@ -4856,7 +4856,7 @@ async function handleChat(
|
|||||||
role: 'system',
|
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.
|
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.
|
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);
|
const header = part.substring(0, headerEnd);
|
||||||
if (!header.includes('name="image"')) continue;
|
if (!header.includes('name="image"')) continue;
|
||||||
|
|
||||||
const fnMatch = header.match(/filename="([^"]+)"/);
|
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
|
||||||
if (fnMatch) filename = fnMatch[1];
|
?? 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);
|
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||||
if (ctMatch) filetype = ctMatch[1].trim();
|
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);
|
const header = part.substring(0, headerEnd);
|
||||||
if (!header.includes('name="file"')) continue;
|
if (!header.includes('name="file"')) continue;
|
||||||
|
|
||||||
const fnMatch = header.match(/filename="([^"]+)"/);
|
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
|
||||||
if (fnMatch) filename = fnMatch[1];
|
?? 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);
|
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||||
if (ctMatch) filetype = ctMatch[1].trim();
|
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.write(text.slice(0, 2000), 'utf8');
|
||||||
py.stdin.end();
|
py.stdin.end();
|
||||||
let stderr = '';
|
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('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`)));
|
||||||
py.on('error', reject);
|
py.on('error', reject);
|
||||||
});
|
});
|
||||||
@@ -9063,7 +9076,7 @@ app.post('/api/chat', async (req, res) => {
|
|||||||
|
|
||||||
lastMainSessionId = String(sessionId || 'default');
|
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('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no');
|
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();
|
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<string, boolean> | null = null;
|
||||||
|
if (targetUser) {
|
||||||
|
userState = skillsManager.getUserState(getUserWorkspace(targetUser));
|
||||||
|
}
|
||||||
|
|
||||||
let orchestrationEligibility: { eligible: boolean; reason?: string } = { eligible: true };
|
let orchestrationEligibility: { eligible: boolean; reason?: string } = { eligible: true };
|
||||||
try {
|
try {
|
||||||
orchestrationEligibility = await checkOrchestrationEligibility();
|
orchestrationEligibility = await checkOrchestrationEligibility();
|
||||||
@@ -9233,21 +9260,27 @@ app.get('/api/skills', async (_req, res) => {
|
|||||||
|
|
||||||
const skills = skillsManager.getAll().map(s => {
|
const skills = skillsManager.getAll().map(s => {
|
||||||
const isOrchestrator = s.id === 'multi-agent-orchestrator';
|
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 {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
emoji: s.emoji,
|
emoji: s.emoji,
|
||||||
version: s.version,
|
version: s.version,
|
||||||
enabled: s.enabled,
|
enabled,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
|
globalOnly: isGlobalOnly,
|
||||||
eligible: isOrchestrator ? orchestrationEligibility.eligible : true,
|
eligible: isOrchestrator ? orchestrationEligibility.eligible : true,
|
||||||
eligibleReason: isOrchestrator
|
eligibleReason: isOrchestrator
|
||||||
? (orchestrationEligibility.eligible ? undefined : orchestrationEligibility.reason)
|
? (orchestrationEligibility.eligible ? undefined : orchestrationEligibility.reason)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
res.json({ success: true, skills });
|
res.json({ success: true, skills, targetUser: targetUser || null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ——— Skill Templates API ————————————————————————————————
|
// ——— Skill Templates API ————————————————————————————————
|
||||||
@@ -9321,7 +9354,25 @@ app.post('/api/skills/:id/toggle', async (req, res) => {
|
|||||||
const current = skillsManager.get(skillId);
|
const current = skillsManager.get(skillId);
|
||||||
if (!current) { res.status(404).json({ success: false, error: 'Skill not found' }); return; }
|
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) {
|
if (skillId === 'multi-agent-orchestrator' && !current.enabled) {
|
||||||
const eligibility = await checkOrchestrationEligibility();
|
const eligibility = await checkOrchestrationEligibility();
|
||||||
if (!eligibility.eligible) {
|
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
|
// SSE stream for live task updates
|
||||||
app.get('/api/bg-tasks/:id/stream', (req, res) => {
|
app.get('/api/bg-tasks/:id/stream', (req, res) => {
|
||||||
const taskId = req.params.id;
|
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('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no');
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
@@ -11365,6 +11416,7 @@ import { getProvider, resetProvider, buildProviderForLLM } from '../providers/fa
|
|||||||
import { buildWebhookRouter, resolveHookConfig } from './webhook-handler';
|
import { buildWebhookRouter, resolveHookConfig } from './webhook-handler';
|
||||||
import { getMCPManager } from './mcp-manager';
|
import { getMCPManager } from './mcp-manager';
|
||||||
import { startOAuthFlow, isConnected, clearTokens, loadTokens, exchangeManualCodeFromPending } from '../auth/openai-oauth';
|
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 {
|
function sanitizeLLMConfig(llm: any): any {
|
||||||
if (!llm || typeof llm !== 'object') return llm;
|
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
|
// POST /api/models/test — test connectivity for the active (or a given) provider
|
||||||
app.post('/api/models/test', async (req, res) => {
|
app.post('/api/models/test', async (req, res) => {
|
||||||
try {
|
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 provider = llmOverride ? buildProviderForLLM(llmOverride) : getProvider();
|
||||||
const ok = await provider.testConnection();
|
const ok = await provider.testConnection();
|
||||||
const models = ok ? await provider.listModels() : [];
|
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/<songName>/
|
||||||
|
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?
|
// GET /api/auth/openai/status — is the user connected via OAuth?
|
||||||
app.get('/api/auth/openai/status', (_req, res) => {
|
app.get('/api/auth/openai/status', (_req, res) => {
|
||||||
const configDir = CONFIG_DIR_PATH;
|
const configDir = CONFIG_DIR_PATH;
|
||||||
@@ -11812,7 +12041,7 @@ wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
|
|||||||
|
|
||||||
ws.on('message', async (d) => {
|
ws.on('message', async (d) => {
|
||||||
let msg: any;
|
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') {
|
if (msg.type === 'pty_spawn') {
|
||||||
const shellId = msg.shellId;
|
const shellId = msg.shellId;
|
||||||
|
|||||||
+125
-55
@@ -2324,8 +2324,19 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.music-dl-btn:hover { background: var(--hover); }
|
.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 {
|
.music-page-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -9483,8 +9494,8 @@ function buildMusicActions(data, downloadName) {
|
|||||||
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
|
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
|
||||||
+ '<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" 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" 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\')">🎹 편집</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>';
|
||||||
@@ -9563,75 +9574,134 @@ async function renderMusicBlock(widgetId, source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadMidiAndRender(file) {
|
// ─── MIDI 업로드 → 액션 피커 ──────────────────────────────────────────────────
|
||||||
var msgId = 'midi-render-' + Date.now();
|
|
||||||
appendChatBubble('system',
|
function buildMidiPickerWidget(data, filename) {
|
||||||
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">' +
|
var wid = 'midi-' + Math.random().toString(36).slice(2, 10);
|
||||||
'🎵 <strong>' + escHtml(file.name) + '</strong> 악보 변환 중...' +
|
var midiUrl = data.midi_url;
|
||||||
'<br><span id="' + msgId + '-prog" style="font-size:11px;opacity:0.7">준비 중...</span>' +
|
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
|
||||||
'</div>'
|
|
||||||
);
|
var savedLine = data.saved_to
|
||||||
|
? '<div style="font-size:11px;color:var(--muted);margin-bottom:8px">💾 ' + escHtml(data.saved_to) + '</div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// MIDI player panel (hidden by default) — includes MIDI download
|
||||||
|
var playerPanel =
|
||||||
|
'<div id="' + rid + '" class="midi-router-panel" style="display:none;margin-top:8px">' +
|
||||||
|
'<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>' +
|
||||||
|
'<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(filename) + '" style="padding:2px 8px;font-size:11px">⬇ MIDI</a>' +
|
||||||
|
'<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// 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 = '<div id="' + wid + '-score" style="margin-top:8px;display:none">' + scoreInner + '</div>';
|
||||||
|
|
||||||
|
var scoreBtnLabel = '🎼 악보';
|
||||||
|
|
||||||
|
var html =
|
||||||
|
'<div class="music-widget" id="' + wid + '">' +
|
||||||
|
'<div class="music-widget-header">' +
|
||||||
|
'<span class="music-widget-label">🎵 ' + escHtml(filename) + '</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">' +
|
||||||
|
savedLine +
|
||||||
|
'<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-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">🎹 편집</button>' +
|
||||||
|
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
playerPanel +
|
||||||
|
scoreArea +
|
||||||
|
'</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
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 = '<div style="font-size:12px;color:var(--muted);padding:8px 0">📄 악보 변환 중... <span id="' + wid + '-sprog"></span></div>';
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var form = new FormData();
|
var resp = await fetch('/api/music/midi-score', {
|
||||||
form.append('file', file);
|
method: 'POST',
|
||||||
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
|
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||||
|
body: JSON.stringify({ id: midiId }),
|
||||||
|
});
|
||||||
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
|
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
|
||||||
|
var reader = resp.body.getReader(), decoder = new TextDecoder(), buf = '';
|
||||||
var reader = resp.body.getReader();
|
|
||||||
var decoder = new TextDecoder();
|
|
||||||
var buf = '';
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
var chunk = await reader.read();
|
var chunk = await reader.read();
|
||||||
if (chunk.done) break;
|
if (chunk.done) break;
|
||||||
buf += decoder.decode(chunk.value, { stream: true });
|
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++) {
|
for (var i = 0; i < parts.length; i++) {
|
||||||
var part = parts[i];
|
if (!parts[i].startsWith('data: ')) continue;
|
||||||
if (!part.startsWith('data: ')) continue;
|
var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; }
|
||||||
var ev;
|
|
||||||
try { ev = JSON.parse(part.slice(6)); } catch (e) { continue; }
|
|
||||||
|
|
||||||
if (ev.type === 'progress') {
|
if (ev.type === 'progress') {
|
||||||
var progEl = document.getElementById(msgId + '-prog');
|
var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message;
|
||||||
if (progEl) progEl.textContent = ev.message;
|
|
||||||
|
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
var wid = msgId + '-widget';
|
scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename);
|
||||||
var html = '<div class="music-widget" id="' + wid + '">' +
|
if (btn) { btn.disabled = false; btn.textContent = '📄 닫기'; }
|
||||||
'<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') {
|
} else if (ev.type === 'error') {
|
||||||
var pl2 = document.getElementById(msgId);
|
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">악보 변환 실패: ' + escHtml(ev.message) + '</span>';
|
||||||
if (pl2) pl2.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(ev.message) + '</span>';
|
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
var pl3 = document.getElementById(msgId);
|
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">오류: ' + escHtml(e.message) + '</span>';
|
||||||
if (pl3) pl3.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(e.message) + '</span>';
|
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMidiAndRender(file) {
|
||||||
|
var msgId = 'midi-upload-' + Date.now();
|
||||||
|
appendChatBubble('system',
|
||||||
|
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">🎵 <strong>' + escHtml(file.name) + '</strong> 업로드 중...</div>'
|
||||||
|
);
|
||||||
|
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 = '<span style="color:var(--err);font-size:12px">업로드 실패: ' + escHtml(e.message) + '</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user