未来っぽいグラフ作成ツール「Neuro Linker」
【更新履歴】
・2026/2/13 バージョン1.0公開。
・2026/2/14 バージョン1.1公開。
・2026/2/15 バージョン1.2公開。(プロパティ、js部品化)
・2026/2/15 バージョン1.3公開。(Undo、実行エンジンの改良など)
・2026/2/15 バージョン1.4公開。(ライブラリ・リストのツリー化、
ワークフロー化など)
・2026/2/16 バージョン1.5公開。(ソースファイルを分離)
・2026/8/3 バージョン1.6公開。
・ダウンロードされる方はこちら。↓
《ユーザーコード(Javascript)の書き方ガイド》
・エディタに記述できるコードの例です。
・F1キーを押してプロパティモードにしてから
接続作業を行ってください。
1. データ生成(Source Node)
・input は使いません。値を return してください。
// 配列データを生成
const items = [
{ id: 1, name: "Alpha", val: 100 },
{ id: 2, name: "Beta", val: 200 }
];
// デバッグ用にログ出力(トースト通知されます)
log.info("Generating data...");
return items;
2. プロパティの使用と加工(Process Node)
設定: プロパティに threshold を追加し、値を 0 にしておきます。
接続: 別の「閾値設定用ノード(Sourceなど)」のOutポートから、
このノードの threshold ポートへ点線のケーブルをつなぐと、
自動的に値が上書きされます。
// props.threshold には、手動入力値、または接続されたノードの値が入っています
const th = parseFloat(props.threshold) || 0;
// データ加工
const filtered = input.filter(item => item.val > th);
// 結果を返す
return filtered;
3. 非同期処理(Agent Node / Action Node)
・await が使えます。
// 2秒待機する例
log.info("Waiting...");
await new Promise(r => setTimeout(r, 2000));
log.info("Done!");
return input;・ソースコードはこちら。↓
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Neuro Linker v1.6</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg: #12161c; --panel: rgba(24, 30, 38, 0.97); --accent: #00ffcc;
--text: #eef6ff; --border: #4a6178; --menu-bg: #1a2029;
--sel-box: rgba(0, 255, 204, 0.2); --sel-border: #00ffcc;
--danger: #ff5577; --warn: #ffcc00;
}
body { margin: 0; overflow: hidden; background: var(--bg); font-family: 'Segoe UI', 'Roboto', monospace; color: var(--text); user-select: none; }
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
.interactive { pointer-events: auto; }
/* Menu Bar */
#menubar {
position: absolute; top: 0; left: 0; width: 100%; height: 32px;
background: var(--menu-bg); border-bottom: 1px solid #425163;
display: flex; align-items: center; padding-left: 10px; pointer-events: auto; z-index: 1000;
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
}
.menu-item { padding: 0 16px; height: 100%; display: flex; align-items: center; cursor: pointer; font-size: 12px; color: #bbb; position: relative; letter-spacing: 0.5px; }
.menu-item:hover { background: #425163; color: var(--accent); }
.submenu {
position: absolute; top: 32px; left: 0; background: var(--panel);
border: 1px solid var(--border); min-width: 220px; display: none; flex-direction: column;
box-shadow: 0 5px 20px rgba(0,0,0,0.8); border-radius: 0 0 4px 4px;
}
.menu-item:hover .submenu { display: flex; }
.sub-item { padding: 10px 20px; color: #ccc; cursor: pointer; transition: 0.1s; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 12px; }
.sub-item:hover { background: rgba(0, 255, 204, 0.1); color: var(--accent); padding-left: 25px; }
.ctx-sep { height: 1px; background: #425163; margin: 4px 0; }
/* Left Library Pane */
#lib-pane {
position: absolute; top: 33px; left: 0; width: 280px; bottom: 0;
background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; transition: transform 0.3s ease; pointer-events: auto; z-index: 900; outline: none;
}
#lib-pane.collapsed { transform: translateX(-280px); }
#lib-toggle {
position: absolute; top: 50%; right: -15px; width: 15px; height: 50px;
background: var(--border); border-radius: 0 5px 5px 0; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 10px; color: #aaa;
}
.lib-tabs { display: flex; border-bottom: 1px solid var(--border); }
.lib-tab { flex: 1; padding: 10px; text-align: center; cursor: pointer; font-size: 11px; background: #141b22; color: #92a4b5; }
.lib-tab.active { background: var(--panel); color: var(--accent); font-weight: bold; border-bottom: 2px solid var(--accent); }
#lib-toolbar { padding: 5px; border-bottom: 1px solid #425163; display: flex; gap: 5px; }
.lib-btn { flex: 1; padding: 6px; background: #262f3a; border: 1px solid #444; color: #ccc; font-size: 10px; cursor: pointer; text-align: center; border-radius: 3px; }
.lib-btn:hover { border-color: var(--accent); color: var(--accent); }
#lib-content { flex: 1; overflow-y: auto; padding: 5px; outline: none; }
/* Tree View */
.tree-node { margin-left: 10px; font-size: 12px; color: #ccc; }
.tree-header { padding: 4px 6px; cursor: pointer; display: flex; align-items: center; gap: 6px; border-radius: 3px; border: 1px solid transparent; }
.tree-header:hover { background: #232c37; border-color: #425163; }
.tree-header.selected { background: var(--accent); color: #000; border-color: #fff; }
.tree-icon { width: 14px; text-align: center; color: var(--warn); }
.file-icon { color: var(--accent); }
.tree-children { display: none; margin-left: 4px; border-left: 1px solid #425163; }
.tree-node.open > .tree-children { display: block; }
.tree-arrow { font-size: 8px; color: #92a4b5; transition: 0.2s; }
.tree-node.open > .tree-header > .tree-arrow { transform: rotate(90deg); }
/* Mode Bar */
#mode-bar {
position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%);
display: flex; gap: 20px; background: rgba(10, 12, 16, 0.9); padding: 10px 20px;
border: 1px solid #425163; border-radius: 50px; backdrop-filter: blur(10px);
align-items: center; box-shadow: 0 10px 30px rgba(0,0,0,0.5); pointer-events: auto; z-index: 1000;
}
.tool-group { display: flex; flex-direction: column; gap: 2px; align-items: center; }
.group-label { font-size: 9px; color: #556677; text-transform: uppercase; letter-spacing: 1px; font-weight: bold; }
.btn-row { display: flex; gap: 8px; }
.mode-btn {
background: transparent; border: 1px solid #444; color: #888; padding: 8px 16px; cursor: pointer; font-weight: bold; font-size: 11px;
text-transform: uppercase; transition: 0.2s; border-radius: 20px; min-width: 70px; text-align: center;
}
.mode-btn:hover { color: #fff; border-color: #92a4b5; background: #262f3a; }
.mode-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(0,255,204,0.15); box-shadow: 0 0 15px rgba(0,255,204,0.2); }
/* Others */
#selection-box { position: absolute; border: 1px solid var(--sel-border); background: var(--sel-box); display: none; pointer-events: none; z-index: 200; }
.ctx-menu {
position: absolute; display: none; flex-direction: column; background: var(--panel); border: 1px solid var(--accent);
box-shadow: 0 0 25px rgba(0,255,204,0.1); min-width: 180px; z-index: 5000; border-radius: 4px; overflow: hidden; pointer-events: auto;
}
.ctx-item { padding: 10px 15px; cursor: pointer; font-size: 12px; color: var(--text); border-bottom: 1px solid rgba(255,255,255,0.05); }
.ctx-item:hover { background: var(--accent); color: #000; }
#toast-container { position: absolute; top: 50px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 9000; pointer-events: none; }
.toast {
background: rgba(20, 25, 30, 0.95); color: #fff; padding: 12px 20px; border-left: 4px solid var(--accent);
box-shadow: 0 5px 15px rgba(0,0,0,0.5); border-radius: 2px; font-size: 12px;
opacity: 0; transform: translateX(50px); transition: 0.3s; pointer-events: auto; display: flex; align-items: center; gap: 10px;
}
.toast.show { opacity: 1; transform: translateX(0); }
.toast.warn { border-color: #ffcc00; } .toast.error { border-color: #ff4466; }
/* Dialogs */
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 2000;
display: none; justify-content: center; align-items: center; opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s; perspective: 1500px;
}
.modal-overlay.active { display: flex; opacity: 1; pointer-events: auto; visibility: visible; }
.dialog-box {
width: 500px; max-width: 90%; background: #0a0f14; border: 1px solid #334455; box-shadow: 0 0 60px rgba(0,0,0,0.8);
display: flex; flex-direction: column; border-radius: 6px; transform-style: preserve-3d; opacity: 0; transform-origin: center;
}
.modal-overlay.opening .dialog-box { animation: animBackflipOpen 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }
.modal-overlay.closing .dialog-box { animation: animBackflipClose 0.4s cubic-bezier(0.6, -0.28, 0.735, 0.045) forwards; }
@keyframes animBackflipOpen { 0% { transform: scale(0.5) rotateX(-90deg); opacity: 0; } 100% { transform: scale(1) rotateX(0deg); opacity: 1; } }
@keyframes animBackflipClose { 0% { transform: scale(1) rotateX(0deg); opacity: 1; } 100% { transform: scale(0.5) rotateX(90deg); opacity: 0; } }
.dlg-header { padding: 12px 20px; background: rgba(255,255,255,0.05); border-bottom: 1px solid #425163; color: var(--accent); font-weight: bold; font-size: 13px; display: flex; justify-content: space-between; }
.dlg-body { padding: 20px; display: flex; flex-direction: column; gap: 15px; max-height: 70vh; overflow-y: auto; }
.btn-bar { display: flex; justify-content: flex-end; gap: 10px; padding: 15px 20px; border-top: 1px solid #222; background: rgba(0,0,0,0.2); }
input, textarea, select { background: #1b232c; border: 1px solid #444; color: #eee; padding: 10px; font-family: monospace; width: 100%; box-sizing: border-box; font-size: 12px; border-radius: 4px; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); background: #151515; }
.lbl-tag { font-size: 9px; color: var(--accent); background: rgba(0, 255, 204, 0.08); padding: 3px 8px; border-radius: 3px; display: inline-block; margin-bottom: 5px; letter-spacing: 1px; font-weight: bold; border: 1px solid rgba(0,255,204,0.2); }
.btn { background: #262f3a; color: #ccc; border: 1px solid #444; padding: 8px 18px; cursor: pointer; font-size: 11px; border-radius: 3px; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { border-color: var(--accent); color: var(--accent); }
.btn-sm { padding: 4px 8px; font-size: 10px; }
/* Preview Area */
.preview-tabs { display: flex; gap: 5px; margin-bottom: 10px; border-bottom: 1px solid #425163; padding-bottom: 5px; }
.p-tab { flex: 1; padding: 8px; background: #1b232c; color: #92a4b5; text-align: center; cursor: pointer; font-size: 11px; border-radius: 4px 4px 0 0; }
.p-tab:hover { background: #262f3a; color: #aaa; }
.p-tab.active { background: #004444; color: var(--accent); font-weight: bold; border-bottom: 2px solid var(--accent); }
#preview-area { min-height: 400px; max-height: 600px; background: #262f3a; border: 1px solid #425163; border-radius: 4px; padding: 10px; overflow: auto; position: relative; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { border: 1px solid #444; padding: 6px; text-align: left; color: #ccc; }
th { background: #232c37; color: var(--accent); position: sticky; top: 0; }
.prop-row { display: grid; grid-template-columns: 100px 1fr 30px; gap: 10px; margin-bottom: 5px; align-items: center; }
.prop-key { font-size: 11px; color: #888; text-align: right; }
#scope-badge { position: absolute; top: 80px; left: 20px; background: rgba(0,0,0,0.8); border: 1px solid var(--accent); color: var(--accent); padding: 5px 15px; border-radius: 20px; font-size: 11px; font-weight: bold; pointer-events: none; display: none; }
</style>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
</script>
</head>
<body>
<div id="selection-box"></div>
<div id="toast-container"></div>
<div id="scope-badge">SCOPE: ROOT</div>
<div id="menubar">
<div class="menu-item">
PROJECT
<div class="submenu">
<div class="sub-item" onclick="proj.action('new')">New Project</div>
<div class="sub-item" onclick="proj.action('open')">Open Project...</div>
<div class="sub-item" onclick="proj.action('save')">Save</div>
<div class="sub-item" onclick="proj.action('saveas')">Save As...</div>
<div class="ctx-sep"></div>
<div class="sub-item" onclick="proj.genProject('input')">✨ Gen AI (Input)</div>
<div class="sub-item" onclick="proj.genProject('api')">🤖 Gen AI (Local LLM)</div>
<div class="ctx-sep"></div>
<div class="sub-item" onclick="app.executeAll()" style="color:var(--accent);">▶ Execute Workflow</div>
<div class="ctx-sep"></div>
<div class="sub-item" onclick="ui.openProjectProps()">Global Properties</div>
</div>
</div>
<div class="menu-item">
EDIT
<div class="submenu">
<div class="sub-item" onclick="historyMgr.undo()">Undo (Ctrl+Z)</div>
<div class="sub-item" onclick="historyMgr.redo()">Redo (Ctrl+Y)</div>
<div class="ctx-sep"></div>
<div class="sub-item" onclick="app.copySelection()">Copy (Ctrl+C)</div>
<div class="sub-item" onclick="app.pasteAtCursor()">Paste (Ctrl+V)</div>
<div class="sub-item" onclick="app.ctxAction(null, 'delete')">Delete Selected</div>
</div>
</div>
<div class="menu-item">
ADD OBJECT
<div class="submenu">
<div class="sub-item" onclick="app.addNodeAtCenter('source')">+ Data Source</div>
<div class="sub-item" onclick="app.addNodeAtCenter('process')">+ Process (JS)</div>
<div class="sub-item" onclick="app.addNodeAtCenter('agent')">+ AI Agent</div>
<div class="ctx-sep"></div>
<div class="sub-item" onclick="app.addNodeAtCenter('loop')">+ Loop</div>
<div class="sub-item" onclick="app.addNodeAtCenter('gate')">+ Gate</div>
<div class="sub-item" onclick="app.addNodeAtCenter('delay')">+ Delay</div>
<div class="ctx-sep"></div>
<div class="sub-item" onclick="app.addNodeAtCenter('action')">+ DOM Action</div>
<div class="sub-item" onclick="app.addNodeAtCenter('view')">+ View Result</div>
</div>
</div>
<div class="menu-item" style="margin-left:auto; cursor:default; color:#92a4b5; font-weight:bold;">NEURO LINKER v3.1M</div>
</div>
<div id="lib-pane" tabindex="0">
<div id="lib-toggle" onclick="lib.togglePane()">◀</div>
<div class="lib-tabs">
<div class="lib-tab active" id="tab-obj" onclick="lib.setTab('object')">OBJECTS</div>
<div class="lib-tab" id="tab-flow" onclick="lib.setTab('workflow')">WORKFLOWS</div>
</div>
<div id="lib-toolbar">
<div class="lib-btn" onclick="ui.showLibSaveDialog()">+ Save Sel</div>
<div class="lib-btn" onclick="lib.createFolder()">+ Folder</div>
</div>
<div id="lib-content" ondragover="lib.handleDragOver(event)" ondragleave="lib.handleDragLeave(event)" ondrop="lib.handleDrop(event)">
<div style="padding:20px; text-align:center; color:#555;">Library (Tree View)</div>
</div>
</div>
<div id="ui-layer">
<div class="status" id="status-text" style="position:absolute; top:45px; left:290px; color:#556677; font-size:10px;">READY</div>
<div style="position:absolute; top:45px; right:20px; color:#556677; font-size:10px; text-align:right;">F1: Props Mode | F2: Icon Mode | DblClick: Edit<br>Del: Delete | Ctrl+C/V: Copy/Paste<br>Drag Empty Space: Pan | Ctrl+Drag: Box Select</div>
<div id="mode-bar">
<div class="tool-group">
<div class="group-label">Control</div>
<div class="btn-row">
<button class="mode-btn active" onclick="app.setMode('select')">Select</button>
<button class="mode-btn" onclick="app.setMode('move')">Move</button>
<button class="mode-btn" onclick="app.setMode('link')">Link</button>
</div>
</div>
<div style="width:1px; height:30px; background:#425163; margin:0 5px;"></div>
<div class="tool-group">
<div class="group-label">Camera</div>
<div class="btn-row">
<button class="mode-btn" onclick="app.setMode('rotate')">Rotate</button>
<button class="mode-btn" onclick="app.setMode('pan')">Pan</button>
<button class="mode-btn" onclick="app.setMode('zoom')">Zoom</button>
<button class="mode-btn" onclick="app.focusSelection()">Focus</button>
</div>
</div>
</div>
</div>
<div id="ctx-node" class="ctx-menu interactive">
<div class="ctx-item" onclick="app.ctxAction(event, 'exec')">▶ Execute Node</div>
<div class="ctx-item" onclick="app.ctxAction(event, 'preview')">👁 Result View</div>
<div class="ctx-sep"></div>
<div class="ctx-item" onclick="app.ctxAction(event, 'edit')">Open Editor</div>
<div class="ctx-item" onclick="app.ctxAction(event, 'props')">⚙️ Properties (Args)</div>
<div class="ctx-sep"></div>
<div class="ctx-item" id="ctx-create-wf" onclick="app.ctxAction(event, 'create-wf')">Create WorkFlow</div>
<div class="ctx-item" id="ctx-cancel-wf" onclick="app.ctxAction(event, 'cancel-wf')" style="display:none;">Ungroup (Cancel)</div>
<div class="ctx-sep"></div>
<div class="ctx-item" onclick="app.ctxAction(event, 'saveLib')">Save to Library</div>
<div class="ctx-item" onclick="app.ctxAction(event, 'copy')">Copy (C)</div>
<div class="ctx-item" onclick="app.ctxAction(event, 'delete')" style="color:var(--danger)">Delete (Del)</div>
</div>
<div id="ctx-global" class="ctx-menu interactive">
<div class="ctx-item" onclick="app.addNodeAtCenter('process')">Add Process</div>
<div class="ctx-item" onclick="app.addNodeAtCenter('agent')">Add AI Agent</div>
<div class="ctx-item" onclick="app.pasteAtCursor()">Paste (V)</div>
<div class="ctx-sep"></div>
<div class="ctx-item" id="ctx-close-wf" onclick="app.exitWorkflow()" style="display:none; color:var(--accent);">⬆ Exit WorkFlow Scope</div>
<div class="ctx-sep"></div>
<div class="ctx-item" onclick="historyMgr.undo()">Undo</div>
</div>
<div id="ctx-lib" class="ctx-menu interactive">
<div class="ctx-item" onclick="lib.ctxAction('delete')" style="color:var(--danger)">Delete</div>
<div class="ctx-item" onclick="lib.ctxAction('new-folder')">New Subfolder</div>
<div class="ctx-item" onclick="lib.ctxAction('rename')">Rename</div>
</div>
<div id="preview-dialog" class="modal-overlay">
<div class="dialog-box interactive" style="width:900px;">
<div class="dlg-header"><span>RESULT VIEW</span><button onclick="ui.closeDialog('preview-dialog')">✕</button></div>
<div class="dlg-body">
<div class="preview-tabs">
<div class="p-tab active" data-mode="dom" onclick="ui.setPreviewMode('dom', event)">DOM (SVG/HTML)</div>
<div class="p-tab" data-mode="chart" onclick="ui.setPreviewMode('chart', event)">CHART</div>
<div class="p-tab" data-mode="table" onclick="ui.setPreviewMode('table', event)">TABLE</div>
<div class="p-tab" data-mode="text" onclick="ui.setPreviewMode('text', event)">JSON</div>
</div>
<div id="preview-area">
<canvas id="preview-chart-canvas" style="display:none; width:100%; height:100%;"></canvas>
<div id="preview-html-container" style="color:#eee; font-size:12px; display:block;"></div>
</div>
</div>
<div class="btn-bar"><button class="btn" onclick="ui.closeDialog('preview-dialog')">Close</button></div>
</div>
</div>
<div id="editor-dialog" class="modal-overlay">
<div class="dialog-box interactive" style="width: 800px;">
<div class="dlg-header"><span id="editor-title">EDITOR</span><button onclick="ui.closeDialog('editor-dialog')">✕</button></div>
<div class="dlg-body">
<div style="display:flex; gap:10px; margin-bottom:5px;">
<button class="btn" onclick="ui.editorAction('load-file')">📂 Load</button>
<button class="btn" onclick="ui.editorAction('save-file')">💾 Save</button>
<div style="width:1px; background:#444; margin:0 5px;"></div>
<button class="btn" onclick="ui.editorAction('ai-input')">✨ AI Generation</button>
</div>
<div style="display:grid; grid-template-columns: 100px 1fr 100px 1fr; gap:10px; margin: 10px 0; align-items:center;">
<div class="lbl-tag" style="margin:0;">TYPE</div>
<select id="editor-icon-type">
<option value="source">Data Source</option>
<option value="process">Process</option>
<option value="view">View</option>
<option value="agent">Agent</option>
<option value="loop">Loop</option>
<option value="gate">Gate</option>
<option value="action">Action</option>
<option value="workflow">Workflow</option>
</select>
<div class="lbl-tag" style="margin:0;">ICON</div>
<input type="text" id="editor-icon-text" placeholder="e.g. 📂">
</div>
<textarea id="editor-content" style="height:350px; font-family:'Consolas', monospace; color:#aaffff; background:#141b22;" placeholder="// return input;"></textarea>
</div>
<div class="btn-bar">
<button class="btn" onclick="ui.closeDialog('editor-dialog')">Cancel</button>
<button class="btn btn-primary" onclick="ui.saveEditor()">Apply</button>
</div>
</div>
</div>
<div id="ai-proj-dialog" class="modal-overlay">
<div class="dialog-box interactive" style="width:700px;">
<div class="dlg-header"><span>PROJECT GENERATION (AI)</span><button onclick="ui.closeDialog('ai-proj-dialog')">✕</button></div>
<div class="dlg-body">
<div class="lbl-tag">1. SYSTEM DESCRIPTION</div>
<div style="display:flex; gap:10px;">
<input type="text" id="ai-proj-input" placeholder="e.g. Load CSV data, filter by Year > 2023, then show Chart">
<button class="btn" onclick="proj.genPrompt()">Generate Prompt</button>
</div>
<div id="ai-proj-manual-section">
<div class="lbl-tag" style="margin-top:10px;">2. PROMPT (Copy this to LLM)</div>
<div style="display:flex; gap:10px;">
<textarea id="ai-proj-prompt-out" style="height:140px; font-size:11px;" readonly></textarea>
<button class="btn" onclick="ui.copyPrompt('ai-proj-prompt-out')">Copy</button>
</div>
<div class="lbl-tag" style="margin-top:10px;">3. PASTE JSON RESULT</div>
<textarea id="ai-proj-json-in" style="height:140px;" placeholder="Paste valid JSON here..."></textarea>
<div style="display:flex; justify-content:flex-end;">
<button class="btn" onclick="ui.pasteTo('ai-proj-json-in')">Paste</button>
</div>
</div>
</div>
<div class="btn-bar">
<button class="btn" onclick="ui.closeDialog('ai-proj-dialog')">Cancel</button>
<button class="btn btn-primary" onclick="proj.applyGen()">Generate Project</button>
</div>
</div>
</div>
<div id="node-prop-dialog" class="modal-overlay">
<div class="dialog-box interactive" style="width: 500px;">
<div class="dlg-header"><span>NODE PROPERTIES</span><button onclick="ui.closeDialog('node-prop-dialog')">✕</button></div>
<div class="dlg-body">
<div id="node-prop-list"></div>
<button class="btn btn-sm" style="width:100%" onclick="ui.addNodeProp()">+ Add Property</button>
</div>
<div class="btn-bar"><button class="btn btn-primary" onclick="ui.saveNodeProps()">Save Properties</button></div>
</div>
</div>
<div id="input-dialog" class="modal-overlay">
<div class="dialog-box interactive" style="width:400px;">
<div class="dlg-header"><span id="input-title">INPUT</span></div>
<div class="dlg-body">
<div id="input-msg" style="color:#ccc;">Value:</div>
<input id="input-val" type="text" autocomplete="off">
</div>
<div class="btn-bar">
<button class="btn" onclick="ui.closeDialog('input-dialog')">Cancel</button>
<button class="btn btn-primary" id="input-ok-btn">OK</button>
</div>
</div>
</div>
<div id="prop-dialog" class="modal-overlay">
<div class="dialog-box interactive" style="width:640px;">
<div class="dlg-header"><span>GLOBAL PROPERTIES</span><button onclick="ui.closeDialog('prop-dialog')">✕</button></div>
<div class="dlg-body">
<div>
<div class="lbl-tag">CLOUD API KEY (optional, for manual copy-paste prompt workflow)</div>
<input id="prop-ai-key" type="password">
</div>
<div class="ctx-sep"></div>
<div class="lbl-tag">LOCAL LLM (llama.cpp / Ollama)</div>
<div style="font-size:10px; color:#667;">A browser page can't launch or stop a local program directly. Fill this in, click Start to get the launch command, run that yourself in a terminal, then Start again to connect.</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div>
<div class="lbl-tag" style="margin:0;">ENGINE</div>
<select id="llm-engine" onchange="ui.llmEngineChanged()">
<option value="ollama">Ollama</option>
<option value="llamacpp">llama.cpp (llama-server)</option>
</select>
</div>
<div>
<div class="lbl-tag" style="margin:0;">PORT</div>
<input id="llm-port" type="number" placeholder="11434">
</div>
</div>
<div>
<div class="lbl-tag">EXECUTABLE PATH</div>
<input id="llm-exec-path" placeholder="e.g. /usr/local/bin/ollama or C:\llama.cpp\llama-server.exe">
</div>
<div>
<div class="lbl-tag" id="llm-model-label">MODEL</div>
<input id="llm-model-path" placeholder="e.g. llama3 (Ollama) or /models/model.gguf (llama.cpp)">
</div>
<div id="llamacpp-only-fields" style="display:flex; flex-direction:column; gap:15px;">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div>
<div class="lbl-tag" style="margin:0;">KV CACHE - K TYPE</div>
<select id="llm-kv-k">
<option value="f16">f16</option>
<option value="f32">f32</option>
<option value="q8_0">q8_0</option>
<option value="q5_1">q5_1</option>
<option value="q5_0">q5_0</option>
<option value="q4_1">q4_1</option>
<option value="q4_0">q4_0</option>
<option value="iq4_nl">iq4_nl</option>
</select>
</div>
<div>
<div class="lbl-tag" style="margin:0;">KV CACHE - V TYPE</div>
<select id="llm-kv-v">
<option value="f16">f16</option>
<option value="f32">f32</option>
<option value="q8_0">q8_0</option>
<option value="q5_1">q5_1</option>
<option value="q5_0">q5_0</option>
<option value="q4_1">q4_1</option>
<option value="q4_0">q4_0</option>
<option value="iq4_nl">iq4_nl</option>
</select>
</div>
</div>
<div>
<div class="lbl-tag">LORA FILE PATH (optional)</div>
<input id="llm-lora-path" placeholder="/path/to/adapter.gguf">
</div>
<div>
<div class="lbl-tag">ADDITIONAL OPTIONS</div>
<input id="llm-extra-args" placeholder="--n-gpu-layers 999 --ctx-size 8192 ...">
</div>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" onclick="llm.start()">▶ Start / Connect</button>
<button class="btn" onclick="llm.stop()">■ Stop / Disconnect</button>
<span id="llm-status" style="font-size:11px; color:#888; margin-left:auto;">○ STOPPED</span>
</div>
<div>
<div class="lbl-tag">LAUNCH COMMAND (run this yourself in a terminal)</div>
<div style="display:flex; gap:10px;">
<input id="llm-launch-cmd" readonly>
<button class="btn btn-sm" onclick="ui.copyPrompt('llm-launch-cmd')">Copy</button>
</div>
</div>
</div>
<div class="btn-bar"><button class="btn btn-primary" onclick="ui.saveProjectProps()">Save</button></div>
</div>
</div>
<div id="ai-manual-dialog" class="modal-overlay">
<div class="dialog-box interactive" style="width:650px;">
<div class="dlg-header"><span>AI NODE GEN</span><button onclick="ui.closeDialog('ai-manual-dialog')">✕</button></div>
<div class="dlg-body">
<div class="lbl-tag">GOAL</div>
<div style="display:flex; gap:10px;"><input type="text" id="ai-goal-input"><button class="btn" onclick="ui.genPrompt()">Gen</button></div>
<div class="lbl-tag">PROMPT</div>
<textarea id="ai-prompt-output" style="height:70px;" readonly></textarea>
<div class="lbl-tag">CODE</div>
<textarea id="ai-code-input" style="height:120px;"></textarea>
</div>
<div class="btn-bar"><button class="btn" onclick="ui.closeDialog('ai-manual-dialog')">Cancel</button><button class="btn btn-primary" onclick="ui.applyAICode()">Apply</button></div>
</div>
</div>
<input type="file" id="file-loader" style="display:none" accept=".json">
<script type="module" src="js/main.js"></script>
</body>
</html>ai.js
import { state, toast, COLORS } from './state.js';
import { ui } from './ui.js';
import { SpatialNode, LaserLink } from './core.js';
import { llm } from './llm.js';
import * as THREE from 'three';
export const proj = {
async action(act) {
if(act === 'new') { if(confirm("New Project?")) { if(window.app) window.app.clearScene(); if(await proj.ensureHandle()) await proj.initFolders(); } }
if(act === 'open') {
try {
if ('showDirectoryPicker' in window) {
const h = await window.showDirectoryPicker(); state.projectHandle = h;
const f = await h.getFileHandle('settings.json'); const json = JSON.parse(await (await f.getFile()).text());
proj.loadFromJSON(json); document.getElementById('status-text').innerText = "PROJ: " + h.name;
} else {
document.getElementById('file-loader').onchange = async (e) => { if(e.target.files.length > 0) { const json = JSON.parse(await e.target.files[0].text()); proj.loadFromJSON(json); document.getElementById('status-text').innerText = "PROJ: File Loaded"; } }; document.getElementById('file-loader').click();
}
} catch(e) { console.error(e); }
}
if(act === 'save') {
if(!state.projectHandle && !(await proj.ensureHandle())) return;
await proj.initFolders();
const d = { nextId: state.nextNodeId, nodes: state.nodes.map(n=>n.serialize()), links: state.links.map(l=>({from:l.from.id, to:l.to.id})) };
if(state.projectHandle) {
const w = await (await state.projectHandle.getFileHandle('settings.json',{create:true})).createWritable(); await w.write(JSON.stringify(d,null,2)); await w.close();
toast.info("Project Saved");
}
}
if(act === 'saveas') { try { const h = await window.showDirectoryPicker(); state.projectHandle = h; await this.action('save'); document.getElementById('status-text').innerText = "PROJ: " + h.name; } catch (e) { } }
},
async ensureHandle() {
if (!('showDirectoryPicker' in window)) {
const d = { nextId: state.nextNodeId, nodes: state.nodes.map(n=>n.serialize()), links: state.links.map(l=>({from:l.from.id, to:l.to.id})) };
const blob = new Blob([JSON.stringify(d,null,2)], {type: "application/json"}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = "project.json"; a.click(); return false;
}
try { state.projectHandle = await window.showDirectoryPicker(); return true; } catch(e){return false;}
},
async initFolders() { if(!state.projectHandle) return; const dirs = ['ObjectLib', 'WorkFlowLib', 'Misc']; for(const d of dirs) await state.projectHandle.getDirectoryHandle(d, {create:true}); },
genProject(m) { state.genMode = m; ui.openDialog('ai-proj-dialog'); const manual = document.getElementById('ai-proj-manual-section'); manual.style.display = (m === 'input') ? 'block' : 'none'; if(m === 'api' && !state.projectSettings.llm.modelPath) toast.warn("Set up a local LLM in Global Properties first"); },
async genPrompt() {
const req = document.getElementById('ai-proj-input').value;
const systemPrompt = `[SYSTEM] You are the architect of "Neuro Linker".
[GOAL] Generate a JSON of Nodes and Links.
[RULES]
1. OUTPUT STRICT VALID JSON. NO MARKDOWN.
2. Structure: { "nodes": [{ "id": int, "type": string, "name": string, "x": number, "y": number, "content": string }], "links": [{ "from": id, "to": id }] }.
3. "content" field MUST be valid JS code.
4. IN CONTENT:
- Use SINGLE QUOTES (') for strings inside code.
- Example: "return { val: 'hello' };"
- No // comments.
5. UI Nodes (View/Action): Return a Container DIV.
- "var d=document.createElement('div'); d.innerHTML='OK'; return d;"
[REQUEST] ${req}`;
document.getElementById('ai-proj-prompt-out').value = systemPrompt;
if(state.genMode === 'api') {
toast.info("Requesting local LLM...");
try {
const raw = await llm.generate(systemPrompt);
proj.applyGen(false, raw);
} catch(e) {
console.error(e);
toast.error("Local LLM generation failed: " + e.message);
}
}
},
// Turns raw AI/manual JSON (possibly fenced, possibly using non-standard node ids) into
// the { nextId, nodes, links } shape loadFromJSON expects, remapping ids to be contiguous.
normalizeGenJSON(raw) {
let json;
try { json = JSON.parse(raw); }
catch (e) { json = new Function("return " + raw)(); }
if (json.nodes && json.nodes.length > 0) {
const idMap = {};
let currentId = 1;
const newNodes = [];
json.nodes.forEach((n, i) => {
const newId = currentId++;
idMap[n.id] = newId;
let type = n.type || 'process';
if (!COLORS[type]) type = 'process';
let finalX = (n.x !== undefined ? (n.x) : ((i%4)*15)-20);
let finalY = (n.y !== undefined ? (n.y) : -((Math.floor(i/4)*15)-10));
newNodes.push({ id: newId, type: type, name: (n.name || "Node").replace(/\s/g,'_') + "_" + newId, x: finalX, y: finalY, z: 0, content: n.content || "// " + n.name, props: {} });
});
const newLinks = [];
if (json.links) {
json.links.forEach(l => {
const src = l.source || l.from; const dst = l.target || l.to;
if (idMap[src] && idMap[dst]) newLinks.push({ from: idMap[src], to: idMap[dst] });
});
}
return { nextId: currentId, nodes: newNodes, links: newLinks };
}
return json;
},
applyGen(useSim = false, rawOverride = null) {
try {
let json;
if(rawOverride !== null) {
const cleaned = rawOverride.replace(/```json/g, '').replace(/```/g, '').trim();
json = proj.normalizeGenJSON(cleaned);
toast.info("AI JSON Normalized");
} else if(useSim || state.genMode === 'api') {
json = { nodes: [ {id:1, type:'source', name:'Sim_Data', x:-15, y:0, z:0, content:'return [{id:1, val:100}, {id:2, val:200}];'}, {id:2, type:'process', name:'Sim_Proc', x:0, y:0, z:0, content:'return input.map(x => ({...x, val: x.val * 2}));'}, {id:3, type:'view', name:'Sim_View', x:15, y:0, z:0, content:'return input;'} ], links: [{from:1, to:2}, {from:2, to:3}] };
} else {
const raw = document.getElementById('ai-proj-json-in').value.replace(/```json/g, '').replace(/```/g, '').trim();
json = proj.normalizeGenJSON(raw);
toast.info("AI JSON Normalized");
}
this.loadFromJSON(json);
ui.closeDialog('ai-proj-dialog');
toast.info("Project Generated");
} catch(e) { console.error(e); alert("JSON Error: " + e.message); }
},
loadFromJSON(json) {
if(window.historyMgr) window.historyMgr.snapshot();
if(window.app) window.app.clearScene();
state.nextNodeId = json.nextId || 1;
json.nodes.forEach(d => {
const n = new SpatialNode(d.type, new THREE.Vector3(d.x, 0, d.y), d.id);
n.name = d.name; n.content = d.content || ""; n.props = d.props || {}; n.icon = d.icon; n.parentId = d.parentId || null;
n.buildVisuals();
});
if(json.links) { json.links.forEach(l => { const f = state.nodes.find(n=>n.id===l.from), t = state.nodes.find(n=>n.id===l.to); if(f&&t) new LaserLink(f,t); }); }
if(state.nextNodeId <= Math.max(...json.nodes.map(n=>n.id), 0)) state.nextNodeId = Math.max(...json.nodes.map(n=>n.id), 0) + 1;
if(window.app) window.app.updateView();
}
};core.js
import * as THREE from 'three';
import TWEEN from 'three/addons/libs/tween.module.js';
import { Line2 } from 'three/addons/lines/Line2.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
import { state, COLORS, DEFAULT_ICONS, toast } from './state.js';
import { engine, scene } from './main.js';
export class SpatialNode {
constructor(type, pos, id=null) {
this.id = id || state.nextNodeId++;
this.type = type.toLowerCase();
if (!COLORS[this.type]) this.type = 'process';
this.name = `${type.toUpperCase()}_${this.id}`;
this.content = "// return input;";
this.props = {};
this.icon = DEFAULT_ICONS[this.type] || '📦';
this.inputs = []; this.outputs = [];
this.parentId = state.currentGroupId;
this.group = new THREE.Group(); this.group.position.copy(pos);
this.group.userData = { isNode: true, obj: this };
this.resultData = null;
this.lastClick = 0;
this.ports = []; // must exist before buildVisuals()/buildPanel() populate it
this.stripGroup = new THREE.Group(); this.group.add(this.stripGroup);
this.largeIconGroup = new THREE.Group(); this.largeIconGroup.visible = false; this.group.add(this.largeIconGroup);
this.panelGroup = new THREE.Group(); this.panelGroup.visible = false; this.group.add(this.panelGroup);
const ringGeo = new THREE.RingGeometry(3.5, 3.8, 32); ringGeo.rotateX(-Math.PI/2);
this.ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xffff00, transparent:true, opacity:0 }));
this.ring.position.y = 0.1;
this.group.add(this.ring);
this.buildVisuals();
scene.add(this.group);
state.nodes.push(this);
if(this.id >= state.nextNodeId) state.nextNodeId = this.id + 1;
}
createLabelCanvas(text, size=32) {
const w=256, h=64;
const cvs = document.createElement('canvas'); cvs.width = w; cvs.height = h; const ctx = cvs.getContext('2d');
ctx.font = `bold ${size}px Arial`; ctx.fillStyle = "#ffffff"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor="black"; ctx.shadowBlur=4; ctx.fillText(text, w/2, h/2);
return cvs;
}
buildVisuals() { this.buildStrip(); this.buildLargeIcon(); this.updateVisibility(); }
disposeGroupChildren(group) {
while(group.children.length > 0) {
const child = group.children[0];
group.remove(child);
if(child.geometry) child.geometry.dispose();
if(child.material) {
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach(m => { if(m.map) m.map.dispose(); m.dispose(); });
}
}
}
buildStrip() {
this.disposeGroupChildren(this.stripGroup);
const cvs = document.createElement('canvas'); cvs.width = 256; cvs.height = 64; const ctx = cvs.getContext('2d');
ctx.fillStyle = "rgba(18,24,30,0.8)"; ctx.roundRect(10, 10, 236, 44, 10); ctx.fill();
ctx.strokeStyle = '#'+COLORS[this.type].toString(16).padStart(6,'0'); ctx.lineWidth = 4; ctx.stroke();
ctx.font = 'bold 32px Arial'; ctx.fillStyle = "#ffffff"; ctx.textAlign = 'center'; ctx.fillText(this.name, 128, 42);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs), toneMapped: false }));
sp.scale.set(8, 2, 1); sp.position.y = 2;
this.stripGroup.add(sp);
const dot = new THREE.Mesh(new THREE.BoxGeometry(1,1,1), new THREE.MeshStandardMaterial({ color:COLORS[this.type], emissive: COLORS[this.type], emissiveIntensity: 2.0 }));
dot.position.y = 0.5;
this.stripGroup.add(dot);
}
buildLargeIcon() {
this.disposeGroupChildren(this.largeIconGroup);
const cvs = this.createLabelCanvas(this.name);
const labelSp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs), toneMapped: false }));
labelSp.scale.set(8, 2, 1); labelSp.position.y = 6.5;
this.largeIconGroup.add(labelSp);
let geo, col = COLORS[this.type] || 0xffffff;
if(this.type==='source') geo = new THREE.CylinderGeometry(2,2,3,16);
else if(this.type==='workflow') geo = new THREE.BoxGeometry(4,4,4);
else geo = new THREE.BoxGeometry(3,3,3);
const mat = new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 3.0 });
const mesh = new THREE.Mesh(geo, mat); mesh.position.y = 3;
this.largeIconGroup.add(mesh);
}
buildPanel() {
this.disposeGroupChildren(this.panelGroup);
this.ports = [];
const w = 14, h = 10;
const isGround = state.view.propsMode === 2;
if (isGround) { this.panelGroup.rotation.x = -Math.PI / 2; this.panelGroup.position.set(0, 0.2, 0); }
else { this.panelGroup.rotation.x = 0; this.panelGroup.position.set(0, 0, 0); }
const yOff = isGround ? 0 : -5; const zOff = isGround ? 0 : -1;
const bg = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: 0x15202b, side: THREE.DoubleSide, transparent:true, opacity:0.92 }));
bg.position.set(0, yOff, zOff); bg.userData = { isNode: true, obj: this };
const border = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, h)), new THREE.LineBasicMaterial({ color: new THREE.Color(COLORS[this.type]).multiplyScalar(2) }));
border.position.set(0, yOff, zOff);
this.panelGroup.add(bg); this.panelGroup.add(border);
const cvs = document.createElement('canvas'); cvs.width = 512; cvs.height = 360; const ctx = cvs.getContext('2d');
ctx.fillStyle = '#fff';
ctx.font = "bold 40px Arial"; ctx.fillText(this.name, 20, 50);
const portMat = new THREE.MeshBasicMaterial({ color: 0xcccccc });
const addPort = (name, x, y, type, key=null) => {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.5), portMat);
mesh.position.set(x, yOff + y, zOff + 0.1);
mesh.userData = { isPort: true, node: this, type: type, key: key };
this.panelGroup.add(mesh); this.ports.push(mesh);
ctx.font = "24px Arial"; ctx.fillStyle = "#ccc";
const relY = 5 - y; const cvsY = (relY / 10) * 360;
if(x < 0) { ctx.textAlign = "left"; ctx.fillText(name, 40, cvsY); }
else { ctx.textAlign = "right"; ctx.fillText(name, 470, cvsY); }
};
addPort("In", -w/2, 4, 'in'); addPort("Out", w/2, 4, 'out');
let py = 2;
Object.keys(this.props).forEach(k => { addPort(k, -w/2, py, 'prop', k); py -= 1.5; });
// The labels drawn onto `cvs` above were never attached to the scene before - add them now.
const labelTex = new THREE.CanvasTexture(cvs);
const labelPlane = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide, toneMapped: false }));
labelPlane.position.set(0, yOff, zOff + 0.05);
this.panelGroup.add(labelPlane);
}
updateVisibility() {
const inScope = (this.parentId === state.currentGroupId);
this.group.visible = inScope;
if (!inScope) return;
const pMode = state.view.propsMode;
if (pMode > 0) { this.panelGroup.visible = true; this.stripGroup.visible = false; this.largeIconGroup.visible = false; this.buildPanel(); }
else if (state.view.large) { this.panelGroup.visible = false; this.stripGroup.visible = false; this.largeIconGroup.visible = true; }
else { this.panelGroup.visible = false; this.stripGroup.visible = true; this.largeIconGroup.visible = false; }
if (window.app) window.app.updateLinks();
}
getSurfacePoint(targetPos) {
return this.group.position.clone().add(new THREE.Vector3(0,1,0));
}
setSelected(bool) { this.ring.material.opacity = bool ? 1 : 0; }
serialize() {
return {
id: this.id, type: this.type, name: this.name, icon: this.icon,
x: this.group.position.x, y: this.group.position.z, // Mapping Z to Y for storage
content: this.content, props: this.props, parentId: this.parentId
};
}
async execute(inputData = null) {
this.flash(); toast.info(`Exec: ${this.name}`);
const runtimeProps = { ...this.props };
state.cables.forEach(cable => {
if (cable.dst.node === this && cable.dst.key) {
const srcNode = cable.src.node;
let val = null;
if (cable.src.type === 'out') { val = srcNode.resultData; if(Array.isArray(val) && val.length === 1 && typeof val[0] !== 'object') val = val[0]; }
else if (cable.src.type === 'prop') { val = srcNode.props[cable.src.key]; }
if(val !== undefined && val !== null) {
const currentVal = runtimeProps[cable.dst.key];
if(!isNaN(parseFloat(currentVal)) && !isNaN(parseFloat(val))) val = parseFloat(val);
runtimeProps[cable.dst.key] = val;
}
}
});
let output = inputData;
const hasCode = this.content && this.content.replace(/\/\/.*/g, '').trim().length > 0;
if (this.type === 'workflow') {
// Executing a workflow node runs the source nodes it contains; the workflow
// itself still forwards its own input onward so external chains keep working.
const innerSources = state.nodes.filter(n => n.parentId === this.id && n.type === 'source');
if (innerSources.length > 0) { toast.info(`Running workflow: ${this.name}`); innerSources.forEach(n => n.execute(inputData)); }
output = inputData;
} else if (hasCode) {
try {
if (['action', 'view', 'join', 'gate', 'loop', 'delay'].includes(this.type)) {
// Main Thread execution for UI access
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const userFn = new AsyncFunction('input', 'props', 'lib', 'log', `try { ${this.content} } catch(err) { throw err; }`);
const logProxy = { log: (m) => console.log(`[${this.name}]`, m), info: (m) => toast.info(`${this.name}: ${m}`), warn: (m) => toast.warn(`${this.name}: ${m}`), error: (m) => toast.error(`${this.name}: ${m}`) };
const result = await userFn(inputData, runtimeProps, THREE, logProxy);
if (result !== undefined) output = result;
} else {
// Worker execution
const result = await engine.runAsync(this.id, this.content, inputData, runtimeProps);
if (result !== undefined) output = result;
}
} catch (e) { toast.error(`Error in ${this.name}: ${e.message}`); console.error(e); return; }
}
this.resultData = Array.isArray(output) ? output : [output];
if (this.type === 'view' && window.ui) window.ui.openPreview(this);
// Gate: block propagation when the code returns a falsy value (null/undefined/false/0/"")
if (this.type === 'gate' && !output) { toast.info(`Gate closed: ${this.name}`); return; }
// Loop: fan the downstream execution out once per item. If the node's code returned an
// array, each element becomes one iteration's payload (map-style). If props.loopCount is
// set instead, the same output is repeated that many times.
if (this.type === 'loop') {
const loopCount = !isNaN(parseFloat(runtimeProps.loopCount)) ? parseInt(runtimeProps.loopCount) : null;
const items = loopCount ? Array.from({ length: loopCount }, () => output) : (Array.isArray(output) ? output : [output]);
const iterDelay = !isNaN(parseFloat(runtimeProps.loopDelay)) ? parseFloat(runtimeProps.loopDelay) : 400;
toast.info(`Loop "${this.name}": ${items.length} iteration(s)`);
items.forEach((item, idx) => {
this.outputs.forEach(l => {
setTimeout(() => { l.pulse(); l.to.execute(item); }, idx * iterDelay);
});
});
return;
}
// Delay: honor a numeric props.delay (ms) if provided, otherwise fall back to the default pulse timing
const delayMs = this.type === 'delay' && !isNaN(parseFloat(runtimeProps.delay)) ? parseFloat(runtimeProps.delay) : 500;
this.outputs.forEach(l => {
l.pulse(); setTimeout(() => l.to.execute(output), delayMs);
});
}
flash() {
const target = state.view.propsMode > 0 ? this.panelGroup : (state.view.large ? this.largeIconGroup : this.stripGroup);
const s = target.scale.clone(); new TWEEN.Tween(target.scale).to({x:s.x*1.2, y:s.y*1.2, z:s.z*1.2}, 100).yoyo(true).repeat(1).start();
}
}
export class LaserLink {
constructor(n1, n2, listToAddTo = null) {
this.from = n1; this.to = n2;
if (listToAddTo !== state.implicitLinks) { n1.outputs.push(this); n2.inputs.push(this); }
const geo = new THREE.CylinderGeometry(0.2, 0.2, 1, 8, 1, true); geo.translate(0, 0.5, 0); geo.rotateX(Math.PI/2);
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(2, 2, 2), transparent: true, opacity: 0.6 });
this.mesh = new THREE.Mesh(geo, mat);
const coneGeo = new THREE.ConeGeometry(0.6, 1.5, 8); coneGeo.rotateX(Math.PI/2);
this.arrow = new THREE.Mesh(coneGeo, new THREE.MeshBasicMaterial({ color: new THREE.Color(2,2,2) }));
this.packet = new THREE.Mesh(new THREE.SphereGeometry(0.4), new THREE.MeshBasicMaterial({color:0xffffff})); this.packet.visible=false;
scene.add(this.mesh); scene.add(this.arrow); scene.add(this.packet);
const targetList = listToAddTo || state.links; targetList.push(this); this.update();
}
update() {
if(!this.from || !this.to) return;
if (this.from.parentId !== state.currentGroupId || this.to.parentId !== state.currentGroupId) {
this.mesh.visible = false; this.arrow.visible = false; return;
}
const visible = (state.view.propsMode === 0);
this.mesh.visible = visible; this.arrow.visible = visible;
if(!visible) return;
const startPos = this.from.group.position.clone().add(new THREE.Vector3(0,1,0));
const endPos = this.to.group.position.clone().add(new THREE.Vector3(0,1,0));
this.mesh.position.copy(startPos); this.mesh.lookAt(endPos);
const dist = startPos.distanceTo(endPos);
this.mesh.scale.set(1, 1, dist);
this.arrow.position.copy(endPos); this.arrow.lookAt(startPos);
}
pulse() {
if(!this.mesh.visible) return;
this.packet.visible=true; let t={v:0};
const startPos = this.from.group.position.clone().add(new THREE.Vector3(0,1,0));
const endPos = this.to.group.position.clone().add(new THREE.Vector3(0,1,0));
new TWEEN.Tween(t).to({v:1}, 500).onUpdate(()=>{ this.packet.position.lerpVectors(startPos, endPos, t.v); }).onComplete(()=>this.packet.visible=false).start();
}
dispose() {
scene.remove(this.mesh); scene.remove(this.arrow); scene.remove(this.packet);
this.mesh.geometry.dispose(); this.arrow.geometry.dispose();
if (state.links.includes(this)) { this.from.outputs = this.from.outputs.filter(l=>l!==this); this.to.inputs = this.to.inputs.filter(l=>l!==this); }
}
}
export class ConnectionCable {
constructor(port1, port2) {
this.src = { node: port1.userData.node, type: port1.userData.type, key: port1.userData.key };
this.dst = { node: port2.userData.node, type: port2.userData.type, key: port2.userData.key };
const geometry = new LineGeometry();
const material = new LineMaterial({ color: 0x888888, linewidth: 2, dashed: true });
material.resolution.set(window.innerWidth, window.innerHeight);
this.mesh = new Line2(geometry, material);
scene.add(this.mesh); state.cables.push(this); this.update();
}
update() {
if (this.src.node.parentId !== state.currentGroupId || this.dst.node.parentId !== state.currentGroupId) {
this.mesh.visible = false; return;
}
const visible = (state.view.propsMode > 0);
if (!visible) { this.mesh.visible = false; return; }
const p1 = this.src.node.ports.find(p => p.userData.type === this.src.type && p.userData.key === this.src.key);
const p2 = this.dst.node.ports.find(p => p.userData.type === this.dst.type && p.userData.key === this.dst.key);
if (!p1 || !p2) { this.mesh.visible = false; return; }
this.mesh.visible = true;
const v1 = new THREE.Vector3(); p1.getWorldPosition(v1);
const v2 = new THREE.Vector3(); p2.getWorldPosition(v2);
const d = v1.distanceTo(v2) * 0.4;
const curve = new THREE.CubicBezierCurve3(v1, v1.clone().add(new THREE.Vector3(d,0,0)), v2.clone().add(new THREE.Vector3(-d,0,0)), v2);
const positions = []; curve.getPoints(20).forEach(p => positions.push(p.x, p.y, p.z));
this.mesh.geometry.setPositions(positions);
this.mesh.computeLineDistances();
}
dispose() {
scene.remove(this.mesh); this.mesh.geometry.dispose(); this.mesh.material.dispose();
state.cables = state.cables.filter(c => c !== this);
}
}engine.js
import { state, toast } from './state.js';
import { SpatialNode, LaserLink } from './core.js';
import * as THREE from 'three';
const WORKER_CODE = `
self.onmessage = async (e) => {
const { id, code, input, props } = e.data;
try {
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
// Fix: Do not use template literals for body construction to avoid syntax errors with backticks
const funcBody = "try {\\n" + code + "\\n} catch(err) { throw err; }";
const userFn = new AsyncFunction('input', 'props', 'log', funcBody);
const logs = [];
const logProxy = {
log: (...args) => logs.push({t:'log', m:args.join(' ')}),
info: (...args) => logs.push({t:'info', m:args.join(' ')}),
warn: (...args) => logs.push({t:'warn', m:args.join(' ')}),
error: (...args) => logs.push({t:'error', m:args.join(' ')})
};
const result = await userFn(input, props, logProxy);
self.postMessage({ status: 'success', id, result, logs });
} catch (err) {
self.postMessage({ status: 'error', id, message: err.message });
}
};
`;
export class ExecutionEngine {
constructor() {
const blob = new Blob([WORKER_CODE], { type: 'application/javascript' });
this.workerUrl = URL.createObjectURL(blob);
}
runAsync(id, code, input, props) {
return new Promise((resolve, reject) => {
const worker = new Worker(this.workerUrl);
const timeoutId = setTimeout(() => { worker.terminate(); reject(new Error("Timeout (3s)")); }, 3000);
worker.onmessage = (e) => {
clearTimeout(timeoutId); worker.terminate();
if (e.data.status === 'success') {
if(e.data.logs) e.data.logs.forEach(l => {
if(l.t==='error') toast.error(l.m); else if(l.t==='warn') toast.warn(l.m); else console.log(l.m);
});
resolve(e.data.result);
} else { reject(new Error(e.data.message)); }
};
worker.onerror = (e) => { clearTimeout(timeoutId); worker.terminate(); reject(new Error(e.message)); };
worker.postMessage({ id, code, input, props });
});
}
}
export class HistoryManager {
constructor() { this.past = []; this.future = []; this.locked = false; }
snapshot() {
if(this.locked) return;
const stateDump = {
nodes: state.nodes.map(n => n.serialize()),
links: state.links.map(l => ({from: l.from.id, to: l.to.id})),
nextId: state.nextNodeId
};
if(this.past.length > 0) { const last = JSON.stringify(this.past[this.past.length-1]); if(last === JSON.stringify(stateDump)) return; }
this.past.push(stateDump); if(this.past.length > 20) this.past.shift(); this.future = [];
}
undo() {
if(this.past.length === 0) return toast.info("Nothing to Undo");
const current = { nodes: state.nodes.map(n => n.serialize()), links: state.links.map(l => ({from: l.from.id, to: l.to.id})), nextId: state.nextNodeId };
this.future.push(current);
const prev = this.past.pop(); this.loadState(prev); toast.info("Undo");
}
redo() {
if(this.future.length === 0) return toast.info("Nothing to Redo");
const next = this.future.pop();
this.past.push({ nodes: state.nodes.map(n => n.serialize()), links: state.links.map(l => ({from: l.from.id, to: l.to.id})), nextId: state.nextNodeId });
this.loadState(next); toast.info("Redo");
}
loadState(data) {
if(!window.app) return;
this.locked = true; window.app.clearScene(); state.nextNodeId = data.nextId;
const idMap = {};
data.nodes.forEach(d => {
const n = new SpatialNode(d.type, new THREE.Vector3(d.x, 0, d.y), d.id);
n.name = d.name; n.content = d.content; n.props = d.props; n.icon = d.icon; n.parentId = d.parentId || null;
n.buildVisuals(); idMap[d.id] = n;
});
data.links.forEach(l => { if(idMap[l.from] && idMap[l.to]) new LaserLink(idMap[l.from], idMap[l.to]); });
window.app.updateView();
this.locked = false;
}
}library.js
import { state, toast } from './state.js';
import { ui } from './ui.js';
import { SpatialNode, LaserLink } from './core.js';
import * as THREE from 'three';
export const lib = {
currentTab: 'object', targetItem: null, clipFile: null,
togglePane: () => { document.getElementById('lib-pane').classList.toggle('collapsed'); },
setTab: (t) => { lib.currentTab = t; document.querySelectorAll('.lib-tab').forEach(e => e.classList.remove('active')); document.getElementById(t === 'object' ? 'tab-obj' : 'tab-flow').classList.add('active'); lib.refresh(); },
async getDirHandle(name) { if(!state.projectHandle) return null; return await state.projectHandle.getDirectoryHandle(name, {create:true}); },
async saveFromSelection(name, icon) {
if(!state.projectHandle) return toast.warn("Open a Project first");
if(!name) return;
if(state.selectedNodes.length === 0) return toast.warn("Nothing selected");
const dirName = lib.currentTab === 'object' ? 'ObjectLib' : 'WorkFlowLib';
const dir = await lib.getDirHandle(dirName);
const nodesData = state.selectedNodes.map(n => n.serialize());
const linksData = state.links
.filter(l => state.selectedNodes.includes(l.from) && state.selectedNodes.includes(l.to))
.map(l => ({ from: l.from.id, to: l.to.id }));
const data = { icon: icon || null, nodes: nodesData, links: linksData };
const fileName = name.endsWith('.json') ? name : name + '.json';
try {
const fh = await dir.getFileHandle(fileName, { create: true });
const w = await fh.createWritable();
await w.write(JSON.stringify(data, null, 2));
await w.close();
toast.info(`Saved to ${dirName}/${fileName}`);
lib.refresh();
} catch(e) { console.error(e); toast.error("Save failed: " + e.message); }
},
async refresh() {
const list = document.getElementById('lib-content'); list.innerHTML = "";
if(!state.projectHandle) { list.innerHTML = "<div style='padding:20px; text-align:center;'>Open a Project to use Library.</div>"; return; }
const dirName = lib.currentTab === 'object' ? 'ObjectLib' : 'WorkFlowLib';
try {
const dir = await lib.getDirHandle(dirName);
await lib.renderTree(dir, list, dirName);
} catch(e) { console.error(e); }
},
async renderTree(dirHandle, parentEl, pathVal) {
const entries = [];
for await (const [name, handle] of dirHandle.entries()) { entries.push({name, handle}); }
entries.sort((a,b) => (a.handle.kind === b.handle.kind ? a.name.localeCompare(b.name) : (a.handle.kind === 'directory' ? -1 : 1)));
for(const entry of entries) {
const nodeEl = document.createElement('div'); nodeEl.className = 'tree-node';
const headEl = document.createElement('div'); headEl.className = 'tree-header';
const isDir = entry.handle.kind === 'directory';
headEl.innerHTML = `<span class="tree-arrow">${isDir ? '▶' : ''}</span><span class="tree-icon ${isDir?'':'file-icon'}">${isDir ? '📂' : '📄'}</span><span>${entry.name.replace('.json','')}</span>`;
headEl.onclick = async (e) => {
e.stopPropagation();
document.querySelectorAll('.tree-header').forEach(el=>el.classList.remove('selected'));
headEl.classList.add('selected');
lib.targetItem = { name: entry.name, handle: entry.handle, parent: dirHandle, parentPath: pathVal };
if(isDir) { nodeEl.classList.toggle('open'); }
};
headEl.ondblclick = async (e) => { if(!isDir) { await lib.loadItem(entry.handle, true); } };
headEl.oncontextmenu = (e) => {
e.preventDefault(); e.stopPropagation();
lib.targetItem = { name: entry.name, handle: entry.handle, parent: dirHandle, parentPath: pathVal };
headEl.click();
const m = document.getElementById('ctx-lib');
m.style.display = 'flex'; m.style.left = e.pageX + 'px'; m.style.top = e.pageY + 'px';
};
headEl.draggable = !isDir;
headEl.ondragstart = (e) => {
e.dataTransfer.setData('application/json', JSON.stringify({type: 'lib-load', file: entry.name, tab: lib.currentTab, fullPath: pathVal + '/' + entry.name}));
e.dataTransfer.effectAllowed = 'copy';
lib.targetItem = { handle: entry.handle };
};
nodeEl.appendChild(headEl); parentEl.appendChild(nodeEl);
if(isDir) {
const childContainer = document.createElement('div'); childContainer.className = 'tree-children';
nodeEl.appendChild(childContainer);
await lib.renderTree(entry.handle, childContainer, pathVal + '/' + entry.name);
}
}
},
async loadItem(fileHandle, atCenter=false) {
const content = JSON.parse(await (await fileHandle.getFile()).text());
state.clipboard = content;
if(atCenter && window.app) { const center = new THREE.Vector3(); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(0,0), window.app.camera); raycaster.ray.intersectPlane(window.app.plane, center); state.cursorPos.copy(center); }
if(window.app) window.app.pasteAtCursor();
},
ctxAction: async (act) => {
document.getElementById('ctx-lib').style.display='none';
if(!lib.targetItem) return;
const { name, handle, parent } = lib.targetItem;
if(act === 'new-folder') {
ui.showInputDialog("Folder Name:", async (val, icon) => {
if(!val) return;
let targetDir = handle.kind === 'directory' ? handle : parent;
await targetDir.getDirectoryHandle(val, {create:true});
lib.refresh();
}, false);
}
if(act === 'delete') {
if(confirm(`Delete ${name}?`)) { await parent.removeEntry(name, {recursive:true}); lib.refresh(); }
}
if(act === 'rename') {
ui.showInputDialog("New Name:", async (val) => {
if(!val || val === name) return;
try {
if (handle.kind === 'file') {
const file = await handle.getFile(); const text = await file.text();
const newName = val.endsWith('.json') ? val : val + '.json';
const newHandle = await parent.getFileHandle(newName, {create: true});
const w = await newHandle.createWritable(); await w.write(text); await w.close();
await parent.removeEntry(name);
} else {
const newDir = await parent.getDirectoryHandle(val, {create: true});
await lib.copyDirectoryRecursive(handle, newDir);
await parent.removeEntry(name, {recursive: true});
}
lib.refresh();
} catch(e) { console.error(e); toast.error("Rename failed: " + e.message); }
}, false);
}
},
async copyDirectoryRecursive(srcHandle, destHandle) {
for await (const entry of srcHandle.values()) {
if (entry.kind === 'file') {
const srcFile = await entry.getFile(); const destFile = await destHandle.getFileHandle(entry.name, {create: true});
const w = await destFile.createWritable(); await w.write(await srcFile.arrayBuffer()); await w.close();
} else if (entry.kind === 'directory') {
const newSub = await destHandle.getDirectoryHandle(entry.name, {create: true}); await lib.copyDirectoryRecursive(entry, newSub);
}
}
},
createFolder: () => {
if(!state.projectHandle) return;
ui.showInputDialog("New Folder Name:", async (val) => {
if(!val) return;
const dirName = lib.currentTab === 'object' ? 'ObjectLib' : 'WorkFlowLib';
const dir = await lib.getDirHandle(dirName);
await dir.getDirectoryHandle(val, {create:true});
lib.refresh();
}, false);
},
handleDragOver: (e) => { e.preventDefault(); document.getElementById('lib-content').classList.add('drop-zone-active'); },
handleDragLeave: (e) => { document.getElementById('lib-content').classList.remove('drop-zone-active'); },
handleDrop: (e) => {
e.preventDefault(); document.getElementById('lib-content').classList.remove('drop-zone-active');
const dataStr = e.dataTransfer.getData('application/json');
if (dataStr) { try { const data = JSON.parse(dataStr); if(data.type === 'canvas-save') { ui.showLibSaveDialog(); } } catch(err) { } }
}
};llm.js
import { state, toast } from './state.js';
export const llm = {
cfg() { return state.projectSettings.llm; },
// Build the CLI command the user needs to run themselves in a terminal.
// A browser page has no capability to launch or kill local executables,
// so this is the closest honest equivalent of a "start" action.
buildLaunchCommand() {
const c = llm.cfg();
if (c.engine === 'ollama') {
const exe = c.execPath || 'ollama';
return `${exe} serve`;
}
const exe = c.execPath || './llama-server';
let cmd = `${exe} --model "${c.modelPath || '<model.gguf>'}" --port ${c.port || 8080}`;
if (c.kvCacheK) cmd += ` --cache-type-k ${c.kvCacheK}`;
if (c.kvCacheV) cmd += ` --cache-type-v ${c.kvCacheV}`;
if (c.loraPath) cmd += ` --lora "${c.loraPath}"`;
if (c.extraArgs) cmd += ` ${c.extraArgs}`;
return cmd;
},
healthUrl() {
const c = llm.cfg();
return c.engine === 'ollama' ? `http://localhost:${c.port}/api/tags` : `http://localhost:${c.port}/health`;
},
async checkStatus() {
const c = llm.cfg();
try {
const res = await fetch(llm.healthUrl(), { method: 'GET' });
c.status = res.ok ? 'running' : 'stopped';
} catch (e) { c.status = 'stopped'; }
llm.updateStatusUI();
return c.status === 'running';
},
async start() {
const cmdEl = document.getElementById('llm-launch-cmd');
if (cmdEl) cmdEl.value = llm.buildLaunchCommand();
toast.info("Checking for a running local server...");
const ok = await llm.checkStatus();
const c = llm.cfg();
if (ok) toast.info(`Connected: ${c.engine === 'ollama' ? 'Ollama' : 'llama.cpp'} is reachable on port ${c.port}`);
else toast.warn("Not reachable yet. Run the launch command in a terminal, then press Start again.");
},
stop() {
// This cannot terminate the actual local process (browsers have no such access).
// It only clears the connection state shown in this UI.
llm.cfg().status = 'stopped';
llm.updateStatusUI();
toast.info("Disconnected in the UI. To actually stop the server, close/Ctrl+C it in its terminal.");
},
updateStatusUI() {
const el = document.getElementById('llm-status');
if (!el) return;
const running = llm.cfg().status === 'running';
el.innerText = running ? '● RUNNING' : '○ STOPPED';
el.style.color = running ? '#00ffcc' : '#888';
},
// Real generation call against the local server's HTTP API.
async generate(prompt) {
const c = llm.cfg();
await llm.checkStatus();
if (c.status !== 'running') throw new Error("Local LLM server is not reachable. Start it first (Project > Global Properties).");
if (c.engine === 'ollama') {
if (!c.modelPath) throw new Error("Set the Ollama model name in Global Properties.");
const res = await fetch(`http://localhost:${c.port}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: c.modelPath, prompt, stream: false })
});
if (!res.ok) throw new Error(`Ollama returned HTTP ${res.status}`);
const data = await res.json();
return data.response;
} else {
const res = await fetch(`http://localhost:${c.port}/completion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, n_predict: 4096, temperature: 0.7 })
});
if (!res.ok) throw new Error(`llama.cpp server returned HTTP ${res.status}`);
const data = await res.json();
return data.content;
}
}
};
main.js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import TWEEN from 'three/addons/libs/tween.module.js';
import { state, toast } from './state.js';
import { ExecutionEngine, HistoryManager } from './engine.js';
import { SpatialNode, LaserLink, ConnectionCable } from './core.js';
import { ui } from './ui.js';
import { proj } from './ai.js';
import { lib } from './library.js';
import { llm } from './llm.js';
// --- Setup Three.js ---
export const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x0d1218, 0.0012);
export const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 2000);
camera.position.copy(state.homeState.pos);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 1.35;
document.body.appendChild(renderer.domElement);
renderer.domElement.style.touchAction = 'none';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.1, 0.6, 0.85);
composer.addPass(bloomPass);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enabled = false;
scene.add(new THREE.GridHelper(200, 50, 0x445566, 0x2a3540)); scene.add(new THREE.AmbientLight(0xffffff, 0.75));
const dl = new THREE.DirectionalLight(0xffffff, 1.1); dl.position.set(20,50,20); scene.add(dl);
const fillLight = new THREE.DirectionalLight(0xaaccff, 0.4); fillLight.position.set(-20,30,-20); scene.add(fillLight);
const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2();
export const plane = new THREE.Plane(new THREE.Vector3(0,1,0), 0);
export const engine = new ExecutionEngine();
export const historyMgr = new HistoryManager();
// --- Main App Logic ---
export const app = {
camera: camera,
plane: plane,
setMode: (m) => {
state.mode = m; document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
let btn = document.querySelector(`button[onclick="app.setMode('${m}')"]`); if(btn) btn.classList.add('active');
const isCamMode = ['rotate', 'pan', 'zoom'].includes(m); controls.enabled = isCamMode;
if(isCamMode) {
if(m==='rotate') controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
else if(m==='pan') controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.ROTATE, RIGHT: THREE.MOUSE.PAN };
else if(m==='zoom') controls.mouseButtons = { LEFT: THREE.MOUSE.DOLLY, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
renderer.domElement.style.cursor = m === 'pan' ? 'grab' : 'default';
} else { renderer.domElement.style.cursor = 'default'; }
},
updateView: () => {
const badge = document.getElementById('scope-badge');
if(state.currentGroupId) {
const groupNode = state.nodes.find(n => n.id === state.currentGroupId);
if(badge) { badge.style.display = 'block'; badge.innerText = `SCOPE: ${groupNode ? groupNode.name : 'Unknown'}`; }
} else { if(badge) badge.style.display = 'none'; }
state.nodes.forEach(n => n.updateVisibility());
app.updateLinks();
},
updateLinks: () => {
state.links.forEach(l => l.update());
state.implicitLinks.forEach(l => l.update());
state.cables.forEach(c => c.update());
},
focusSelection: () => {
if(state.selectedNodes.length === 0) { toast.warn("No node selected"); return; }
const p = state.selectedNodes[0].group.position;
new TWEEN.Tween(camera.position).to({x:p.x, y:p.y+40, z:p.z+30}, 800).easing(TWEEN.Easing.Cubic.Out).start();
new TWEEN.Tween(controls.target).to({x:p.x, y:p.y, z:p.z}, 800).easing(TWEEN.Easing.Cubic.Out).onComplete(() => { app.setMode('rotate'); }).start();
},
selectNode: (node, ctrlKey) => {
if(ctrlKey) {
if(state.selectedNodes.includes(node)) { node.setSelected(false); state.selectedNodes = state.selectedNodes.filter(n => n !== node); }
else { state.selectedNodes.push(node); node.setSelected(true); }
} else { if(!state.selectedNodes.includes(node)) { app.clearSelection(); state.selectedNodes.push(node); node.setSelected(true); } }
},
clearSelection: () => { state.selectedNodes.forEach(n => n.setSelected(false)); state.selectedNodes = []; },
clearScene: () => {
state.nodes.forEach(n=>scene.remove(n.group)); state.nodes=[];
state.links.forEach(l=>l.dispose()); state.links=[];
state.cables.forEach(c=>c.dispose()); state.cables=[];
state.implicitLinks.forEach(l=>l.dispose()); state.implicitLinks=[];
state.currentGroupId = null; state.scopeStack = [];
},
copySelection: () => {
if(state.selectedNodes.length === 0) return;
const nodesData = state.selectedNodes.map(n => n.serialize());
const linksData = state.links.filter(l => state.selectedNodes.includes(l.from) && state.selectedNodes.includes(l.to)).map(l => ({ from: l.from.id, to: l.to.id }));
state.clipboard = { nodes: nodesData, links: linksData }; toast.info(`Copied ${nodesData.length} items`);
},
pasteAtCursor: () => {
historyMgr.snapshot();
if(!state.clipboard) return;
const center = state.clipboard.nodes.reduce((acc, n) => ({x:acc.x+n.x, z:acc.z+n.y}), {x:0, z:0});
center.x /= state.clipboard.nodes.length; center.z /= state.clipboard.nodes.length;
const offset = { x: state.cursorPos.x - center.x, z: state.cursorPos.z - center.z };
const idMap = {};
state.clipboard.nodes.forEach(d => {
const n = new SpatialNode(d.type, new THREE.Vector3(d.x + offset.x, 0, d.y + offset.z));
n.content = d.content; n.name = d.name + "_Copy"; n.props = d.props || {}; n.icon = d.icon;
n.parentId = state.currentGroupId;
n.buildVisuals(); idMap[d.id] = n;
});
state.clipboard.links.forEach(l => { if(idMap[l.from] && idMap[l.to]) new LaserLink(idMap[l.from], idMap[l.to]); });
app.clearSelection(); Object.values(idMap).forEach(n => n.setSelected(true)); state.selectedNodes = Object.values(idMap); toast.info("Pasted");
},
addNodeAtCenter: (type) => {
historyMgr.snapshot(); raycaster.setFromCamera(new THREE.Vector2(0,0), camera); const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(plane, pt);
const n = new SpatialNode(type, pt); app.clearSelection(); app.selectNode(n, false); document.querySelectorAll('.ctx-menu').forEach(e => e.style.display='none');
},
ctxAction: (e, act) => {
if(e) e.stopPropagation(); document.querySelectorAll('.ctx-menu').forEach(e => e.style.display='none');
const targetNodes = state.selectedNodes;
if(act==='delete') {
if(targetNodes.length === 0) return;
historyMgr.snapshot();
state.links = state.links.filter(l => { if(targetNodes.includes(l.from) || targetNodes.includes(l.to)) { l.dispose(); return false; } return true; });
state.implicitLinks = state.implicitLinks.filter(l => { if(targetNodes.includes(l.from) || targetNodes.includes(l.to)) { l.dispose(); return false; } return true; });
state.cables = state.cables.filter(c => { if(targetNodes.includes(c.src.node) || targetNodes.includes(c.dst.node)) { c.dispose(); return false; } return true; });
targetNodes.forEach(n => { scene.remove(n.group); });
state.nodes = state.nodes.filter(n => !targetNodes.includes(n)); state.selectedNodes = [];
}
if(act==='copy') app.copySelection();
if(act==='saveLib') { window.ui.showLibSaveDialog(); }
if(act==='edit') targetNodes.length === 1 ? (targetNodes[0].type === 'workflow' ? app.enterWorkflow(targetNodes[0]) : window.ui.openEditor(targetNodes[0])) : toast.warn("Select one node");
if(act==='props') targetNodes.length === 1 ? window.ui.openNodeProps(targetNodes[0]) : toast.warn("Select one node");
if(act === 'exec') targetNodes.length > 0 ? targetNodes.forEach(n => n.execute(null)) : toast.warn("Select nodes");
if(act === 'preview') targetNodes.length === 1 && targetNodes[0].type === 'view' ? window.ui.openPreview(targetNodes[0]) : toast.warn("Select one View node");
if(act === 'create-wf') app.createWorkflow();
if(act === 'cancel-wf') app.cancelWorkflow();
},
createWorkflow: () => {
const sel = state.selectedNodes;
if(sel.length < 1) return toast.warn("Select nodes to group");
historyMgr.snapshot();
const center = sel.reduce((acc, n) => ({x:acc.x+n.group.position.x, z:acc.z+n.group.position.z}), {x:0, z:0});
center.x /= sel.length; center.z /= sel.length;
const wfNode = new SpatialNode('workflow', new THREE.Vector3(center.x, 0, center.z));
wfNode.name = "WorkFlow_" + wfNode.id;
const internalIds = sel.map(n => n.id);
const propsToAdd = {};
state.cables.forEach(c => {
if (internalIds.includes(c.dst.node.id) && !internalIds.includes(c.src.node.id)) {
const propKey = `${c.dst.node.name}_${c.dst.key}`;
propsToAdd[propKey] = 0;
}
});
wfNode.props = propsToAdd;
wfNode.buildVisuals();
sel.forEach(n => { n.parentId = wfNode.id; n.setSelected(false); });
state.selectedNodes = [wfNode];
wfNode.setSelected(true);
app.updateView();
toast.info("Workflow Created");
},
enterWorkflow: (node) => {
if(!node || node.type !== 'workflow') return;
state.scopeStack.push({ id: state.currentGroupId, camPos: camera.position.clone(), camTarget: controls.target.clone() });
state.currentGroupId = node.id;
app.clearSelection();
new TWEEN.Tween(camera.position).to(state.homeState.pos, 500).easing(TWEEN.Easing.Quadratic.Out).start();
new TWEEN.Tween(controls.target).to(state.homeState.target, 500).easing(TWEEN.Easing.Quadratic.Out).start();
app.updateView();
toast.info(`Entered ${node.name}`);
},
exitWorkflow: () => {
if(state.scopeStack.length === 0) return;
const prev = state.scopeStack.pop();
state.currentGroupId = prev.id;
app.clearSelection();
new TWEEN.Tween(camera.position).to(prev.camPos, 500).easing(TWEEN.Easing.Quadratic.Out).start();
new TWEEN.Tween(controls.target).to(prev.camTarget, 500).easing(TWEEN.Easing.Quadratic.Out).start();
app.updateView();
toast.info("Exited Workflow");
},
cancelWorkflow: () => {
if(state.selectedNodes.length !== 1 || state.selectedNodes[0].type !== 'workflow') return toast.warn("Select one Workflow");
historyMgr.snapshot();
const wfNode = state.selectedNodes[0];
const children = state.nodes.filter(n => n.parentId === wfNode.id);
children.forEach(n => { n.parentId = state.currentGroupId; });
scene.remove(wfNode.group);
state.nodes = state.nodes.filter(n => n !== wfNode);
state.selectedNodes = [];
app.updateView();
toast.info("Workflow Cancelled");
},
executeAll: () => { state.executionId++; state.nodes.filter(n => n.parentId === state.currentGroupId && n.type === 'source').forEach(n => n.execute(null)); toast.info("Execution Started"); }
};
// --- GLOBAL BINDING (Fixes ReferenceErrors) ---
window.app = app;
window.ui = ui;
window.proj = proj;
window.lib = lib;
window.llm = llm;
window.historyMgr = historyMgr;
// --- Utilities & Events ---
function findParentNode(obj) { while(obj) { if(obj.userData && obj.userData.isNode) return obj.userData.obj; obj = obj.parent; } return null; }
window.addEventListener('pointerdown', (e) => {
if(e.button!==0 || e.target.closest('.interactive') || e.target.closest('#menubar') || e.target.closest('#mode-bar') || e.target.closest('#lib-pane')) return;
if (['rotate', 'pan', 'zoom'].includes(state.mode)) return;
mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(scene.children, true);
const hitPort = hits.find(h => h.object.userData.isPort);
let nodeObj = null; hits.find(h => { const n = findParentNode(h.object); if(n) { nodeObj = n; return true; } return false; });
raycaster.ray.intersectPlane(plane, state.cursorPos);
if (state.mode === 'select') {
if (nodeObj) {
const node = nodeObj;
const now = Date.now();
if (now - node.lastClick < 300) {
if(node.type === 'workflow') app.enterWorkflow(node);
else window.ui.openEditor(node);
} else { app.selectNode(node, e.ctrlKey); }
node.lastClick = now;
if(state.selectedNodes.includes(node)) { renderer.domElement.draggable = true; historyMgr.snapshot(); }
} else if(e.ctrlKey) {
app.clearSelection();
state.boxSel.active = true; state.boxSel.start.set(e.clientX, e.clientY);
const box = document.getElementById('selection-box');
box.style.left = e.clientX + 'px'; box.style.top = e.clientY + 'px'; box.style.width = '0px'; box.style.height = '0px'; box.style.display = 'block';
} else {
app.clearSelection();
state.panDrag.active = true; state.panDrag.anchor.copy(state.cursorPos);
renderer.domElement.style.cursor = 'grabbing';
}
}
else if (state.mode === 'move' && nodeObj) {
const node = nodeObj;
if(!state.selectedNodes.includes(node)) { app.clearSelection(); app.selectNode(node, false); }
state.dragNode = node; historyMgr.snapshot();
}
else if (state.mode === 'link') {
if(hitPort) { state.linkStartPort = hitPort.object; } else if (nodeObj) { state.linkStartNode = nodeObj; }
}
});
window.addEventListener('pointermove', (e) => {
mouse.x = (e.clientX/window.innerWidth)*2-1;
mouse.y = -(e.clientY/window.innerHeight)*2+1;
if(state.dragNode && state.mode==='move') {
raycaster.setFromCamera(mouse, camera); const pt = new THREE.Vector3(); raycaster.ray.intersectPlane(plane, pt);
if(pt) { const delta = pt.clone().sub(state.dragNode.group.position); state.selectedNodes.forEach(n => n.group.position.add(delta)); app.updateLinks(); }
}
if(state.panDrag.active && state.mode === 'select') {
raycaster.setFromCamera(mouse, camera);
const pt = new THREE.Vector3();
if(raycaster.ray.intersectPlane(plane, pt)) {
const delta = state.panDrag.anchor.clone().sub(pt);
camera.position.add(delta); controls.target.add(delta); controls.update();
}
}
if(state.boxSel.active && state.mode === 'select') {
const currentX = e.clientX; const currentY = e.clientY;
const x = Math.min(currentX, state.boxSel.start.x), y = Math.min(currentY, state.boxSel.start.y);
const w = Math.abs(currentX - state.boxSel.start.x), h = Math.abs(currentY - state.boxSel.start.y);
const box = document.getElementById('selection-box');
box.style.left = x + 'px'; box.style.top = y + 'px'; box.style.width = w + 'px'; box.style.height = h + 'px';
}
});
window.addEventListener('keydown', (e) => {
if(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'F1') { e.preventDefault(); state.view.propsMode = (state.view.propsMode + 1) % 3; const modes = ['OFF', 'NORMAL', 'GROUND']; toast.info(`Properties: ${modes[state.view.propsMode]}`); app.updateView(); }
else if (e.key === 'F2') { e.preventDefault(); state.view.large = !state.view.large; toast.info(`Large Icons: ${state.view.large ? 'ON' : 'OFF'}`); app.updateView(); }
else if (e.key === 'PageUp') { e.preventDefault(); if(camera.position.distanceTo(controls.target) > 5) { camera.position.add(new THREE.Vector3().subVectors(controls.target, camera.position).normalize().multiplyScalar(5)); controls.update(); } }
else if (e.key === 'PageDown') { e.preventDefault(); camera.position.add(new THREE.Vector3().subVectors(camera.position, controls.target).normalize().multiplyScalar(5)); controls.update(); }
else if (e.key === 'Home') { e.preventDefault(); new TWEEN.Tween(camera.position).to(state.homeState.pos, 800).easing(TWEEN.Easing.Cubic.Out).start(); new TWEEN.Tween(controls.target).to(state.homeState.target, 800).easing(TWEEN.Easing.Cubic.Out).start(); toast.info("View Reset"); }
else if (e.key === 'Delete') { app.ctxAction(null, 'delete'); }
else if (e.ctrlKey && (e.key === 'c' || e.key === 'C')) { app.copySelection(); }
else if (e.ctrlKey && (e.key === 'v' || e.key === 'V')) { raycaster.setFromCamera(mouse, camera); raycaster.ray.intersectPlane(plane, state.cursorPos); app.pasteAtCursor(); }
else if (e.ctrlKey && (e.key === 'z' || e.key === 'Z')) { historyMgr.undo(); }
else if (e.ctrlKey && (e.key === 'y' || e.key === 'Y')) { historyMgr.redo(); }
});
window.addEventListener('pointerup', (e) => {
renderer.domElement.draggable = false;
if(state.panDrag.active) { state.panDrag.active = false; renderer.domElement.style.cursor = 'default'; }
if(state.boxSel.active) {
state.boxSel.active = false;
const box = document.getElementById('selection-box');
const startX = parseFloat(box.style.left), startY = parseFloat(box.style.top), w = parseFloat(box.style.width), h = parseFloat(box.style.height);
box.style.display = 'none';
if(w > 5 || h > 5) {
state.nodes.forEach(n => {
if(n.parentId !== state.currentGroupId) return;
const p = n.group.position.clone().project(camera);
const sx = (p.x * .5 + .5) * window.innerWidth; const sy = (-(p.y * .5) + .5) * window.innerHeight;
if(sx > startX && sx < (startX + w) && sy > startY && sy < (startY + h)) app.selectNode(n, true);
});
}
}
state.dragNode = null;
if(state.mode === 'link') {
mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(scene.children, true);
const hitPort = hits.find(h => h.object.userData.isPort);
let nodeObj = null; hits.find(h => { const n = findParentNode(h.object); if(n) { nodeObj = n; return true; } return false; });
if(state.linkStartPort && hitPort && state.linkStartPort !== hitPort.object) { historyMgr.snapshot(); new ConnectionCable(state.linkStartPort, hitPort.object); app.updateLinks(); toast.info("Parameter Connected"); }
else if (state.linkStartNode && nodeObj && state.linkStartNode !== nodeObj) { historyMgr.snapshot(); new LaserLink(state.linkStartNode, nodeObj); toast.info("Node Linked"); }
state.linkStartNode = null; state.linkStartPort = null;
}
});
document.body.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('tree-header')) { }
else if (state.mode === 'select' && state.selectedNodes.length > 0 && e.target === renderer.domElement) {
e.dataTransfer.setData('application/json', JSON.stringify({type: 'canvas-save'})); e.dataTransfer.effectAllowed = 'copy';
const img = new Image(); img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; e.dataTransfer.setDragImage(img, 0, 0);
} else { e.preventDefault(); }
});
document.body.addEventListener('dragover', e => e.preventDefault());
document.body.addEventListener('drop', async (e) => {
e.preventDefault(); const dataStr = e.dataTransfer.getData('application/json');
if (dataStr) { try { const data = JSON.parse(dataStr);
if(data.type === 'lib-load' && e.target === renderer.domElement) {
historyMgr.snapshot();
const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); raycaster.ray.intersectPlane(plane, state.cursorPos);
if(!window.lib.targetItem && !state.projectHandle) return;
let fileHandle = window.lib.targetItem ? window.lib.targetItem.handle : null;
if(fileHandle) window.lib.loadItem(fileHandle, true);
}
if(data.type === 'canvas-save' && e.target.closest('#lib-content')) window.ui.showLibSaveDialog();
} catch(err) { } }
});
window.addEventListener('contextmenu', (e) => {
if(e.target.closest('.interactive') || e.target.closest('#lib-pane')) return;
e.preventDefault(); mouse.x = (e.clientX/window.innerWidth)*2-1; mouse.y = -(e.clientY/window.innerHeight)*2+1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(scene.children, true);
let hitNode = null; hits.find(h => { const n = findParentNode(h.object); if(n) { hitNode = n; return true; } return false; });
const m = hitNode ? document.getElementById('ctx-node') : document.getElementById('ctx-global');
document.querySelectorAll('.ctx-menu').forEach(e=>e.style.display='none');
if(hitNode) {
if(!state.selectedNodes.includes(hitNode)) { app.clearSelection(); app.selectNode(hitNode, false); }
const pvBtn = document.getElementById('ctx-preview'); if(pvBtn) pvBtn.style.display = hitNode.type === 'view' ? 'block' : 'none';
const crWf = document.getElementById('ctx-create-wf'); if(crWf) crWf.style.display = hitNode.type === 'workflow' ? 'none' : 'block';
const caWf = document.getElementById('ctx-cancel-wf'); if(caWf) caWf.style.display = hitNode.type === 'workflow' ? 'block' : 'none';
} else { const clWf = document.getElementById('ctx-close-wf'); if(clWf) clWf.style.display = state.currentGroupId ? 'block' : 'none'; }
m.style.display = 'flex'; m.style.left = e.pageX + 'px'; m.style.top = e.pageY + 'px';
});
window.addEventListener('click', (e) => { if(!e.target.closest('.ctx-menu')) document.querySelectorAll('.ctx-menu').forEach(e=>e.style.display='none'); });
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight); composer.setSize(window.innerWidth, window.innerHeight);
bloomPass.setSize(new THREE.Vector2(window.innerWidth, window.innerHeight));
state.cables.forEach(c => c.mesh.material.resolution.set(window.innerWidth, window.innerHeight));
});
// Start Animation
function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); composer.render(); state.links.forEach(l => l.update()); state.implicitLinks.forEach(l => l.update()); state.cables.forEach(c => c.update()); }
animate();
app.setMode('select');
new SpatialNode('source', new THREE.Vector3(-10,0,0));state.js
import * as THREE from 'three';
export const COLORS = { source: 0x00ffff, process: 0xffaa00, agent: 0xaa00ff, join: 0xff0055, action: 0xff8800, loop: 0xffee00, gate: 0xffee00, delay: 0x888888, view: 0x00ff00, workflow: 0x3366ff };
export const DEFAULT_ICONS = { source: '📄', process: '⚙️', agent: '🤖', join: '🔗', action: '⚡', view: '👁️', loop: '🔄', gate: '❓', delay: '⏳', workflow: '📦' };
export const state = {
nodes: [], links: [], cables: [], implicitLinks: [],
mode: 'select', selectedNodes: [],
dragNode: null, linkStartNode: null, linkStartPort: null,
cursorPos: new THREE.Vector3(), projectHandle: null,
clipboard: null,
boxSel: { start: new THREE.Vector2(), active: false },
panDrag: { active: false, anchor: new THREE.Vector3() },
nextNodeId: 1, executionId: 0,
projectSettings: {
aiKey: "",
llm: { engine: 'ollama', execPath: '', modelPath: '', port: 11434, kvCacheK: 'f16', kvCacheV: 'f16', loraPath: '', extraArgs: '', status: 'stopped' }
},
inputCallback: null, genMode: 'input',
currentChart: null,
view: { propsMode: 0, large: false, previewMode: 'dom' },
homeState: { pos: new THREE.Vector3(0, 40, 60), target: new THREE.Vector3(0,0,0) },
currentGroupId: null, // Workflow Scope
scopeStack: [] // { id: null, camPos, camTarget }
};
export const toast = {
show: (msg, type='info') => {
const c = document.getElementById('toast-container');
const t = document.createElement('div'); t.className = `toast ${type}`;
t.innerHTML = `<span style="font-size:16px">${type=='error'?'❌':type=='warn'?'⚠️':'ℹ️'}</span> ${msg}`;
c.appendChild(t); requestAnimationFrame(()=>t.classList.add('show'));
setTimeout(()=> { t.classList.remove('show'); setTimeout(()=>t.remove(), 300); }, 3000);
},
info: (m) => toast.show(m, 'info'), warn: (m) => toast.show(m, 'warn'), error: (m) => toast.show(m, 'error')
};ui.js
import { state, toast } from './state.js';
import { lib } from './library.js';
import { llm } from './llm.js';
export const ui = {
targetNode: null,
openDialog: (id) => { const d = document.getElementById(id); d.classList.remove('closing'); d.classList.add('active', 'opening'); },
closeDialog: (id) => { const d = document.getElementById(id); d.classList.remove('opening'); d.classList.add('closing'); setTimeout(() => { d.classList.remove('active', 'closing'); }, 400); },
showInputDialog: (msg, cb, showIcon=true) => {
document.getElementById('input-msg').innerText = msg; document.getElementById('input-val').value = "";
const iconDiv = document.getElementById('input-icon-wrapper');
if(iconDiv) iconDiv.style.display = showIcon ? 'block' : 'none';
state.inputCallback = cb; ui.openDialog('input-dialog'); setTimeout(() => document.getElementById('input-val').focus(), 400);
},
showLibSaveDialog: () => {
if(state.selectedNodes.length === 0) return toast.warn("Nothing selected");
ui.showInputDialog("Save to Library (Enter Name):", (name, icon) => { lib.saveFromSelection(name, icon); }, true);
},
openEditor: (node) => {
ui.targetNode = node;
document.getElementById('editor-title').innerText = `EDIT: ${node.name}`;
document.getElementById('editor-content').value = node.content;
document.getElementById('editor-icon-type').value = node.type;
document.getElementById('editor-icon-text').value = node.icon || "";
ui.openDialog('editor-dialog');
},
saveEditor: () => {
if(ui.targetNode) {
if(window.historyMgr) window.historyMgr.snapshot();
ui.targetNode.content = document.getElementById('editor-content').value;
const newType = document.getElementById('editor-icon-type').value;
const newIcon = document.getElementById('editor-icon-text').value;
let rebuild = false;
if (newType !== ui.targetNode.type) { ui.targetNode.type = newType; rebuild = true; }
if (newIcon !== ui.targetNode.icon) { ui.targetNode.icon = newIcon; rebuild = true; }
if(rebuild) ui.targetNode.buildVisuals();
}
ui.closeDialog('editor-dialog'); toast.info("Saved");
},
editorAction: async (act) => {
if(act === 'load-file') {
if(!state.projectHandle) {
document.getElementById('file-loader').onchange = async (e) => { if(e.target.files.length>0) { document.getElementById('editor-content').value = await e.target.files[0].text(); } };
document.getElementById('file-loader').click(); return;
}
try { const typeDir = await state.projectHandle.getDirectoryHandle('Misc', {create:true}); const [fh] = await window.showOpenFilePicker({ startIn: typeDir }); const file = await fh.getFile(); document.getElementById('editor-content').value = await file.text(); } catch(e) { }
}
if(act === 'save-file') {
if(!state.projectHandle) return alert("Save Project First");
const typeDir = await state.projectHandle.getDirectoryHandle('Misc', {create:true}); const fh = await typeDir.getFileHandle(`${ui.targetNode.name}.txt`, {create:true}); const w = await fh.createWritable(); await w.write(document.getElementById('editor-content').value); await w.close(); toast.info(`Saved`);
}
if(act === 'ai-input') { ui.openDialog('ai-manual-dialog'); }
if(act === 'ai-api') { if(!state.projectSettings.aiKey) return alert("Set API Key in Properties"); document.getElementById('editor-content').value += "\n\n// AI Gen (API): Executed."; }
},
openNodeProps: (node) => {
ui.targetNode = node; const list = document.getElementById('node-prop-list'); list.innerHTML = '';
if(!node.props) node.props = {};
Object.keys(node.props).forEach(k => ui.addNodePropRow(k, node.props[k]));
ui.openDialog('node-prop-dialog');
},
addNodePropRow: (key, val) => {
const list = document.getElementById('node-prop-list');
const div = document.createElement('div'); div.className = 'prop-row';
div.innerHTML = `<input type="text" class="prop-k" value="${key}" placeholder="Key"><input type="text" class="prop-v" value="${val}" placeholder="Value"><button class="btn btn-sm" style="color:red" onclick="this.parentElement.remove()">×</button>`;
list.appendChild(div);
},
addNodeProp: () => ui.addNodePropRow('newKey', 'value'),
saveNodeProps: () => {
if(window.historyMgr) window.historyMgr.snapshot();
if(!ui.targetNode) return; const rows = document.querySelectorAll('#node-prop-list .prop-row'); const newProps = {};
rows.forEach(r => { const k = r.querySelector('.prop-k').value.trim(); const v = r.querySelector('.prop-v').value; if(k) newProps[k] = v; });
ui.targetNode.props = newProps; ui.closeDialog('node-prop-dialog'); toast.info("Properties Saved");
if(state.view.propsMode > 0) ui.targetNode.buildPanel();
},
genPrompt: () => { const goal = document.getElementById('ai-goal-input').value; const type = ui.targetNode ? ui.targetNode.type : "Node"; document.getElementById('ai-prompt-output').value = `[TASK] Write content for a "${type}" node in Neuro Linker.\n[GOAL] ${goal}`; },
copyPrompt: (id) => { document.getElementById(id).select(); document.execCommand('copy'); toast.info("Copied"); },
pasteTo: async (id) => { try { const text = await navigator.clipboard.readText(); document.getElementById(id).value = text; } catch(e){alert("Use Ctrl+V");} },
applyAICode: () => { document.getElementById('editor-content').value = document.getElementById('ai-code-input').value; ui.closeDialog('ai-manual-dialog'); },
openProjectProps: () => {
ui.openDialog('prop-dialog');
document.getElementById('prop-ai-key').value = state.projectSettings.aiKey || "";
const cfg = state.projectSettings.llm;
document.getElementById('llm-engine').value = cfg.engine;
document.getElementById('llm-port').value = cfg.port;
document.getElementById('llm-exec-path').value = cfg.execPath;
document.getElementById('llm-model-path').value = cfg.modelPath;
document.getElementById('llm-kv-k').value = cfg.kvCacheK;
document.getElementById('llm-kv-v').value = cfg.kvCacheV;
document.getElementById('llm-lora-path').value = cfg.loraPath;
document.getElementById('llm-extra-args').value = cfg.extraArgs;
document.getElementById('llm-launch-cmd').value = llm.buildLaunchCommand();
ui.llmEngineChanged();
llm.updateStatusUI();
},
llmEngineChanged: () => {
const engine = document.getElementById('llm-engine').value;
document.getElementById('llamacpp-only-fields').style.display = engine === 'llamacpp' ? 'flex' : 'none';
document.getElementById('llm-port').placeholder = engine === 'ollama' ? '11434' : '8080';
document.getElementById('llm-model-label').innerText = engine === 'ollama' ? 'MODEL NAME (e.g. llama3, qwen2.5)' : 'MODEL PATH (.gguf)';
},
saveProjectProps: () => {
state.projectSettings.aiKey = document.getElementById('prop-ai-key').value;
const cfg = state.projectSettings.llm;
cfg.engine = document.getElementById('llm-engine').value;
cfg.port = parseInt(document.getElementById('llm-port').value) || (cfg.engine === 'ollama' ? 11434 : 8080);
cfg.execPath = document.getElementById('llm-exec-path').value;
cfg.modelPath = document.getElementById('llm-model-path').value;
cfg.kvCacheK = document.getElementById('llm-kv-k').value;
cfg.kvCacheV = document.getElementById('llm-kv-v').value;
cfg.loraPath = document.getElementById('llm-lora-path').value;
cfg.extraArgs = document.getElementById('llm-extra-args').value;
ui.closeDialog('prop-dialog');
toast.info("Properties Saved");
},
openPreview: (node) => { ui.targetNode = node; ui.updatePreview(node); ui.openDialog('preview-dialog'); },
setPreviewMode: (m, evt) => {
state.view.previewMode = m;
document.querySelectorAll('.p-tab').forEach(e => e.classList.remove('active'));
const target = (evt || window.event) ? (evt || window.event).target : document.querySelector(`.p-tab[data-mode="${m}"]`);
if(target) target.classList.add('active');
if(ui.targetNode) ui.updatePreview(ui.targetNode);
},
updatePreview: (node) => {
const data = node.resultData;
const container = document.getElementById('preview-html-container');
const cvs = document.getElementById('preview-chart-canvas');
if(state.currentChart) { state.currentChart.destroy(); state.currentChart=null; }
container.style.display = 'none'; cvs.style.display = 'none';
if(!data) { container.style.display='block'; container.innerHTML = "<div style='padding:20px; text-align:center;'>NO DATA</div>"; return; }
if(state.view.previewMode === 'chart') {
cvs.style.display = 'block';
let labels=[], values=[];
const arr = Array.isArray(data) ? data : [data];
arr.forEach((d,i) => {
if(typeof d === 'number') { labels.push(i); values.push(d); }
else if(typeof d === 'object') { const k = Object.keys(d); labels.push(d[k[0]]||i); values.push(d[k[1]]||0); }
});
if(window.Chart) state.currentChart = new Chart(cvs, { type: 'bar', data: { labels, datasets: [{ label: 'Data', data: values, backgroundColor:'#00ffcc' }] } });
}
else if(state.view.previewMode === 'dom') {
container.style.display = 'block';
if(Array.isArray(data) && data[0] instanceof HTMLElement) { container.innerHTML = ""; container.appendChild(data[0]); }
else if(typeof data[0] === 'string' && data[0].trim().startsWith('<')) { container.innerHTML = data[0]; }
else { container.innerHTML = "<div style='padding:20px; color:#aaa; text-align:center;'>Data is not HTML/DOM.</div>"; }
} else if(state.view.previewMode === 'table') {
container.style.display = 'block';
const arr = Array.isArray(data) ? data : [data];
if(arr.length === 0) { container.innerHTML = "<div style='padding:20px; text-align:center;'>NO DATA</div>"; }
else if(typeof arr[0] === 'object' && arr[0] !== null && !(arr[0] instanceof HTMLElement)) {
const cols = [...new Set(arr.flatMap(row => Object.keys(row || {})))];
let html = "<table><thead><tr>" + cols.map(c => `<th>${c}</th>`).join('') + "</tr></thead><tbody>";
arr.forEach(row => { html += "<tr>" + cols.map(c => `<td>${row && row[c] !== undefined ? String(row[c]) : ''}</td>`).join('') + "</tr>"; });
html += "</tbody></table>";
container.innerHTML = html;
} else {
let html = "<table><thead><tr><th>#</th><th>Value</th></tr></thead><tbody>";
arr.forEach((v,i) => { html += `<tr><td>${i}</td><td>${String(v)}</td></tr>`; });
html += "</tbody></table>";
container.innerHTML = html;
}
} else {
container.style.display = 'block'; container.innerHTML = `<pre style="padding:15px; color:#fff; font-family:monospace; height:100%; overflow:auto;">${JSON.stringify(data, null, 2)}</pre>`;
}
},
renderPreview: (mode) => ui.setPreviewMode(mode)
};
document.getElementById('input-ok-btn').addEventListener('click', () => {
const val = document.getElementById('input-val').value;
const icon = document.getElementById('input-icon-val') ? document.getElementById('input-icon-val').value : null;
if(val && state.inputCallback) state.inputCallback(val, icon);
ui.closeDialog('input-dialog');
});


コメント