未来っぽいグラフ作成ツール「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公開。

画像
3Dワークスペースの画面
画像
円グラフのプレビュー画面


画像
棒グラフのプレビュー画面
画像
折れ線グラフのプレビュー画面
画像
表のプレビュー画面



・ダウンロードされる方はこちら。↓

《ユーザーコード(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'); 
});

いいなと思ったら応援しよう!

ピックアップされています

便利なツール

  • 95本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望は、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
未来っぽいグラフ作成ツール「Neuro Linker」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1