Files
node-red-als/data/flows.json

393 lines
29 KiB
JSON

[
{
"id": "f68df1c4d2e4e1a9",
"type": "tab",
"label": "ALS Inspector",
"disabled": false,
"info": "Dashboard para analizar archivos Ableton Live (.als)"
},
{
"id": "c1ef7251baee3fe8",
"type": "ui_tab",
"name": "Ableton ALS",
"icon": "dashboard",
"disabled": false,
"hidden": false
},
{
"id": "2997d0427c0f3a34",
"type": "ui_group",
"name": "Carga",
"tab": "c1ef7251baee3fe8",
"order": 1,
"disp": true,
"width": "12",
"collapse": false,
"className": ""
},
{
"id": "f93a3673d299cf3c",
"type": "ui_group",
"name": "Resumen",
"tab": "c1ef7251baee3fe8",
"order": 2,
"disp": true,
"width": "12",
"collapse": false,
"className": ""
},
{
"id": "9eae4d30c9181132",
"type": "ui_template",
"z": "f68df1c4d2e4e1a9",
"group": "2997d0427c0f3a34",
"name": "Subir ALS",
"order": 0,
"width": "12",
"height": "7",
"format": "<md-card class=\"als-upload-card\">\n <md-card-title>\n <span class=\"md-headline\">Inspector de proyectos ALS</span>\n </md-card-title>\n <md-card-content>\n <p>Selecciona un archivo <code>.als</code>. Se enviar\u00e1 al endpoint <code>/als/upload</code> y, si es nuevo, se almacenar\u00e1 en la biblioteca.</p>\n <input type=\"file\" accept=\".als\" id=\"als-input-{{$id}}\" style=\"margin-top:12px\" />\n <p class=\"md-caption\" ng-if=\"statusText\">{{statusText}}</p>\n </md-card-content>\n</md-card>\n<script>\n(function(scope) {\n const inputId = 'als-input-' + scope.$id;\n function setStatus(text) {\n scope.$applyAsync(function() { scope.statusText = text; });\n }\n function uploadFile(file) {\n if (!file) { return; }\n setStatus('Subiendo ' + file.name + '...');\n const reader = new FileReader();\n reader.onload = function(evt) {\n setStatus('Analizando ' + file.name + '...');\n fetch('/als/upload', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename: file.name, size: file.size, data: evt.target.result })\n })\n .then(async (res) => {\n const text = await res.text();\n let payload = null;\n try { payload = text ? JSON.parse(text) : {}; } catch (err) {}\n if (!res.ok) {\n throw new Error(payload && payload.error ? payload.error : ('HTTP ' + res.status));\n }\n setStatus('An\u00e1lisis guardado para ' + (payload.projectName || file.name));\n const inputEl = document.getElementById(inputId);\n if (inputEl) { inputEl.value = null; }\n return payload;\n })\n .catch((err) => {\n console.error('ALS upload error', err);\n setStatus('Error: ' + (err.message || 'del servidor'));\n });\n };\n reader.onerror = function(e) {\n console.error('No se pudo leer el archivo ALS', e);\n setStatus('No se pudo leer el archivo.');\n };\n reader.readAsDataURL(file);\n }\n function bindInput() {\n const input = document.getElementById(inputId);\n if (!input) { return; }\n input.addEventListener('change', function(evt) {\n uploadFile(evt.target.files[0]);\n });\n }\n setTimeout(bindInput, 0);\n})(scope);\n</script>",
"storeOutMessages": false,
"fwdInMessages": false,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 230,
"y": 100,
"wires": []
},
{
"id": "f9c6054bed66e56e",
"type": "ui_template",
"z": "f68df1c4d2e4e1a9",
"group": "f93a3673d299cf3c",
"name": "Resumen ALS",
"order": 0,
"width": "12",
"height": "14",
"format": "<md-card class=\"als-summary-card\">\n <md-card-title>\n <span class=\"md-headline\">Resumen del proyecto</span>\n </md-card-title>\n <md-card-content ng-if=\"msg.payload\">\n <div class=\"summary-grid\">\n <div class=\"stat\">\n <div class=\"label\">Proyecto</div>\n <div class=\"value\">{{msg.payload.projectName}}</div>\n </div>\n <div class=\"stat\">\n <div class=\"label\">Tempo</div>\n <div class=\"value\">{{msg.payload.liveSet.tempo || 'N/D'}} BPM</div>\n </div>\n <div class=\"stat\">\n <div class=\"label\">Duraci\u00f3n aprox.</div>\n <div class=\"value\">{{msg.payload.liveSet.durationHuman || 'N/D'}}</div>\n </div>\n <div class=\"stat\">\n <div class=\"label\">Escenas</div>\n <div class=\"value\">{{msg.payload.stats.scenes}}</div>\n </div>\n <div class=\"stat\">\n <div class=\"label\">Samples</div>\n <div class=\"value\">{{msg.payload.samples.total}}</div>\n </div>\n <div class=\"stat\">\n <div class=\"label\">Dispositivos</div>\n <div class=\"value\">{{msg.payload.stats.devices}}</div>\n </div>\n <div class=\"stat\">\n <div class=\"label\">Hash</div>\n <div class=\"value\">{{msg.payload.hash | limitTo:12}}\u2026</div>\n </div>\n <div class=\"stat\">\n <div class=\"label\">Guardado</div>\n <div class=\"value\">{{msg.payload.storedAt | date:'medium'}}</div>\n </div>\n </div>\n <div class=\"meta-block\">\n <p><strong>Versi\u00f3n:</strong> {{msg.payload.meta.creator || 'Desconocido'}} {{msg.payload.meta.version}}</p>\n <p><strong>Archivo:</strong> {{msg.payload.meta.fileName || '\u2014'}} \u00b7 {{msg.payload.meta.sizeHuman}}</p>\n </div>\n <h4>Tracks ({{msg.payload.stats.totalTracks}})</h4>\n <div class=\"track-list\" ng-if=\"msg.payload.tracks.length\">\n <div class=\"track\" ng-repeat=\"track in msg.payload.tracks\">\n <div class=\"track-name\">{{track.name || '(sin nombre)'}} </div>\n <div class=\"track-meta\">{{track.type}} \u00b7 {{track.deviceCount}} dispositivos</div>\n </div>\n </div>\n <h4 ng-if=\"msg.payload.liveSet.scenes.length\">Escenas</h4>\n <md-list ng-if=\"msg.payload.liveSet.scenes.length\">\n <md-list-item ng-repeat=\"scene in msg.payload.liveSet.scenes\">\n <p>{{scene || '(sin nombre)'}} </p>\n </md-list-item>\n </md-list>\n <h4 ng-if=\"msg.payload.samples.relative.length\">Muestras referenciadas</h4>\n <md-list ng-if=\"msg.payload.samples.relative.length\">\n <md-list-item ng-repeat=\"path in msg.payload.samples.relative\">\n <p>{{path}}</p>\n </md-list-item>\n </md-list>\n </md-card-content>\n <md-card-content ng-if=\"!msg.payload && !msg.error\">\n <p>Sube un archivo <code>.als</code> para ver el an\u00e1lisis y guardarlo.</p>\n </md-card-content>\n <md-card-content ng-if=\"msg.error\">\n <p class=\"md-warn\">{{msg.error}}</p>\n </md-card-content>\n</md-card>\n<style>\n .als-summary-card h4 { margin-top: 16px; }\n .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }\n .summary-grid .stat { background: rgba(0,0,0,0.05); padding: 8px 12px; border-radius: 6px; }\n .summary-grid .label { font-size: 0.8em; text-transform: uppercase; color: #666; }\n .summary-grid .value { font-size: 1.1em; font-weight: 600; }\n .meta-block { margin-top: 12px; }\n .track-list .track { padding: 6px 0; border-bottom: 1px solid rgba(0,0,0,0.08); }\n .track-name { font-weight: 600; }\n .track-meta { font-size: 0.85em; color: #555; }\n</style>",
"storeOutMessages": false,
"fwdInMessages": false,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 1240,
"y": 320,
"wires": []
},
{
"id": "7d93f766ed4124fd",
"type": "http in",
"z": "f68df1c4d2e4e1a9",
"name": "POST /als/upload",
"url": "/als/upload",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 180,
"y": 320,
"wires": [
[
"5df1cfb22fbc2a0c"
]
]
},
{
"id": "5df1cfb22fbc2a0c",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "JSON base64 \u2192 Buffer",
"func": "const body = msg.payload;\nif (!body || typeof body.data !== 'string') {\n msg.error = 'No se recibi\u00f3 el archivo en el formato esperado.';\n msg.statusCode = 400;\n msg.analysis = null;\n return [null, msg];\n}\nconst commaIndex = body.data.indexOf(',');\nconst base64 = commaIndex > -1 ? body.data.slice(commaIndex + 1) : body.data;\ntry {\n const buffer = Buffer.from(base64, 'base64');\n msg.payload = buffer;\n msg.originalBuffer = buffer;\n msg.filename = body.filename || 'upload.als';\n msg.size = body.size || buffer.length;\n msg.statusText = 'Archivo recibido (' + Math.round(buffer.length / 1024) + ' KB)';\n return [msg, null];\n} catch (err) {\n node.error('No se pudo decodificar el archivo ALS', err);\n msg.error = 'No se pudo decodificar el archivo ALS.';\n msg.statusCode = 400;\n msg.analysis = null;\n return [null, msg];\n}",
"outputs": 2,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 460,
"y": 320,
"wires": [
[
"84ef635c6b70bb7c"
],
[
"4bcaddf8a5c2b6c9"
]
]
},
{
"id": "84ef635c6b70bb7c",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "Inspector ALS",
"func": "function toArray(value) {\n if (!value) { return []; }\n return Array.isArray(value) ? value : [value];\n}\nfunction readName(entity) {\n if (!entity || !entity.Name) { return ''; }\n const name = entity.Name;\n if (name.EffectiveName && name.EffectiveName.Value) { return name.EffectiveName.Value; }\n if (name.UserName && name.UserName.Value) { return name.UserName.Value; }\n if (name.Value) { return name.Value; }\n return '';\n}\nfunction countDevices(node) {\n if (!node || typeof node !== 'object') { return 0; }\n let total = 0;\n Object.values(node).forEach((value) => {\n if (!value) { return; }\n if (Array.isArray(value)) {\n total += value.length;\n } else if (typeof value === 'object') {\n total += 1;\n }\n });\n return total;\n}\nfunction humanFileSize(bytes) {\n if (!bytes && bytes !== 0) { return 'N/D'; }\n if (bytes < 1024) { return bytes + ' B'; }\n const units = ['KB', 'MB', 'GB', 'TB'];\n let value = bytes / 1024;\n let unit = units[0];\n for (let i = 0; i < units.length; i += 1) {\n unit = units[i];\n if (value < 1024 || i === units.length - 1) { break; }\n value /= 1024;\n }\n return value.toFixed(1) + ' ' + unit;\n}\nfunction secondsToHuman(seconds) {\n if (!seconds && seconds !== 0) { return null; }\n const total = Math.round(seconds);\n const mins = Math.floor(total / 60);\n const secs = String(total % 60).padStart(2, '0');\n return mins + ':' + secs;\n}\nfunction handlePathField(field, bucket) {\n if (!field) { return; }\n if (typeof field === 'string') {\n bucket.add(field);\n } else if (Array.isArray(field)) {\n field.forEach((item) => handlePathField(item, bucket));\n } else if (typeof field === 'object' && field.Value) {\n bucket.add(field.Value);\n }\n}\nfunction collectRefs(node, bag) {\n if (!node || typeof node !== 'object') { return; }\n handlePathField(node.RelativePath, bag.relative);\n handlePathField(node.Path, bag.absolute);\n Object.values(node).forEach((value) => {\n if (Array.isArray(value)) {\n value.forEach((item) => collectRefs(item, bag));\n } else if (value && typeof value === 'object') {\n collectRefs(value, bag);\n }\n });\n}\nif (!Buffer.isBuffer(msg.payload)) {\n msg.error = 'No se recibi\u00f3 un Buffer con el ALS.';\n msg.analysis = null;\n msg.statusCode = 400;\n return msg;\n}\nconst originalSize = msg.size || msg.payload.length;\nlet xmlBuffer;\ntry {\n xmlBuffer = zlib.gunzipSync(msg.payload);\n} catch (err) {\n msg.error = 'El archivo no parece ser un ALS v\u00e1lido (fall\u00f3 la descompresi\u00f3n).';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nlet doc;\ntry {\n const ParserClass = fastXmlParser.XMLParser || fastXmlParser;\n const parser = new ParserClass({\n ignoreAttributes: false,\n attributeNamePrefix: '',\n parseAttributeValue: false,\n allowBooleanAttributes: true\n });\n doc = parser.parse(xmlBuffer.toString('utf8'));\n} catch (err) {\n msg.error = 'No se pudo parsear el XML del proyecto.';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nconst ableton = doc?.Ableton || {};\nconst liveSet = ableton.LiveSet || {};\nconst projectName = readName(liveSet) || msg.filename || 'Proyecto sin nombre';\nconst tempoValue = liveSet?.MainTrack?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? liveSet?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? null;\nconst tempo = tempoValue ? Number(tempoValue) : null;\nconst loopLengthBeats = Number(liveSet?.Transport?.LoopLength?.Value) || null;\nconst durationSeconds = tempo && loopLengthBeats ? (loopLengthBeats * 60) / tempo : null;\nconst trackBuckets = [\n { key: 'AudioTrack', label: 'Audio', stat: 'audio' },\n { key: 'MidiTrack', label: 'MIDI', stat: 'midi' },\n { key: 'GroupTrack', label: 'Grupo', stat: 'group' }\n];\nconst stats = { audio: 0, midi: 0, group: 0, devices: 0 };\nconst tracks = [];\nconst trackNode = liveSet?.Tracks || {};\ntrackBuckets.forEach((bucket) => {\n toArray(trackNode[bucket.key]).forEach((track) => {\n stats[bucket.stat] += 1;\n const deviceCount = countDevices(track?.DeviceChain?.DeviceChain?.Devices);\n stats.devices += deviceCount;\n tracks.push({\n name: readName(track) || bucket.label + ' ' + stats[bucket.stat],\n type: bucket.label,\n deviceCount,\n color: track?.Color?.Value || null\n });\n });\n});\nconst scenes = toArray(liveSet?.Scenes?.Scene)\n .map((scene) => readName(scene))\n .filter((name) => name !== '');\nconst sampleRefs = { relative: new Set(), absolute: new Set() };\ncollectRefs(liveSet, sampleRefs);\nconst sampleSummary = {\n total: sampleRefs.relative.size || sampleRefs.absolute.size,\n relative: Array.from(sampleRefs.relative).slice(0, 15),\n absolute: Array.from(sampleRefs.absolute).slice(0, 10)\n};\nconst hash = crypto.createHash('md5').update(msg.payload).digest('hex');\nmsg.analysis = {\n hash,\n projectName,\n meta: {\n creator: ableton.Creator || null,\n version: [ableton.MajorVersion, ableton.MinorVersion].filter(Boolean).join('.'),\n revision: ableton.Revision || null,\n fileName: msg.filename || null,\n sizeBytes: originalSize,\n sizeHuman: humanFileSize(originalSize)\n },\n liveSet: {\n tempo,\n loopLengthBeats,\n durationSeconds,\n durationHuman: secondsToHuman(durationSeconds),\n loopStart: Number(liveSet?.Transport?.LoopStart?.Value) || 0,\n scenes\n },\n tracks,\n stats: {\n audio: stats.audio,\n midi: stats.midi,\n group: stats.group,\n totalTracks: tracks.length,\n devices: stats.devices,\n scenes: scenes.length,\n samples: sampleSummary.total\n },\n samples: sampleSummary\n};\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis generado';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [
{
"var": "fastXmlParser",
"module": "fast-xml-parser"
},
{
"var": "zlib",
"module": "zlib"
},
{
"var": "crypto",
"module": "crypto"
}
],
"x": 750,
"y": 300,
"wires": [
[
"b0b4475c1cd1a2f1"
]
]
},
{
"id": "b0b4475c1cd1a2f1",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "Registrar ALS",
"func": "const DB_PATH = '/data/als-library.json';\nconst LIB_DIR = '/data/library';\nfunction readLibrary() {\n try {\n const raw = fs.readFileSync(DB_PATH, 'utf8');\n return raw ? JSON.parse(raw) : [];\n } catch (err) {\n if (err.code === 'ENOENT') { return []; }\n node.error('No se pudo leer la biblioteca ALS', err);\n throw err;\n }\n}\nfunction writeLibrary(data) {\n fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2));\n}\nif (!msg.analysis || !msg.analysis.hash) {\n msg.error = 'No se pudo guardar el an\u00e1lisis (hash faltante).';\n msg.statusCode = msg.statusCode || 400;\n msg.analysis = null;\n return msg;\n}\nconst library = readLibrary();\nconst existing = library.find((entry) => entry.hash === msg.analysis.hash);\nif (existing) {\n msg.error = 'Este archivo ALS ya fue registrado anteriormente.';\n msg.statusCode = 409;\n msg.statusText = 'Archivo duplicado';\n msg.analysis = null;\n return msg;\n}\nconst fileName = msg.analysis.hash + '.als';\nconst filePath = path.join(LIB_DIR, fileName);\nfs.mkdirSync(LIB_DIR, { recursive: true });\nif (msg.originalBuffer && Buffer.isBuffer(msg.originalBuffer)) {\n fs.writeFileSync(filePath, msg.originalBuffer);\n}\nconst record = {\n ...msg.analysis,\n storedAt: new Date().toISOString(),\n filePath: path.posix.join('library', fileName)\n};\nlibrary.push(record);\nwriteLibrary(library);\nmsg.analysis = record;\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis almacenado';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [
{
"var": "fs",
"module": "fs"
},
{
"var": "path",
"module": "path"
}
],
"x": 980,
"y": 300,
"wires": [
[
"d2bc9fd9a04fde13",
"f64b6c9ce8b18591"
]
]
},
{
"id": "4bcaddf8a5c2b6c9",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "Solicitud inv\u00e1lida",
"func": "if (!msg.error) {\n msg.error = 'No se pudo procesar el archivo.';\n}\nif (!msg.statusCode) {\n msg.statusCode = 400;\n}\nmsg.analysis = null;\nmsg.statusText = 'Error al analizar el archivo';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 760,
"y": 400,
"wires": [
[
"d2bc9fd9a04fde13",
"f64b6c9ce8b18591"
]
]
},
{
"id": "d2bc9fd9a04fde13",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "Preparar respuesta HTTP",
"func": "if (!msg.statusCode) {\n msg.statusCode = msg.error ? 400 : 200;\n}\nmsg.payload = msg.error ? { error: msg.error } : msg.analysis;\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1240,
"y": 240,
"wires": [
[
"3c1fe83bb7f4a173"
]
]
},
{
"id": "3c1fe83bb7f4a173",
"type": "http response",
"z": "f68df1c4d2e4e1a9",
"name": "",
"statusCode": "",
"headers": {},
"x": 1440,
"y": 240,
"wires": []
},
{
"id": "f64b6c9ce8b18591",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "Enviar al dashboard",
"func": "return {\n payload: msg.analysis,\n error: msg.error || null,\n statusText: msg.statusText || ''\n};",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1240,
"y": 380,
"wires": [
[
"f9c6054bed66e56e",
"dc3db36cf0f12d58"
]
]
},
{
"id": "dc3db36cf0f12d58",
"type": "debug",
"z": "f68df1c4d2e4e1a9",
"name": "Resumen debug",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"statusVal": "",
"statusType": "auto",
"x": 1450,
"y": 440,
"wires": []
},
{
"id": "5d10419c2babf6bc",
"type": "http in",
"z": "f68df1c4d2e4e1a9",
"name": "GET /als/library",
"url": "/als/library",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 200,
"y": 500,
"wires": [
[
"8c33f8cbb1c2669c"
]
]
},
{
"id": "8c33f8cbb1c2669c",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "Listar biblioteca ALS",
"func": "const DB_PATH = '/data/als-library.json';\ntry {\n const raw = fs.readFileSync(DB_PATH, 'utf8');\n const data = raw ? JSON.parse(raw) : [];\n msg.payload = data;\n msg.statusCode = 200;\n} catch (err) {\n if (err.code === 'ENOENT') {\n msg.payload = [];\n msg.statusCode = 200;\n } else {\n node.error('No se pudo leer la biblioteca ALS', err);\n msg.statusCode = 500;\n msg.payload = { error: 'No se pudo leer la biblioteca ALS' };\n }\n}\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [
{
"var": "fs",
"module": "fs"
}
],
"x": 480,
"y": 500,
"wires": [
[
"0f16a9fca5dce956"
]
]
},
{
"id": "0f16a9fca5dce956",
"type": "http response",
"z": "f68df1c4d2e4e1a9",
"name": "",
"statusCode": "",
"headers": {},
"x": 700,
"y": 500,
"wires": []
},
{
"id": "c87f0e0d1bdbf964",
"type": "ui_group",
"name": "Chatbot",
"tab": "c1ef7251baee3fe8",
"order": 3,
"disp": true,
"width": "12",
"collapse": false,
"className": ""
},
{
"id": "85899363d75b2e10",
"type": "ui_template",
"z": "f68df1c4d2e4e1a9",
"group": "c87f0e0d1bdbf964",
"name": "Chatbot UI",
"order": 0,
"width": "12",
"height": "10",
"format": "\n<md-card class=\"chatbot-card\">\n <md-card-title>\n <span class=\"md-headline\">Chatbot ALS</span>\n </md-card-title>\n <md-card-content>\n <div class=\"chat-log\">\n <div ng-repeat=\"item in messages\" class=\"message {{item.role}}\">\n <strong>{{item.role === 'user' ? 'T\u00fa' : (item.role === 'bot' ? 'ALS Bot' : 'Error')}}:</strong>\n <span>{{item.text}}</span>\n </div>\n </div>\n <form ng-submit=\"sendMessage()\">\n <md-input-container class=\"md-block\">\n <label>Escribe algo (ej. generame un als de reggaeton 2001)</label>\n <input ng-model=\"promptText\" required ng-disabled=\"busy\" />\n </md-input-container>\n <md-button class=\"md-raised md-primary\" type=\"submit\" ng-disabled=\"busy\">{{busy ? 'Generando...' : 'Enviar'}}</md-button>\n </form>\n </md-card-content>\n</md-card>\n<script>\n(function(scope) {\n scope.messages = [];\n scope.promptText = '';\n scope.busy = false;\n function pushMessage(role, text) {\n scope.messages.push({ role: role, text: text });\n if (scope.messages.length > 50) {\n scope.messages.shift();\n }\n scope.$applyAsync();\n }\n scope.sendMessage = function() {\n if (!scope.promptText || scope.busy) { return; }\n const text = scope.promptText;\n scope.promptText = '';\n pushMessage('user', text);\n scope.busy = true;\n fetch('/als/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ prompt: text })\n })\n .then(function(res) {\n return res.text().then(function(body) {\n var data;\n try { data = body ? JSON.parse(body) : {}; } catch (err) { data = { error: body || err.message }; }\n return { ok: res.ok, data: data };\n });\n })\n .then(function(result) {\n if (result.ok) {\n if (result.data && result.data.projectName) {\n var message = 'Proyecto generado: ' + result.data.projectName + '. Archivo: ' + result.data.outputPath;\n pushMessage('bot', message);\n } else if (result.data && result.data.reply) {\n pushMessage('bot', result.data.reply);\n } else {\n pushMessage('bot', 'Listo. \u00bfQuieres que genere un ALS?');\n }\n } else {\n pushMessage('error', result.data && result.data.error ? result.data.error : 'Error generando ALS');\n }\n })\n .catch(function(err) {\n pushMessage('error', err.message || 'Error inesperado');\n })\n .finally(function() {\n scope.busy = false;\n scope.$applyAsync();\n });\n };\n})(scope);\n</script>\n<style>\n .chatbot-card .chat-log {\n max-height: 260px;\n overflow-y: auto;\n margin-bottom: 12px;\n padding: 8px;\n background: rgba(0,0,0,0.05);\n border-radius: 6px;\n }\n .chatbot-card .message { margin-bottom: 6px; }\n .chatbot-card .message.user strong { color: #1976d2; }\n .chatbot-card .message.bot strong { color: #2e7d32; }\n .chatbot-card .message.error strong { color: #c62828; }\n</style>\n",
"storeOutMessages": false,
"fwdInMessages": false,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
"x": 240,
"y": 520,
"wires": []
},
{
"id": "b6a5b6ec8f7a4c33",
"type": "http in",
"z": "f68df1c4d2e4e1a9",
"name": "POST /als/chat",
"url": "/als/chat",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 220,
"y": 660,
"wires": [
[
"ca7c4bb1e81b009a"
]
]
},
{
"id": "ca7c4bb1e81b009a",
"type": "function",
"z": "f68df1c4d2e4e1a9",
"name": "Chatbot ALS",
"func": "\nconst raw = typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload || {});\nlet body = {};\ntry {\n body = typeof msg.payload === 'object' ? msg.payload : JSON.parse(raw);\n} catch (err) {\n body = {};\n}\nconst prompt = (body.prompt || body.message || msg.prompt || '').trim();\nconst chatHistory = context.get('chatHistory') || [];\nfunction remember(role, text) {\n if (!text) { return; }\n chatHistory.push({ role, text });\n if (chatHistory.length > 40) {\n chatHistory.shift();\n }\n context.set('chatHistory', chatHistory);\n}\nif (!prompt) {\n msg.statusCode = 400;\n msg.payload = { error: 'Falta el prompt del chatbot.' };\n return msg;\n}\nconst generator = global.get('alsGenerator');\nif (!generator || typeof generator.generateFromPrompt !== 'function') {\n msg.statusCode = 500;\n msg.payload = { error: 'Generador no disponible.' };\n return msg;\n}\nremember('user', prompt);\nconst info = generator.listLibrary ? generator.listLibrary() : [];\nconst sources = generator.listSources ? generator.listSources() : [];\nfunction shouldGenerate(text) {\n return /(genera(me)?|crea(me)?|haz(me)?|arma(me)?|produce(me)?).*(als|ableton|beat|proyecto)/i.test(text);\n}\nfunction shouldDescribeSources(text) {\n return /(source|sources|stems|loops|samples|archivos|carpeta)/i.test(text);\n}\nfunction describeSources(list) {\n if (!list || !list.length) {\n return 'Todav\u00eda no hay archivos en data/sources/. Copia stems o loops ah\u00ed para que puedan usarse como referencia.';\n }\n const preview = list.slice(0, 10).join(', ');\n return `Encontr\u00e9 ${list.length} archivos en data/sources/. Ejemplos: ${preview}${list.length > 10 ? ' ...' : ''}`;\n}\nfunction buildSummary(text, libraryList, sourceList) {\n if (!libraryList || !libraryList.length) {\n return 'A\u00fan no tengo proyectos cargados. Sube un .als primero y luego dime \"generame un als ...\" para empezar.';\n }\n const recent = libraryList.slice(-1)[0];\n const parts = [\n `Tengo ${libraryList.length} proyectos almacenados. El \u00faltimo fue \"${recent.projectName}\" a ${recent.liveSet?.tempo || 'N/D'} BPM.`,\n sourceList && sourceList.length ? `Hay ${sourceList.length} recursos en sources (ej: ${sourceList.slice(0,3).join(', ')})` : 'A\u00fan no hay archivos en data/sources/.',\n 'Cuando est\u00e9s listo dime algo como \"generame un als afrohouse 2025 con 124 bpm\" y preparo la sesi\u00f3n.'\n ];\n return parts.join(' ');\n}\nif (shouldDescribeSources(prompt)) {\n const reply = describeSources(sources);\n remember('bot', reply);\n msg.statusCode = 200;\n msg.payload = { reply };\n return msg;\n}\nif (!shouldGenerate(prompt)) {\n return (async () => {\n try {\n const reply = await (generator.chatReply ? generator.chatReply(prompt, info, sources, chatHistory) : buildSummary(prompt, info, sources));\n remember('bot', reply);\n msg.statusCode = 200;\n msg.payload = { reply };\n return msg;\n } catch (err) {\n node.error(err.message, msg);\n msg.statusCode = 500;\n msg.payload = { error: err.message };\n return msg;\n }\n })();\n}\nreturn (async () => {\n try {\n const result = await generator.generateFromPrompt(prompt);\n const summary = `Listo, gener\u00e9 \"${result.plan.projectName}\" usando ${result.plan.templateHash}.`;\n remember('bot', summary);\n msg.statusCode = 200;\n msg.payload = {\n prompt: prompt,\n projectName: result.plan.projectName,\n templateHash: result.plan.templateHash,\n outputPath: result.outputPath,\n registered: result.registered\n };\n return msg;\n } catch (err) {\n node.error(err.message, msg);\n remember('bot', err.message);\n msg.statusCode = 500;\n msg.payload = { error: err.message };\n return msg;\n }\n})();\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 520,
"y": 660,
"wires": [
[
"e0b21f83f64a49c7"
]
]
},
{
"id": "e0b21f83f64a49c7",
"type": "http response",
"z": "f68df1c4d2e4e1a9",
"name": "",
"statusCode": "",
"headers": {},
"x": 720,
"y": 660,
"wires": []
}
]