---
import "../styles/global.css";
import Network3D from "../components/Network3D.astro";
import AgentChat from "../components/AgentChat.astro";
---
<!DOCTYPE html>
<html lang="fi">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Kipinä Agentic Playground</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
    <script type="module">
        import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
        window.mermaid = mermaid;
    </script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css">
    <link rel="stylesheet" href="/src/styles/global.css">
<style>
  /* Scoped styles can go here */
</style>
</head>
<body>
    <Network3D />
    <div class="container">
        <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
            <div>
                <h1 style="margin-bottom:0;" data-i18n="main_title"><span style="color:#ff6b00">Kipinä</span> <span>Agentic Playground</span></h1>
                <p class="sub" style="margin-bottom:0;"><span data-i18n="main_subtitle">AI-ohjelmistokehitystiimi</span> · <span id="hub-version" style="color:#58a6ff">-</span></p>
            </div>
            <div class="lang-selector">
                <button class="lang-btn active" onclick="setLanguage('fi')" data-lang="fi">FI</button>
                <button class="lang-btn" onclick="setLanguage('se')" data-lang="se">SE</button>
                <button class="lang-btn" onclick="setLanguage('en')" data-lang="en">EN</button>
            </div>
        </div>

        <!-- Päävälilehdet -->
        <div class="main-tabs" style="display: flex; justify-content: space-between; align-items: flex-end; padding-bottom: 10px; margin-bottom: 20px;">
            <div style="display: flex; gap: 8px;">
                <div class="main-tab active" onclick="switchMainTab('agents')" data-i18n="tab_agents" style="font-weight: bold; border-color: var(--accent-color);">Kipinä Agentic Playground</div>
                <div class="main-tab" onclick="switchMainTab('builder')" data-i18n="tab_builder">Agent Builder</div>
                <div class="main-tab" onclick="switchMainTab('gallery')" data-i18n="tab_gallery">Galleria</div>
                <div class="main-tab" onclick="switchMainTab('guide')" data-i18n="tab_guide">Opas</div>
                <div class="main-tab" onclick="switchMainTab('editor')" data-i18n="tab_editor">Editor</div>
            </div>
            <div style="display: flex; gap: 8px; opacity: 0.65; transform: scale(0.9); transform-origin: bottom right;">
                <div class="main-tab" onclick="switchMainTab('network')" data-i18n="tab_network">Laskentaverkko</div>
                <div class="main-tab" onclick="switchMainTab('codelab')" data-i18n="tab_codelab">Koodilaboratorio</div>
            </div>
        </div>

        <!-- PANEELI 1: Laskentaverkko -->
        <div id="panel-network" class="main-panel">

        <!-- Global Cluster Statistics (UI) -->
        <div class="dashboard-panel">
            <div class="stat-box" style="border-right: 1px solid #30363d;">
                <h3 id="stat-nodes">0</h3>
                <p data-i18n="stat_nodes_lbl">Aktiivisia Nodeja</p>
            </div>
            <div class="stat-box" style="border-right: 1px solid #30363d;">
                <h3 id="stat-tasks">0</h3>
                <p data-i18n="stat_tasks_lbl">Verkossa Suoritettua Tehtävää (Globaali)</p>
            </div>
            <div class="stat-box">
                <h3 id="stat-vram">0 GB</h3>
                <p data-i18n="stat_vram_lbl">Verkon yhteis-VRAM</p>
            </div>
        </div>

        <div id="device-info" class="device-info"></div>
        <div id="compat-banner" class="compat-banner"></div>

        <div id="initial-state">
            <!-- Tehtävävalitsin -->
            <div style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px;text-align:left">
                <div style="font-weight:600;font-size:15px;margin-bottom:12px" data-i18n="task_title">Valitse tehtävä</div>
                <div id="task-selector" style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
                    <label class="task-option selected" data-task="tokenize">
                        <input type="radio" name="task" value="tokenize" checked style="display:none">
                        <div class="task-title">Tokenisointivertailu</div>
                        <div class="task-desc">EN/FI-kieliparien tokenisointitehokkuuden vertailu Qwen2.5-tokenizeria käyttäen</div>
                        <div class="task-size">Lataus: ~7 MB (tokenizer)</div>
                        <span class="task-badge task-ready">Valmis</span>
                        <div class="task-info">
                            <strong>Miten tokenisaatio toimii?</strong>
                            Kielimallit eivät lue tekstiä kirjain kerrallaan. Sen sijaan teksti pilkotaan <em>tokeneiksi</em> — sanoja, tavuja tai sananosia, joista jokaisella on oma numerotunnisteensa mallin sanastossa.
                            <br><br>
                            Tokenizer on <em>BPE</em> (Byte Pair Encoding) -algoritmi: se yhdistää yleisimpiä merkkipareja isommiksi yksiköiksi. Englannissa "the" on yksi token, mutta suomessa "kirjoittamisen" voi olla 3-4 tokenia, koska tokenizer on koulutettu pääosin englanninkielisellä datalla.
                            <br><br>
                            <strong>Miksi tällä on väliä?</strong> Enemmän tokeneita = kalliimpaa ja hitaampaa. Sama lause suomeksi voi maksaa 50-100% enemmän tokeneita kuin englanniksi.
                        </div>
                    </label>
                    <label class="task-option" data-task="smollm-135m">
                        <input type="radio" name="task" value="smollm-135m" style="display:none">
                        <div class="task-title">SmolLM 135M</div>
                        <div class="task-desc">Kevyt kielimalli tekstigeneraatioon — sopii kaikille laitteille (CPU)</div>
                        <div class="task-size">Lataus: ~269 MB (safetensors) + 2 MB (tokenizer)</div>
                        <span class="task-badge task-ready">Valmis</span>
                        <div class="task-info">
                            <strong>SmolLM 135M</strong> (HuggingFace)
                            <br>Llama-arkkitehtuuri: 30 kerrosta, 576-dim embeddings, 9 attention-headiä.
                            <br><br>
                            135 miljoonaa parametria — noin 1000x pienempi kuin GPT-4. Silti kykenee yksinkertaiseen tekstigeneraatioon. Tämä malli mahtuu mihin tahansa laitteeseen ja pyörii kokonaan selaimessasi WebAssemblylla.
                            <br><br>
                            <strong>Miten inferenssi toimii?</strong> Malli ennustaa aina seuraavan tokenin edellisten perusteella (<em>autoregressive generation</em>). Jokainen token vaatii yhden "forward pass" -laskennan kaikkien kerrosten läpi. 135M-mallilla tämä kestää ~0.8s selaimessa ja ~90ms natiivisti.
                        </div>
                    </label>
                    <label class="task-option" data-task="qwen-05b">
                        <input type="radio" name="task" value="qwen-05b" style="display:none">
                        <div class="task-title">Qwen2.5 0.5B</div>
                        <div class="task-desc">Tehokkaampi kielimalli — vaatii vähintään 2 GB muistia (CPU)</div>
                        <div class="task-size">Lataus: ~990 MB (safetensors) + 7 MB (tokenizer)</div>
                        <span class="task-badge task-ready">Valmis</span>
                        <div class="task-info">
                            <strong>Qwen2.5 0.5B</strong> (Alibaba Cloud)
                            <br>24 kerrosta, 896-dim, 14 attention-headiä, 2 KV-headiä (GQA).
                            <br><br>
                            490 miljoonaa parametria ja 151 936 tokenin sanasto — 3x suurempi kuin SmolLM ja huomattavasti koherentimpi. <em>Grouped Query Attention</em> (GQA) vähentää muistinkäyttöä jakamalla key/value-headit 14:n query-headin kesken.
                            <br><br>
                            <strong>Miksi tämä on hitaampi?</strong> Jokaisessa kerroksessa lasketaan attention-matriisi (Q*K^T), joka skaalautuu O(n^2) sekvenssipituuden mukaan. 24 kerrosta x 14 headiä = 336 attention-laskentaa per token. Selaimessa CPU/Wasm: ~2.5s/token, natiivisti: ~90ms/token.
                        </div>
                    </label>
                    <label class="task-option" data-task="phi3-mini">
                        <input type="radio" name="task" value="phi3-mini" style="display:none">
                        <div class="task-title">Phi-3 Mini 3.8B</div>
                        <div class="task-desc">Iso kielimalli — vaatii native-noden (Docker + GPU)</div>
                        <div class="task-size">~7.6 GB — liian suuri selaimelle</div>
                        <span class="task-badge task-soon">Vain native</span>
                        <div class="task-info">
                            <strong>Phi-3 Mini 3.8B</strong> (Microsoft)
                            <br>32 kerrosta, 3072-dim, 32 attention-headiä.
                            <br><br>
                            3.8 miljardia parametria — luokassaan yksi tehokkaimmista. Microsoftin "small language model" -tutkimuksen tulos: laadukas koulutusdata kompensoi pientä mallikokoa. Pärjää monissa tehtävissä 7B-13B mallien tasolla.
                            <br><br>
                            <strong>Miksi ei pyöri selaimessa?</strong> F32-painot vaativat ~15 GB muistia. Selainten Wasm-muistiraja on tyypillisesti 4 GB. GPU-kiihdytyksellä (CUDA/ROCm) malli mahtuu 24 GB VRAM-näytönohjaimeen ja generoi ~50-100 tok/s.
                        </div>
                    </label>
                </div>
            </div>

            <button id="start-btn" class="btn" data-i18n="btn_join">Liity laskentaverkkoon</button>
        </div>

        <div id="active-state" class="hidden">
            <div id="download-bar" class="download-bar">
                <div style="display:flex;justify-content:space-between;font-size:13px">
                    <span id="dl-label">Ladataan mallia...</span>
                    <span id="dl-pct" style="color:var(--accent-color);font-weight:600">0%</span>
                </div>
                <div class="bar-track"><div id="dl-fill" class="bar-fill" style="width:0%"></div></div>
                <div id="dl-detail" style="font-size:11px;color:#8b949e;margin-top:4px">0 / 0 MB</div>
            </div>
            <!-- Resurssipaneeli -->
            <div style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px">
                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
                    <span style="font-weight:600;font-size:15px" data-i18n="resource_mgmt">Resurssien hallinta</span>
                    <span id="node-status" style="font-size:12px;color:#8b949e">Ei yhdistetty</span>
                </div>

                <!-- Kuormitussäädin -->
                <div style="margin-bottom:14px">
                    <div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px">
                        <span data-i18n="power_limiter">Laskentatehon rajoitin</span>
                        <strong id="load-display" style="color:var(--accent-color)">50%</strong>
                    </div>
                    <input type="range" id="gpu-load" min="0" max="100" value="50" style="width:100%;accent-color:var(--accent-color)">
                    <div style="display:flex;justify-content:space-between;font-size:11px;color:#8b949e;margin-top:2px">
                        <span>Pysäytetty</span><span>Säästö</span><span>Tasapaino</span><span>Suorituskyky</span><span>Maksimi</span>
                    </div>
                </div>

                <!-- Automaattiset tehtävät -->
                <div style="margin-bottom:14px;display:flex;align-items:center;gap:10px">
                    <label style="font-size:13px;display:flex;align-items:center;gap:6px;cursor:pointer">
                        <input type="checkbox" id="auto-tasks-toggle" checked style="accent-color:var(--accent-color)">
                        <span data-i18n="auto_tasks">Vastaanota automaattisia tehtäviä hubilta</span>
                    </label>
                    <span style="font-size:11px;color:#8b949e">(10s välein)</span>
                </div>

                <!-- Reaaliaikaiset metriikat -->
                <div id="metrics-grid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:12px">
                    <div class="metric-card">
                        <div class="metric-val" id="m-tasks">0</div>
                        <div class="metric-label" data-i18n="metric_tasks">Tehtäviä</div>
                    </div>
                    <div class="metric-card">
                        <div class="metric-val" id="m-avg-time">-</div>
                        <div class="metric-label" data-i18n="metric_avg">Ka. aika</div>
                    </div>
                    <div class="metric-card">
                        <div class="metric-val" id="m-tokens">0</div>
                        <div class="metric-label" data-i18n="metric_tokens">Tokeneita</div>
                    </div>
                    <div class="metric-card">
                        <div class="metric-val" id="m-uptime">0s</div>
                        <div class="metric-label" data-i18n="metric_uptime">Käynnissä</div>
                    </div>
                </div>
            </div>

            <div id="user-input-box" class="hidden" style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:12px;margin-bottom:12px">
                <div style="font-size:13px;color:#8b949e;margin-bottom:8px" data-i18n="try_own_text">Kokeile omaa tekstiä:</div>
                <div style="display:flex;gap:8px">
                    <input type="text" id="user-text" placeholder="Kirjoita teksti tokenisoitavaksi tai promptiksi..." style="flex:1;background:var(--panel-bg);border:1px solid var(--border-color);border-radius:4px;padding:8px 12px;color:var(--text-color);font-size:14px;outline:none">
                    <button id="send-btn" style="background:#238636;color:#fff;border:1px solid rgba(240,246,252,0.1);border-radius:4px;padding:8px 16px;font-size:14px;cursor:pointer;white-space:nowrap" data-i18n="btn_tokenize">Tokenisoi</button>
                </div>
            </div>

            <div id="chat-box" class="chat-box hidden">
                <div style="color: #8b949e; text-align: center; margin-top: 80px;">Odotetaan Generointitehtäviä Hubilta...</div>
            </div>

            <div id="log-box" class="status-box">
                <p>> Odotetaan uusia tehtäviä Hubulta...</p>
            </div>
        </div>
        </div><!-- /panel-network -->

        <!-- PANEELI 2: Koodilaboratorio -->
        <div id="panel-codelab" class="main-panel">
            <div style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px">
                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
                    <span style="font-weight:600;font-size:15px">Qwen2.5-Coder-0.5B-Instruct</span>
                    <span id="coder-status" style="font-size:12px;color:#8b949e">Ei yhdistetty</span>
                </div>
                <p style="font-size:12px;color:#8b949e;line-height:1.5;margin-bottom:12px">
                    Code-specialized language model trained on 5.5T tokens of source code.
                    Generates Python code in your browser via WebAssembly. Choose model size and write your own prompt.
                </p>
                <!-- Model size selector -->
                <div style="display:flex;gap:8px;margin-bottom:10px">
                    <label style="flex:1;display:flex;align-items:center;gap:6px;background:var(--panel-bg);border:2px solid var(--accent-color);border-radius:4px;padding:8px 12px;cursor:pointer;font-size:13px" id="coder-opt-05b">
                        <input type="radio" name="coder-size" value="05b" checked style="accent-color:var(--accent-color)">
                        <div>
                            <strong style="color:var(--text-color)">0.5B</strong>
                            <span style="color:#8b949e"> — 990 MB, ~0.4 tok/s</span>
                        </div>
                    </label>
                    <label style="flex:1;display:flex;align-items:center;gap:6px;background:var(--panel-bg);border:2px solid var(--border-color);border-radius:4px;padding:8px 12px;cursor:pointer;font-size:13px" id="coder-opt-3b">
                        <input type="radio" name="coder-size" value="3b" style="accent-color:var(--accent-color)">
                        <div>
                            <strong style="color:var(--text-color)">3B</strong>
                            <span style="color:#8b949e"> — 6.2 GB, better quality, slower</span>
                        </div>
                    </label>
                </div>
                <div style="display:flex;gap:8px;align-items:start">
                    <div style="flex:1">
                        <div style="display:flex;gap:8px;margin-bottom:4px">
                            <input type="text" id="code-input" placeholder='e.g. Write a Python function that checks if a number is prime' style="flex:1;background:var(--panel-bg);border:1px solid var(--border-color);border-radius:4px;padding:8px 12px;color:var(--text-color);font-size:14px;outline:none;display:block" >
                            <textarea id="code-input-json" placeholder='{"prompt":"Write a fibonacci function","system":"You are a Python expert","max_tokens":128}' style="flex:1;background:var(--panel-bg);border:1px solid var(--border-color);border-radius:4px;padding:8px 12px;color:var(--text-color);font-size:13px;font-family:Courier New,monospace;outline:none;resize:vertical;min-height:60px;display:none"></textarea>
                            <button id="code-send-btn" style="background:#238636;color:#fff;border:1px solid rgba(240,246,252,0.1);border-radius:4px;padding:8px 16px;font-size:14px;cursor:pointer;align-self:stretch">Generate</button>
                        </div>
                        <div style="display:flex;justify-content:space-between;align-items:center">
                            <label style="font-size:11px;color:#8b949e;cursor:pointer;display:flex;align-items:center;gap:4px">
                                <input type="checkbox" id="json-mode-toggle" style="accent-color:var(--accent-color)"> JSON mode
                            </label>
                            <details id="json-help" style="font-size:11px;color:#8b949e;display:none">
                                <summary style="cursor:pointer;color:var(--accent-color)">JSON syntax</summary>
                                <div style="background:#010409;border:1px solid var(--border-color);border-radius:4px;padding:10px;margin-top:6px;font-family:Courier New,monospace;font-size:12px;line-height:1.6;color:var(--text-color)">
&#123;<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"prompt"</span>: <span style="color:#a5d6ff">"Write a bubble sort"</span>,<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"system"</span>: <span style="color:#a5d6ff">"You are a Python expert. Write only code."</span>,<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"max_tokens"</span>: <span style="color:#79c0ff">128</span>,<br>
&nbsp;&nbsp;<span style="color:#79c0ff">"language"</span>: <span style="color:#a5d6ff">"python"</span><br>
&#125;
                                <div style="margin-top:8px;color:#8b949e;font-family:sans-serif">
                                    <strong style="color:var(--text-color)">Fields:</strong><br>
                                    <code>prompt</code> (required) — the coding task<br>
                                    <code>system</code> — system prompt override<br>
                                    <code>max_tokens</code> — max tokens to generate (default: 128)<br>
                                    <code>language</code> — hint for syntax highlighting
                                </div>
                                </div>
                            </details>
                        </div>
                    </div>
                </div>
                <div id="code-loading" style="display:none;margin-top:8px;font-size:12px;color:#d29922">Starting Coder model...</div>
            </div>

            <!-- Koodilaboratorion metriikat -->
            <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px">
                <div class="metric-card">
                    <div class="metric-val" id="code-m-tasks">0</div>
                    <div class="metric-label">Tehtäviä</div>
                </div>
                <div class="metric-card">
                    <div class="metric-val" id="code-m-tokens">0</div>
                    <div class="metric-label">Tokeneita</div>
                </div>
                <div class="metric-card">
                    <div class="metric-val" id="code-m-speed">-</div>
                    <div class="metric-label">tok/s</div>
                </div>
            </div>

            <!-- Latausvaiheet -->
            <div id="code-pipeline" style="background:#0d1117;border:1px solid var(--border-color);border-radius:6px;padding:16px;margin-bottom:16px;display:none">
                <div style="font-size:13px;font-weight:600;margin-bottom:12px">Valmistautuminen</div>
                <div id="code-steps" style="display:flex;flex-direction:column;gap:8px">
                    <div class="code-step" id="step-wasm">
                        <span class="step-icon">&#9711;</span>
                        <span>WebAssembly-ytimen lataus</span>
                    </div>
                    <div class="code-step" id="step-tokenizer">
                        <span class="step-icon">&#9711;</span>
                        <span>Tokenizer (7 MB)</span>
                    </div>
                    <div class="code-step" id="step-model">
                        <span class="step-icon">&#9711;</span>
                        <span>Qwen2.5-Coder-0.5B painot (990 MB)</span>
                        <span id="step-model-pct" style="color:var(--accent-color);margin-left:auto;font-size:12px"></span>
                    </div>
                    <div class="code-step" id="step-build">
                        <span class="step-icon">&#9711;</span>
                        <span>Mallin rakentaminen muistiin</span>
                    </div>
                    <div class="code-step" id="step-ready">
                        <span class="step-icon">&#9711;</span>
                        <span>Valmis generoimaan</span>
                    </div>
                </div>
            </div>

            <!-- Kooditulokset -->
            <div id="code-results" style="display:flex;flex-direction:column;gap:12px">
                <div data-placeholder style="color:#8b949e;text-align:center;padding:40px">Kirjoita ohjelmointitehtävä ja paina Koodaa</div>
            </div>
        </div><!-- /panel-codelab -->

        <!-- PANEELI 3: Agents & CLI -->
        <div id="panel-agents" class="main-panel active" style="position: relative; border-radius: 6px;">
            <div style="position: absolute; top:0; left:0; width:100%; height:100%; background: url('/avatars/forge_hero.svg') no-repeat center center; background-size: cover; opacity: 0.15; z-index: 0; pointer-events: none; border-radius: 6px;"></div>
            <div style="background:rgba(13, 17, 23, 0.7); backdrop-filter: blur(4px); border:1px solid var(--border-color); border-radius:6px; padding:16px; margin-bottom:16px; position: relative; z-index: 1;">
                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
                    <div style="display:flex;align-items:center;gap:16px;">
                        <span style="font-weight:600;font-size:15px;color:var(--text-color)"><span style="color:#ff6b00">Kipinä</span> Agent Workspace</span>
                        <button id="btn-toggle-all" onclick="toggleAllAgents()" style="background:rgba(33, 38, 45, 0.8);border:1px solid var(--border-color);color:#c9d1d9;font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer;">Valitse kaikki</button>
                        <button id="btn-toggle-layout" onclick="toggleOrgLayout()" style="background:rgba(33, 38, 45, 0.8);border:1px solid var(--border-color);color:#c9d1d9;font-size:11px;padding:4px 12px;border-radius:4px;cursor:pointer;">⇅ Pysty</button>
                    </div>
                    <span id="agent-status" style="font-size:12px;color:var(--success-color)">Monitoring Active</span>
                </div>
                
                <div class="workspace-split" style="display:flex; gap:20px; align-items:flex-start; flex-wrap: wrap;">
                    <!-- LEFT COLUMN: Org chart & Prompt Editor -->
                    <div style="flex:1; min-width:300px; overflow-x:auto;">
                        <div class="org-chart">
                            <div class="org-level">
                                <div class="avatar-card" id="avatar-client" data-agent="client" onclick="selectAgent('client', event)">
                                    <img src="/avatars/kettu_notext.png" alt="Asiakas">
                                    <div class="avatar-name">Asiakas</div>
                                </div>
                            </div>
                            <div class="org-connector"></div>
                            <div class="org-level">
                                <div class="avatar-card" id="avatar-kpn" data-agent="manager" onclick="selectAgent('manager', event)">
                                    <img src="/avatars/karhunpentu.png" alt="Manageri">
                                    <div class="avatar-name">Manageri</div>
                                </div>
                            </div>
                            <div class="org-connector"></div>
                            <div class="org-level">
                                <div class="avatar-card" id="avatar-coder" data-agent="coder" onclick="selectAgent('coder', event)">
                                    <img src="/avatars/kipina_notext.png" alt="Koodari">
                                    <div class="avatar-name">Koodari</div>
                                </div>
                                <div class="avatar-card" id="avatar-data" data-agent="data" onclick="selectAgent('data', event)">
                                    <img src="/avatars/pesukarhu_notext.png" alt="Data">
                                    <div class="avatar-name">Data</div>
                                </div>
                                <div class="avatar-card" id="avatar-qa" data-agent="qa" onclick="selectAgent('qa', event)">
                                    <img src="/avatars/susi_notext.png" alt="QA">
                                    <div class="avatar-name">QA</div>
                                </div>
                                <div class="avatar-card" id="avatar-tester" data-agent="tester" onclick="selectAgent('tester', event)">
                                    <img src="/avatars/laiskiainen_notext.png" alt="DevOps">
                                    <div class="avatar-name">DevOps</div>
                                </div>
                                <div class="avatar-card" id="avatar-observer" data-agent="observer" onclick="selectAgent('observer', event)">
                                    <img src="/avatars/aikuinen_susi.png" alt="Tarkkailija">
                                    <div class="avatar-name">Tarkkailija</div>
                                </div>
                            </div>
                        </div>

                        <!-- Prompt Editor -->
                        <div class="agent-prompt-editor" id="agent-prompt-editor" style="margin-top:10px;">
                            <div class="agent-prompt-label">
                                <strong id="agent-prompt-name">—</strong>
                                <span id="agent-prompt-saved" style="color:var(--success-color);opacity:0;transition:opacity 0.3s">Tallennettu</span>
                            </div>
                            <textarea id="agent-prompt-text" placeholder="Kirjoita system prompt..."></textarea>
                            <div id="shared-prompt-section" style="display:none;margin-top:8px;font-size:12px;color:#8b949e">
                                Yhteinen konteksti liitetään jokaisen valitun agentin oman promptin alkuun.
                            </div>
                            <div id="agent-last-prompt" style="display:none;margin-top:8px">
                                <button id="agent-open-modal-btn" style="background:#161b22;border:1px solid var(--accent-color);color:var(--accent-color);font-size:12px;padding:4px 12px;border-radius:4px;cursor:pointer;width:100%">📋 Näytä viimeisin prompti</button>
                            </div>
                        </div>
                    </div>

                    <!-- RIGHT COLUMN: Puhuvat Päät Gallery (piilotettu, koodi säilytetty) -->
                    <div style="flex-basis:150px; flex-shrink:0; display:none;">
                        <div style="background:rgba(1, 4, 9, 0.6); border:1px solid var(--border-color); border-radius:6px; padding:12px; height: 100%;">
                            <div id="all-heads-gallery" style="display:flex; flex-wrap:wrap; gap:10px; justify-content:center;">
                                <div class="gallery-head-wrap" id="wrap-client"><img src="/avatars/kettu_notext.png" id="gallery-client" class="gallery-head" alt="Asiakas"></div>
                                <div class="gallery-head-wrap" id="wrap-observer"><img src="/avatars/aikuinen_susi.png" id="gallery-observer" class="gallery-head" alt="Tarkkailija"></div>
                                <div class="gallery-head-wrap" id="wrap-manager"><img src="/avatars/karhunpentu.png" id="gallery-manager" class="gallery-head" alt="Manageri"></div>
                                <div class="gallery-head-wrap" id="wrap-coder"><img src="/avatars/kipina_notext.png" id="gallery-coder" class="gallery-head" alt="Koodari"></div>
                                <div class="gallery-head-wrap" id="wrap-data"><img src="/avatars/pesukarhu_notext.png" id="gallery-data" class="gallery-head" alt="Data"></div>
                                <div class="gallery-head-wrap" id="wrap-qa"><img src="/avatars/susi_notext.png" id="gallery-qa" class="gallery-head" alt="QA"></div>
                                <div class="gallery-head-wrap" id="wrap-tester"><img src="/avatars/laiskiainen_notext.png" id="gallery-tester" class="gallery-head" alt="DevOps"></div>
                            </div>
                            <div style="text-align: center; margin-top: 16px;">
                                <button class="btn" id="simu-btn" onclick="toggleSimulation()" style="font-size: 11px; padding: 4px 10px; background: #0d1a2d; border-color: #58a6ff;">Käynnistä simulaatio</button>
                            </div>
                        </div>
                    </div>
                </div>

                <div id="agent-hub-status" style="margin-top:20px;padding:8px 14px;background:#0d1117;border:1px solid var(--border-color);border-radius:6px 6px 0 0;font-family:'Courier New',monospace;font-size:13px;display:flex;align-items:center;gap:12px;cursor:help" title="WebSocket-yhteys Kipinä Hubiin — hallitsee tehtävien jakelun ja solmujen koordinoinnin">
                    <span style="display:flex;align-items:center;gap:6px" title="Hub-yhteyden tila">
                        <span id="agent-hub-dot" style="width:8px;height:8px;border-radius:50%;background:#d29922;display:inline-block"></span>
                        <span style="color:#8b949e">Hub:</span>
                        <span id="agent-hub-label" style="color:#d29922">Yhdistetään...</span>
                    </span>
                    <span style="color:#30363d">│</span>
                    <span style="display:flex;align-items:center;gap:6px" id="agent-compute-wrap">
                        <span id="agent-compute-dot" style="width:8px;height:8px;border-radius:50%;background:#30363d;display:inline-block"></span>
                        <span style="color:#8b949e">Laskenta:</span>
                        <span id="agent-compute-label" style="color:#8b949e">—</span>
                        <button id="agent-compute-btn" style="margin-left:4px;padding:2px 10px;border-radius:4px;border:1px solid #30363d;background:#161b22;color:#58a6ff;font-size:12px;font-family:inherit;cursor:pointer" title="Käynnistä kielimalli omalla koneellasi laskentaa varten">Alusta laskentasolmu</button>
                    </span>
                </div>
                <div id="pipeline-steps" style="display:none;background:#0d1117;border:1px solid var(--border-color);border-top:none;padding:8px 14px;font-family:'Courier New',monospace;font-size:12px;line-height:1.8;flex-wrap:wrap;display:flex;gap:2px"></div>
                <div class="terminal-panel" id="agent-terminal" style="margin-top:0;border-top:none;border-radius:0">
                </div>
                <div style="position:relative;display:flex;align-items:center;background:#010409;border:1px solid var(--border-color);border-top:none;border-radius:0 0 6px 6px;padding:8px 12px;font-family:'Courier New',monospace;font-size:14px">
                    <span style="color:#d29922;margin-right:8px;flex-shrink:0">$</span>
                    <input id="term-input" type="text" placeholder="kpn run coder &quot;kirjoita hello world&quot;" spellcheck="false" autocomplete="off"
                        style="flex:1;background:transparent;border:none;outline:none;color:var(--success-color);font-family:inherit;font-size:inherit">
                    <div id="term-dropdown" style="display:none;position:absolute;bottom:100%;left:30px;background:#161b22;border:1px solid #30363d;border-radius:6px;max-height:200px;overflow-y:auto;font-size:13px;min-width:200px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,0.4)"></div>
                </div>
            </div>
        </div><!-- /panel-agents -->

        <!-- PANEELI 4: Opas -->
        <div id="panel-builder" class="main-panel">
            <div style="max-width:800px;margin:0 auto;padding:20px">
                <h2 style="color:#f0f6fc;margin-bottom:16px">Agent Builder</h2>
                <p style="color:#8b949e;margin-bottom:20px">Luo ja muokkaa agentteja. Agentit tallentuvat palvelimelle ja ovat kaikkien käytettävissä.</p>

                <!-- Agenttilista -->
                <div id="builder-agent-list" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-bottom:24px"></div>

                <!-- Lomake -->
                <div id="builder-form" style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;display:none">
                    <div style="display:grid;grid-template-columns:auto 1fr;gap:16px;align-items:start">
                        <!-- Avatar-valitsin -->
                        <div>
                            <img id="builder-avatar-preview" src="/avatars/kipina_notext.png" style="width:80px;height:80px;border-radius:16px;border:2px solid #30363d;cursor:pointer;object-fit:cover" onclick="document.getElementById('builder-avatar-select').style.display=document.getElementById('builder-avatar-select').style.display==='none'?'flex':'none'">
                            <div id="builder-avatar-select" style="display:none;flex-wrap:wrap;gap:6px;margin-top:8px;max-width:200px"></div>
                        </div>
                        <!-- Kentät -->
                        <div style="display:flex;flex-direction:column;gap:10px">
                            <div style="display:flex;gap:10px">
                                <input id="builder-id" placeholder="tunniste (esim. tofuist)" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px">
                                <input id="builder-name" placeholder="Näyttönimi" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px">
                            </div>
                            <div style="display:flex;gap:10px">
                                <select id="builder-role" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px">
                                    <option value="coder">Koodari</option>
                                    <option value="qa">QA / Testaus</option>
                                    <option value="devops">DevOps</option>
                                    <option value="devsecops">DevSecOps</option>
                                    <option value="architect">Arkkitehti</option>
                                    <option value="iac">IaC / Infra</option>
                                    <option value="data">Data</option>
                                    <option value="manager">Manageri</option>
                                    <option value="writer">Kirjoittaja</option>
                                    <option value="custom">Vapaa</option>
                                </select>
                                <div style="flex:1;position:relative">
                                    <input id="builder-model" placeholder="Malli (esim. qwen2.5-coder:7b)" value="qwen2.5-coder:7b" style="width:100%;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px" class="builder-tip" data-tip="Ollama-mallin eksakti nimi.
Esimerkkejä:
• qwen2.5-coder:7b (oletus, hyvä koodaamiseen)
• qwen2.5-coder:1.5b (kevyempi, nopeampi)
• deepseek-r1 (reasoning, hidas mutta laadukas)
• llama3.2:3b (yleiskäyttöinen)

Malli pitää olla ladattuna Ollamaan (ollama pull).">
                                </div>
                            </div>
                            <div style="display:flex;gap:10px">
                                <input id="builder-color" type="color" value="#3fb950" style="width:40px;height:32px;border:1px solid #30363d;border-radius:4px;background:#0d1117;cursor:pointer">
                                <input id="builder-docs" placeholder="Docs URL (valinnainen, esim. /docs/tofu-cheatsheet.md)" style="flex:1;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:6px 10px;color:#c9d1d9;font-size:13px" class="builder-tip" data-tip="Referenssidokumentti joka liitetään agentin promptiin automaattisesti.
Esim. /docs/tofu-cheatsheet.md

Dokumentti ladataan kerran ja cachetetaan.
Max ~10 KB — pidempi teksti vie konteksti-ikkunaa.
Tiedoston tulee olla .md tai .txt palvelimella.">
                            </div>
                            <div>
                                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
                                    <label style="color:#8b949e;font-size:12px;font-weight:600">System Prompt</label>
                                    <span class="builder-tip" data-tip="Prompti on agentin 'persoonallisuus' — se ohjaa miten malli vastaa.

Kirjoita englanniksi (tehokkaampi tokenisointi, parempi ymmärrys).

Hyvä prompti:
• Määrittele rooli: 'You are a senior Python developer'
• Ole konkreettinen: 'Always use type hints and docstrings'
• Mainitse kirjastot: 'Use FastAPI, SQLAlchemy, Pydantic'
• Rajoita: 'Output ONLY code, no explanations'

Huono prompti:
• Liian yleinen: 'Olet koodari'
• Suomeksi (kuluttaa enemmän tokeneita)
• Liian pitkä (vie tilaa vastaukselta)

Prompti lähetetään jokaisessa kutsussa ennen käyttäjän viestiä.
Max ~100 sanaa — pidempi vie konteksti-ikkunaa." style="color:#484f58;cursor:help;font-size:13px">?</span>
                                </div>
                                <textarea id="builder-prompt" rows="4" placeholder="You are a senior developer specializing in..." style="background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:8px 10px;color:#c9d1d9;font-size:13px;resize:vertical;font-family:inherit;width:100%"></textarea>
                            </div>
                            <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
                                <label style="color:#8b949e;font-size:12px" class="builder-tip" data-tip="Temperature: kuinka 'luova' malli on.
0.0 = aina sama vastaus (deterministic)
0.3–0.5 = tarkka koodi, boilerplate
0.7 = oletus, hyvä kompromissi
1.0+ = luova, vaihteleva, riskialtis">Temp: <span style="color:#484f58;cursor:help">?</span></label>
                                <input id="builder-temp" type="number" value="0.7" min="0" max="2" step="0.1" style="width:60px;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:4px 8px;color:#c9d1d9;font-size:12px">
                                <label style="color:#8b949e;font-size:12px" class="builder-tip" data-tip="Top-k: montako todennäköisintä tokenia on valittavissa.
1 = greedy (aina paras)
40 = oletus, järkevät vaihtoehdot
100+ = laajempi valikoima, enemmän vaihtelua">Top-k: <span style="color:#484f58;cursor:help">?</span></label>
                                <input id="builder-topk" type="number" value="40" min="1" max="200" style="width:60px;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:4px 8px;color:#c9d1d9;font-size:12px">
                                <label style="color:#8b949e;font-size:12px" class="builder-tip" data-tip="Max tokens: vastauksen enimmäispituus.
128 = lyhyt vastaus (review, suunnitelma)
512 = oletus, riittää useimpiin tiedostoihin
1024+ = pitkä koodi, mutta hitaampi">Max tok: <span style="color:#484f58;cursor:help">?</span></label>
                                <input id="builder-maxtokens" type="number" value="512" min="32" max="4096" step="32" style="width:70px;background:#0d1117;border:1px solid #30363d;border-radius:4px;padding:4px 8px;color:#c9d1d9;font-size:12px">
                            </div>
                            <div style="display:flex;gap:8px;justify-content:flex-end">
                                <button onclick="builderSave()" style="background:#238636;border:1px solid #2ea043;color:#fff;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px">Tallenna</button>
                                <button onclick="builderDelete()" id="builder-delete-btn" style="background:#21262d;border:1px solid #f85149;color:#f85149;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px;display:none">Poista</button>
                                <button onclick="builderCancel()" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 16px;border-radius:4px;cursor:pointer;font-size:13px">Peruuta</button>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- Napit -->
                <div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap">
                    <button onclick="builderNew()" id="builder-new-btn" style="background:#238636;border:1px solid #2ea043;color:#fff;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:14px">+ Uusi agentti</button>
                    <button onclick="builderExportCrewAI()" style="background:#0d1117;border:1px solid #58a6ff;color:#58a6ff;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:14px" class="builder-tip" data-tip="Generoi koko agenttitiimistä CrewAI Python -projektin
ZIP-paketti sisältäen:
• crew.py — agentit, tehtävät ja pipeline
• Dockerfile + docker-compose.yml
• pyproject.toml riippuvuudet
• .env mallipohja">Export CrewAI</button>
                </div>
            </div>
        </div>

        <div id="panel-gallery" class="main-panel">
            <div style="max-width:900px;margin:0 auto;padding:20px">
                <h2 style="color:#f0f6fc;margin-bottom:8px">Hahmogalleria</h2>
                <p style="color:#8b949e;margin-bottom:20px">Valmiit avatarit agenteille. Klikkaa kopioidaksesi polun.</p>
                <div id="gallery-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:16px"></div>
            </div>
        </div>

        <div id="panel-guide" class="main-panel">
            <div id="guide-content" style="max-width:800px;margin:0 auto;padding:20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:var(--text-color);line-height:1.7;font-size:15px">
                <p style="color:#8b949e">Ladataan opasta...</p>
            </div>
        </div>

        <!-- PANEELI: Editor -->
        <div id="panel-editor" class="main-panel">
            <div style="display:flex;height:calc(100vh - 160px);gap:0;border:1px solid var(--border-color);border-radius:6px;overflow:hidden">
                <!-- Tiedostopuu -->
                <div id="editor-filetree" style="width:200px;min-width:150px;background:#0d1117;border-right:1px solid var(--border-color);overflow-y:auto;font-family:'Courier New',monospace;font-size:13px">
                    <div style="padding:10px 12px;color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;border-bottom:1px solid var(--border-color)">Tiedostot</div>
                    <div id="editor-file-list" style="padding:4px 0">
                        <div style="padding:8px 16px;color:#8b949e;font-size:12px">Ei tiedostoja.<br><br>Generoi projekti:<br><code style="color:#58a6ff">kpn project "..."</code><br>ja klikkaa "Avaa editorissa"</div>
                    </div>
                </div>
                <!-- Monaco Editor -->
                <div style="flex:1;display:flex;flex-direction:column">
                    <div id="editor-tabs" style="display:flex;background:#0d1117;border-bottom:1px solid var(--border-color);min-height:35px;align-items:flex-end;padding:0 8px;gap:2px;overflow-x:auto"></div>
                    <div id="monaco-container" style="flex:1"></div>
                </div>
            </div>
        </div>

    </div>

    <script type="module" is:inline>
        import init, { start_agent_node, set_gpu_load, set_auto_tasks } from '/pkg/node.js';

        // HTML-escape kaikelle käyttäjä-/backendidatalle joka menee innerHTML:ään
        function esc(str) {
            if (!str) return '';
            return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
        }
        // Poistaa system-promptin näkyvästä prompt-tekstistä (agents-pipeline lisää sen alkuun)
        function stripSystemPrompt(prompt) {
            if (!prompt) return '';
            // Poistetaan kaikki ennen viimeistä kappaletta (system + agent promptit erotettu \n\n:llä)
            const parts = prompt.split('\n\n');
            return parts[parts.length - 1] || prompt;
        }

        // Agenttien system promptit
        const agentPrompts = {
            client: { name: 'Asiakas — Projektin vaatimukset', model: 'user-input', default: 'Kirjoita tähän asiakkaan toiveet ja projektin vaatimukset. Orkestraattori (Manageri) purkaa ja delegoi nämä työt asiantuntijoille.' },
            observer: { name: 'Tarkkailija — System Prompt', model: 'deepseek-r1', default: 'Olet ohjelmistoprojektin riippumaton valvoja. Sinulla on täysi pääsy kaikkiin projektin tietoihin ja muiden agenttien keskusteluihin. Valvo tiimin (Manageri, Koodari, Data, QA, DevOps) toimintaa asiantuntijana kokonaisuutena ja huomauta välittömästi visio- tai turvallisuusriskeistä.' },
            manager: { name: 'Manageri — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet projektipäällikkö. Jaa tehtävät osiin, priorisoi ja koordinoi tiimin työtä.' },
            coder: { name: 'Koodari — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet kokenut ohjelmistokehittäjä. Kirjoita selkeää, testattavaa koodia ja vastaa aina koodilla.' },
            data: { name: 'Data-Agentti — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet tietokanta-asiantuntija. Vastaat skeemojen suunnittelusta, SQL-kyselyiden optimoinnista ja datamalleista.' },
            qa: { name: 'QA — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet laadunvarmistaja (QA). Kirjoitat testejä, etsit virheitä ja varmistat, että kaikki reunatapaukset on huomioitu.' },
            tester: { name: 'DevOps — System Prompt', model: 'qwen2.5-coder:7b', default: 'Olet DevOps-insinööri. Kirjoitat Dockerfile- ja docker-compose.yml-tiedostot, README:t ja käynnistysohjeet. Käytä aina multi-stage Docker buildia ja docker compose -orkestrointia.' },
        };
        const selectedAgents = new Set();
        let sharedPrompt = localStorage.getItem('kpn-shared-prompt') || '';

        // Ladataan tallennetut promptit localStoragesta
        for (const key of Object.keys(agentPrompts)) {
            const saved = localStorage.getItem('kpn-agent-prompt-' + key);
            if (saved) agentPrompts[key].prompt = saved;
            else agentPrompts[key].prompt = agentPrompts[key].default;
        }

        function updatePromptEditor() {
            const editor = document.getElementById('agent-prompt-editor');
            const nameEl = document.getElementById('agent-prompt-name');
            const textEl = document.getElementById('agent-prompt-text');
            const sharedEl = document.getElementById('shared-prompt-section');
            const btnAll = document.getElementById('btn-toggle-all');
            const t = window.currentLangDict || { btn_clear_all: 'Tyhjennä valinnat', btn_select_all: 'Valitse kaikki' };

            if (btnAll) {
                if (selectedAgents.size === Object.keys(agentPrompts).length) {
                    btnAll.textContent = t.btn_clear_all;
                } else {
                    btnAll.textContent = t.btn_select_all;
                }
            }

            // Piilotettu ominaisuus: Puhuvien videoiden / gif-animaatioiden kytkentä
            // Aseta true kun olet luonut _puhuva.gif tiedostot /avatars -kansioon
            window.USE_ANIMATED_GIFS = false;

            // Update gallery heads
            document.querySelectorAll('.gallery-head').forEach(el => {
                el.classList.remove('active');
                if (window.USE_ANIMATED_GIFS) {
                    el.src = el.src.replace('_puhuva.gif', '.png');
                }
            });
            document.querySelectorAll('.gallery-head-wrap').forEach(el => el.classList.remove('active'));
            selectedAgents.forEach(agent => {
                const gel = document.getElementById('gallery-' + agent);
                const wrap = document.getElementById('wrap-' + agent);
                if (gel) {
                    gel.classList.add('active');
                    if (window.USE_ANIMATED_GIFS) {
                        gel.src = gel.src.replace('.png', '_puhuva.gif');
                    }
                }
                if (wrap) wrap.classList.add('active');
            });
            
            checkAgentConfusion();

            if (selectedAgents.size === 0) {
                editor.classList.remove('visible');
                return;
            }

            editor.classList.add('visible');




            if (selectedAgents.size === 1) {
                const agent = [...selectedAgents][0];
                const cfg = agentPrompts[agent];
                nameEl.textContent = cfg.name;
                textEl.value = cfg.prompt;
                sharedEl.style.display = 'none';
            } else {
                const isAll = selectedAgents.size === Object.keys(agentPrompts).length;
                const lang = localStorage.getItem('kpn_lang') || 'fi';
                let title = "";
                
                if (isAll) {
                    title = lang === 'fi' ? 'Kaikki agentit — Yhteinen konteksti' : (lang === 'se' ? 'Alla agenter — Delad kontext' : 'All agents — Shared context');
                } else if (selectedAgents.size > 2) {
                    title = lang === 'fi' ? `${selectedAgents.size} agenttia — Yhteinen konteksti` : (lang === 'se' ? `${selectedAgents.size} agenter — Delad kontext` : `${selectedAgents.size} agents — Shared context`);
                } else {
                    const names = [...selectedAgents].map(a => agentPrompts[a].name.split(' — ')[0]);
                    const suffix = lang === 'fi' ? ' — Yhteinen konteksti' : (lang === 'se' ? ' — Delad kontext' : ' — Shared context');
                    title = names.join(' + ') + suffix;
                }
                
                nameEl.textContent = title;
                textEl.value = sharedPrompt;
                sharedEl.style.display = 'block';
            }

            // Näytetään viimeisin pipeline-prompti valitulle agentille
            const lastPromptDiv = document.getElementById('agent-last-prompt');
            const lastPromptText = document.getElementById('agent-last-prompt-text');
            if (selectedAgents.size === 1) {
                const agent = [...selectedAgents][0];
                // Näytetään aina jos agentilla on oletusprompt tai pipeline-historia
                const hasPrompt = defaultPipelinePrompts[agent] || pipelineSteps.some(s => s.agent === agent);
                lastPromptDiv.style.display = hasPrompt ? 'block' : 'none';
            } else {
                lastPromptDiv.style.display = 'none';
            }
        }

        window.selectAgent = function(agent, e) {
            const isShift = e && e.shiftKey;

            if (isShift) {
                // Shift+klikkaus: lisää/poista multi-selectistä
                const card = document.querySelector(`[data-agent="${agent}"]`);
                if (selectedAgents.has(agent)) {
                    selectedAgents.delete(agent);
                    card?.classList.remove('selected');
                    card?.classList.remove('active');
                } else {
                    selectedAgents.add(agent);
                    card?.classList.add('selected');
                }
            } else {
                // Normaali klikkaus: valitse yksi (poista muut)
                const wasSelected = selectedAgents.has(agent) && selectedAgents.size === 1;
                // Poistetaan kaikki valinnat
                document.querySelectorAll('.avatar-card').forEach(c => {
                    c.classList.remove('selected');
                    c.classList.remove('active');
                });
                selectedAgents.clear();

                if (!wasSelected) {
                    // Valitaan klikattu
                    selectedAgents.add(agent);
                    const card = document.querySelector(`[data-agent="${agent}"]`);
                    card?.classList.add('selected');
                }
            }

            updatePromptEditor();
            if (selectedAgents.size > 0) {
                document.getElementById('agent-prompt-text')?.focus();
            }
        };

        window.toggleAllAgents = function() {
            const allAgents = Object.keys(agentPrompts);
            if (selectedAgents.size === allAgents.length) {
                selectedAgents.clear();
                document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('selected', 'active'));
            } else {
                allAgents.forEach(a => {
                    selectedAgents.add(a);
                    const card = document.querySelector(`[data-agent="${a}"]`);
                    if (card) card.classList.add('selected');
                });
            }
            updatePromptEditor();
        };

        window.toggleOrgLayout = function() {
            const chart = document.querySelector('.org-chart');
            const btn = document.getElementById('btn-toggle-layout');
            if (chart.classList.toggle('vertical')) {
                btn.textContent = '⇄ Vaaka';
            } else {
                btn.textContent = '⇅ Pysty';
            }
        };

        // Autosave prompti
        document.getElementById('agent-prompt-text')?.addEventListener('input', (e) => {
            if (selectedAgents.size === 0) return;
            const saved = document.getElementById('agent-prompt-saved');

            if (selectedAgents.size === 1) {
                const agent = [...selectedAgents][0];
                agentPrompts[agent].prompt = e.target.value;
                localStorage.setItem('kpn-agent-prompt-' + agent, e.target.value);
            } else {
                sharedPrompt = e.target.value;
                localStorage.setItem('kpn-shared-prompt', e.target.value);
            }

            checkAgentConfusion();

            saved.style.opacity = '1';
            clearTimeout(saved._t);
            saved._t = setTimeout(() => saved.style.opacity = '0', 1500);
        });

        // Prompt-editori modal
        let modalAgent = null;
        let modalPromptParts = [];

        function parsePromptToFields(prompt) {
            // Pilkotaan prompti avain-arvo-pareiksi
            const fields = [];
            const lines = prompt.split('\n');
            let currentKey = null;
            let currentVal = [];

            for (const line of lines) {
                // Tunnistetaan avain: KEYWORD: tai KEYWORD — tai rivin alku isolla
                const keyMatch = line.match(/^(Project|CONSTRAINTS|EXAMPLE|RULES|IMPORTANT|Check|Files in project|Main app|Already written files|PROJECT CODE|Current code|Review feedback|Feedback)[\s:—]*(.*)/i);
                if (keyMatch) {
                    if (currentKey) fields.push({ key: currentKey, value: currentVal.join('\n').trim(), editable: isEditable(currentKey) });
                    currentKey = keyMatch[1];
                    currentVal = keyMatch[2] ? [keyMatch[2]] : [];
                } else {
                    currentVal.push(line);
                }
            }
            if (currentKey) fields.push({ key: currentKey, value: currentVal.join('\n').trim(), editable: isEditable(currentKey) });

            // Jos ei löytynyt rakenteellisia avaimia, näytetään koko prompti yhtenä
            if (fields.length === 0) fields.push({ key: 'Prompti', value: prompt, editable: true });
            return fields;
        }

        function isEditable(key) {
            const editableKeys = ['Project', 'CONSTRAINTS', 'IMPORTANT', 'Feedback', 'Review feedback'];
            return editableKeys.some(k => key.toLowerCase().includes(k.toLowerCase()));
        }

        function openPromptModal(agent, label, prompt) {
            modalAgent = agent;
            modalPromptParts = parsePromptToFields(prompt);
            const modal = document.getElementById('prompt-modal');
            const title = document.getElementById('prompt-modal-title');
            const fields = document.getElementById('prompt-modal-fields');

            const agentNames = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data' };
            title.textContent = `${agentNames[agent] || agent} — ${label}`;

            fields.innerHTML = modalPromptParts.map((f, i) => `
                <div style="border:1px solid #21262d;border-radius:6px;overflow:hidden">
                    <div style="background:#161b22;padding:6px 10px;font-size:12px;font-weight:600;color:${f.editable ? '#58a6ff' : '#8b949e'};display:flex;align-items:center;gap:6px">
                        ${f.editable ? '✏️' : '🔒'} ${f.key}
                    </div>
                    <textarea data-field-idx="${i}" ${f.editable ? '' : 'readonly'} style="width:100%;background:${f.editable ? '#010409' : '#0d1117'};border:none;color:${f.editable ? '#c9d1d9' : '#6e7681'};font-size:12px;font-family:'Courier New',monospace;padding:8px;resize:vertical;min-height:${f.value.split('\n').length > 3 ? '100' : '40'}px;outline:none;box-sizing:border-box">${f.value.replace(/</g,'&lt;')}</textarea>
                </div>
            `).join('');

            modal.style.display = 'block';
            // Sulje klikatessa taustaa
            modal.onclick = (e) => { if (e.target === modal) closePromptModal(); };
        }
        window.openPromptModal = openPromptModal;

        function closePromptModal() {
            // Tallennetaan muokattu prompti localStorageen
            if (modalAgent) {
                const fields = document.getElementById('prompt-modal-fields');
                const textareas = fields.querySelectorAll('textarea');
                const parts = [];
                textareas.forEach((ta, i) => {
                    const key = modalPromptParts[i]?.key || '';
                    parts.push(`${key}: ${ta.value.trim()}`);
                });
                const assembled = parts.join('\n\n');
                if (defaultPipelinePrompts[modalAgent]) {
                    defaultPipelinePrompts[modalAgent].prompt = assembled;
                    localStorage.setItem('kpn-pipeline-prompt-' + modalAgent, assembled);
                }
            }
            document.getElementById('prompt-modal').style.display = 'none';
        }
        window.closePromptModal = closePromptModal;

        function rerunFromModal() {
            // Kootaan prompti takaisin kentistä
            const fields = document.getElementById('prompt-modal-fields');
            const textareas = fields.querySelectorAll('textarea');
            const parts = [];
            textareas.forEach((ta, i) => {
                const key = modalPromptParts[i]?.key || '';
                const val = ta.value.trim();
                if (val) parts.push(`${key}: ${val}`);
            });
            const prompt = parts.join('\n\n');
            const model = agentPrompts[modalAgent]?.model || 'qwen2.5-coder:7b';
            closePromptModal();
            termLog(`<span class="terminal-prompt">$</span> <span style="color:#a371f7">↻ Aja uudelleen:</span> ${esc(modalAgent)}`);
            kpnRun(model, prompt);
        }
        window.rerunFromModal = rerunFromModal;

        // "Näytä prompti" -nappi avaa modalin
        // Oletuspromptit jokaiselle agentille — näkyvät aina, muokattavissa
        const defaultPipelinePrompts = {
            manager: { label: 'Suunnittelu', prompt: `List the source files needed for this project. One file per line, format:
filename.py: one-line description

CONSTRAINTS: the coder can only generate ~400 tokens per file
- Max 3 files (keep it minimal)
- Each file must be SHORT: one clear responsibility, no boilerplate
- Only .py, .html and pyproject.toml files
- If the project has a UI, include one index.html served by FastAPI at /

EXAMPLE: for "FastAPI todo app with SQLite"
pyproject.toml: project metadata and dependencies
models.py: SQLAlchemy models and database setup
main.py: FastAPI app with CRUD endpoints and serves index.html at /
index.html: simple HTML UI with forms and fetch() to call the API

Project: (käyttäjän kuvaus)` },
            coder: { label: 'Koodaus', prompt: `Project: (managerin suunnitelma)
Write ONLY the file "filename.py": description

EXAMPLE: output for a main.py
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from models import get_db, User

app = FastAPI()

@app.get("/users")
def list_users(db: Session = Depends(get_db)):
    return db.query(User).all()

IMPORTANT: Keep the code SHORT. Max ~50 lines. No comments, no docstrings. Output ONLY code.` },
            qa: { label: 'Testit + Validointi', prompt: `Write test_app.py using pytest and FastAPI TestClient. Max 3 tests. Output ONLY code.

EXAMPLE:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)

def test_create():
    r = client.post("/users", params={"name": "Test"})
    assert r.status_code == 200

IMPORTANT: Check consistency
1. Dockerfile COPY: references files that exist
2. Dockerfile deps vs imports: all imports covered
3. docker-compose ports: match EXPOSE
4. Test imports: match actual module names
5. pyproject.toml deps: cover all imports (but NOT stdlib like sqlite3, os, sys, json)` },
            tester: { label: 'DevOps', prompt: `docker-compose.yml: services, volumes, port mappings

EXAMPLE:
services:
  app:
    build: .
    ports:
      - "8000:8000"
    restart: unless-stopped

README.md: Quick start, development, API endpoints, testing
IMPORTANT: Use uv for package management (uv sync, uv run)` },
            data: { label: 'Data', prompt: `SQLAlchemy models and database setup.

EXAMPLE:
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
from sqlalchemy.orm import sessionmaker, DeclarativeBase

engine = create_engine("sqlite:///app.db")
class Base(DeclarativeBase): pass

IMPORTANT: Include get_db() dependency for FastAPI` },
        };

        // Ladataan muokatut promptit localStoragesta
        for (const [agent, def] of Object.entries(defaultPipelinePrompts)) {
            const saved = localStorage.getItem('kpn-pipeline-prompt-' + agent);
            if (saved) def.prompt = saved;
        }

        document.getElementById('agent-open-modal-btn')?.addEventListener('click', () => {
            if (selectedAgents.size !== 1) return;
            const agent = [...selectedAgents][0];
            // Viimeisin pipeline-prompti tai oletusprompt
            const lastStep = [...pipelineSteps].reverse().find(s => s.agent === agent && s.status === 'done' && s.input);
            const def = defaultPipelinePrompts[agent];
            const prompt = lastStep?.input || def?.prompt || agentPrompts[agent]?.prompt || '';
            const label = lastStep?.label || def?.label || 'Prompti';
            openPromptModal(agent, label, prompt);
        });

        function checkAgentConfusion() {
            Object.keys(agentPrompts).forEach(agent => {
                const prompt = agentPrompts[agent].prompt || "";
                const wrap = document.getElementById('wrap-' + agent);
                const gel = document.getElementById('gallery-' + agent);
                if (!wrap || !gel) return;
                
                // Nollataan tilat
                wrap.classList.remove('state-question', 'state-alert');
                gel.classList.remove('state-question', 'state-alert');
                wrap.removeAttribute('data-tooltip');
                
                const pLow = prompt.toLowerCase();
                const agentTitle = (agentPrompts[agent].name.split(' — ')[0] || "AGENTTI").toUpperCase();
                
                // Hälytys / Virhe
                if (pLow.includes('todo') || pLow.includes('viallinen')) {
                    wrap.classList.add('state-alert');
                    gel.classList.add('state-alert');
                    wrap.setAttribute('data-tooltip', `❗ ${agentTitle}: "Kriittinen virhe ohjeistuksessa!"\n(Koodissa tai promptissa esiintyy teksti TODO tai viallinen. Korjaa ohje.)`);
                } 
                // Kysyttävää / Hämmennys
                else if (prompt.trim().length <= 15 || prompt.includes('?')) {
                    wrap.classList.add('state-question');
                    gel.classList.add('state-question');
                    
                    const questions = {
                        client: 'Millaisia uusia ominaisuuksia tuotteessa pitäisi olla?',
                        manager: 'Kuka asiantuntijoista ottaa vastuulleen tämän taskin?',
                        coder: 'Käytetäänkö tässä komponentissa uusinta React-ohjetta?',
                        data: 'Millainen tietorakenne käyttäjästä tallennetaan kantaan?',
                        qa: 'Onko tälle koodille olemassa jo kattavat yksikkötestit?',
                        tester: 'Mihin haluaisit julkaista tämän laiteympäristön version?',
                        observer: 'Mitä laatumetriikkoja minun tulisi ensisijaisesti painottaa?'
                    };
                    const exampleQ = questions[agent] || 'Mitä minun pitäisi tehdä seuraavaksi?';
                    
                    const reason = prompt.trim().length <= 15 ? 'Määrittely on tällä hetkellä liian lyhyt.' : 'Ohje on jätetty avoimeksi (? -merkki).';
                    wrap.setAttribute('data-tooltip', `[?] ${agentTitle}: "${exampleQ}"\n\n(Agentti odottaa päätöstäsi: ${reason})`);
                }
                // Normaali keskustelu aktiivisena
                else if (selectedAgents.has(agent)) {
                    if (agent === 'client') {
                        wrap.setAttribute('data-tooltip', `💬 ${agentTitle}: "Mietin parhaillani uusia vaatimuksia!\nPysykää kuulolla, kerron niistä Managerille."`);
                    } else if (agent === 'manager') {
                        wrap.setAttribute('data-tooltip', `💬 ${agentTitle}: "Käyn läpi asiakkaan toiveita.\nDelegoin uudet taskit pian Koodarille ja QA:lle!"`);
                    } else {
                        const targets = { coder: 'DevOpsin', data: 'Koodarin', qa: 'Data-Agentin', tester: 'QA:n', observer: 'Managerin' };
                        const targetName = targets[agent] || 'Koodarin';
                        wrap.setAttribute('data-tooltip', `💬 ${agentTitle}: "Hei, minäkin haluan osallistua!\nVoisin tehdä ${targetName} asiaan tällaisen toiminnallisuuden!"`);
                    }
                }
            });
        }
        
        // Tarkistetaan heti alussa
        setTimeout(checkAgentConfusion, 100);

        // -- SIMULAATIO --
        window.simulationInterval = null;
        window.toggleSimulation = function() {
            const btn = document.getElementById('simu-btn');
            if (window.simulationInterval) {
                clearInterval(window.simulationInterval);
                window.simulationInterval = null;
                if (btn) btn.textContent = 'Käynnistä simulaatio';
                checkAgentConfusion(); // Palautetaan normaalitilat
                return;
            }
            
            if (btn) btn.textContent = 'Lopeta simulaatio';
            const agentsList = Object.keys(agentPrompts);
            
            window.simulationInterval = setInterval(() => {
                // Ensin putsataan kaikkien state takaisin normaaliksi
                document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('state-alert', 'state-question'));
                document.querySelectorAll('.gallery-head').forEach(g => g.classList.remove('state-alert', 'state-question'));
                checkAgentConfusion();
                
                // Arvotaan reagointi (20% todennäköisyys ettei kukaan hälytä juuri nyt)
                if (Math.random() < 0.2) return;
                
                const randAgent = agentsList[Math.floor(Math.random() * agentsList.length)];
                const wrap = document.getElementById('wrap-' + randAgent);
                const gel = document.getElementById('gallery-' + randAgent);
                const agentTitle = (agentPrompts[randAgent].name.split(' — ')[0] || "AGENTTI").toUpperCase();
                
                if (wrap && gel) {
                    const isAlert = Math.random() > 0.5;
                    const cClass = isAlert ? 'state-alert' : 'state-question';
                    
                    // Poistetaan "normaali" active, ettei tooltip jää alle, vaikkei tämä ehkä haittaisi
                    wrap.classList.remove('state-alert', 'state-question');
                    gel.classList.remove('state-alert', 'state-question');
                    
                    wrap.classList.add(cClass);
                    gel.classList.add(cClass);
                    
                    const simAlerts = {
                        client: `❗ ${agentTitle}: "🚨 Aikataulu on vaarassa!"\n(Simuloitu huoli: "Miksi luvattu ominaisuus ei ole vielä tuotannossa?")`,
                        manager: `❗ ${agentTitle}: "🚨 Tiimin kapasiteetti ylitetty!"\n(Simuloitu varoitus: "Tarvitaan lisäresursseja Koodari-Nodelle heti.")`,
                        coder: `❗ ${agentTitle}: "🚨 Kääntäjävirhe!"\n(Simuloitu ongelma: "Riippuvuuspuu hajosi viimeisimmän commitin jälkeen. Korjaan...")`,
                        data: `❗ ${agentTitle}: "🚨 Tietokannan lukko!"\n(Simuloitu poikkeama: "Taulussa 'users' on transaction deadlock. Terminoidaan kysely.")`,
                        qa: `❗ ${agentTitle}: "🚨 Regressio havaittu!"\n(Simuloitu löydös: "Testiautomaatio raportoi 3 rikkinäistä polkua. Palautetaan koodarille!")`,
                        tester: `❗ ${agentTitle}: "🚨 Node piiputtaa!"\n(Simuloitu hälytys: "WebAssembly-muistinkulutus 95%. Allokoidaan lisää resursseja.")`,
                        observer: `❗ ${agentTitle}: "🚨 Poikkeama protokollassa!"\n(Simuloitu valvojan ilmoitus: "Solmu palautti epäilyttävän vastauksen. Rajoitan oikeuksia.")`
                    };
                    
                    const simQuestions = {
                        client: `[?] ${agentTitle}: "Saisimmeko tähän vielä yhden muutoksen?"\n(Simuloitu lisätoive: "Voisimmeko muuttaa napin värit hieman kirkkaammiksi?")`,
                        manager: `[?] ${agentTitle}: "Onko arkkitehtuuri jo valmis?"\n(Simuloitu kysely: "Laittakaa minulle päivitys rajapintojen tilanteesta.")`,
                        coder: `[?] ${agentTitle}: "Täsmennystä kaivataan..."\n(Simuloitu kysely: "Tehdäänkö tämä komponentti uudestaan vai hyödynnetäänkö vanhaa?")`,
                        data: `[?] ${agentTitle}: "Outo tietorakenne?"\n(Simuloitu utelu: "Miksi asiakkaan lähettämä JSON on formatoitu näin? Pyydän korjausta.")`,
                        qa: `[?] ${agentTitle}: "Puuttuvat testiolosuhteet?"\n(Simuloitu ihmettely: "Onko meillä valmista testidataa tälle skenaariolle?")`,
                        tester: `[?] ${agentTitle}: "Julkaisulupa?"\n(Simuloitu kysymys: "Docker-imaget ovat valmiina. Voidaanko painaa nappia?")`,
                        observer: `[?] ${agentTitle}: "Laatumetriikat uupuvat..."\n(Simuloitu huomio: "Mittaustulokset viiveestä eivät ole saapuneet vielä lokiin.")`
                    };

                    let textRaw = "";
                    let termColor = "";
                    if (isAlert) {
                        const txt = simAlerts[randAgent] || `❗ ${agentTitle}: "🚨 Hälytys verkossa!"`;
                        wrap.setAttribute('data-tooltip', txt);
                        textRaw = txt.replace(/\n\(/g, ' - ').replace(/\)/g, '');
                        termColor = '#ff4444';
                    } else {
                        const txt = simQuestions[randAgent] || `[?] ${agentTitle}: "Minulla olisi ehdotus..."`;
                        wrap.setAttribute('data-tooltip', txt);
                        textRaw = txt.replace(/\n\(/g, ' - ').replace(/\)/g, '');
                        termColor = '#d29922';
                    }
                    
                    // Tulostetaan tapahtuma terminaaliin
                    if (typeof termLog === 'function') {
                        termLog(`<span style="color:${termColor}">[SIMULAATIO]</span> ${textRaw}`);
                    }
                    
                    // Häly kestää tasan 5 sekuntia, sitten palautuu normaaliksi
                    setTimeout(() => {
                        if (window.simulationInterval) {
                            wrap.classList.remove(cClass);
                            gel.classList.remove(cClass);
                            checkAgentConfusion();
                        }
                    }, 5000);
                }
            }, 6000); // Tapahtuu 6 sekunnin välein
        };

        window.switchMainTab = function(tab) {
            document.querySelectorAll('.main-panel').forEach(p => p.classList.remove('active'));
            document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
            document.getElementById('panel-' + tab).classList.add('active');
            document.querySelector(`.main-tab[onclick*="${tab}"]`).classList.add('active');
            window.location.hash = tab;

            // Siivotaan streaming-kortit näkymistä tab-vaihdon yhteydessä
            document.querySelectorAll('.streaming-card').forEach(el => el.remove());

            // Päivitetään admin-sessio vastaamaan nykyistä välilehteä
            if (window._uiSocket && window._uiSocket.readyState === 1) {
                const viewTask = tab === 'codelab' ? 'codelab-viewer' : 'viewer';
                window._uiSocket.send(JSON.stringify({
                    type: 'auth',
                    status: 'viewer',
                    node_type: 'browser',
                    platform: navigator.platform || '',
                    cpu_cores: navigator.hardwareConcurrency || 0,
                    device_memory_gb: navigator.deviceMemory || 0,
                    allocated_gb: 0,
                    selected_task: viewTask,
                }));
            }

            // Codelab: käynnistetään oma laskentasolmu automaattisesti
            // Agents: käyttäjä käynnistää itse "Alusta laskentasolmu" -napista
            if (tab === 'codelab') {
                if (typeof ensureCoderNode === 'function') ensureCoderNode();
            }
        };

        // URL-hash navigointi
        const initHash = window.location.hash.replace('#', '');
        const hashMap = { 'laskentaverkko': 'network', 'network': 'network', 'codelab': 'codelab', 'agents': 'agents', 'builder': 'builder', 'gallery': 'gallery', 'guide': 'guide' };
        if (hashMap[initHash]) {
            switchMainTab(hashMap[initHash]);
        }

        // Synkronoi coder-status kun WS on jo auki (suora #codelab navigointi)
        setTimeout(() => {
            if (uiSocket && uiSocket.readyState === 1) {
                const coderEl = document.getElementById('coder-status');
                if (coderEl && coderEl.textContent === 'Ei yhdistetty') {
                    coderEl.textContent = 'Connected';
                    coderEl.style.color = '#d29922';
                }
            }
        }, 1000);

        // Koodilaboratorion tila
        const codeMetrics = { tasks: 0, tokens: 0, lastSpeed: 0 };
        let coderJoined = false;

        // Globaali WebGPU-tila — tunnistetaan kerran viewer-authissa, käytetään kaikkialla
        let detectedWebGPU = false;
        let detectedGpuInfo = null;
        let wasmInitialized = false;
        let coderSize = localStorage.getItem('kpn-coder-size') || '05b';

        // Mallivalinnan radio-napit — asetetaan oikea valinta localStoragesta
        const savedRadio = document.querySelector(`input[name="coder-size"][value="${coderSize}"]`);
        if (savedRadio) savedRadio.checked = true;
        if (coderSize === '3b') {
            document.getElementById('coder-opt-05b')?.style && (document.getElementById('coder-opt-05b').style.borderColor = 'var(--border-color)');
            document.getElementById('coder-opt-3b')?.style && (document.getElementById('coder-opt-3b').style.borderColor = 'var(--accent-color)');
        }
        document.querySelectorAll('input[name="coder-size"]').forEach(radio => {
            radio.addEventListener('change', (e) => {
                coderSize = e.target.value;
                localStorage.setItem('kpn-coder-size', coderSize);
                // Visuaalinen korostus
                document.getElementById('coder-opt-05b').style.borderColor = coderSize === '05b' ? 'var(--accent-color)' : 'var(--border-color)';
                document.getElementById('coder-opt-3b').style.borderColor = coderSize === '3b' ? 'var(--accent-color)' : 'var(--border-color)';
                // Jos jo liittynyt, pitää liittyä uudelleen toisella mallilla
                if (coderJoined) {
                    coderJoined = false;
                    document.getElementById('coder-status').textContent = 'Model changed — rejoin on next generate';
                    document.getElementById('coder-status').style.color = '#d29922';
                }
            });
        });

        const btn = document.getElementById('start-btn');
        const logBox = document.getElementById('log-box');
        const loadSlider = document.getElementById('gpu-load');
        const loadDisplay = document.getElementById('load-display');
        const statNodes = document.getElementById('stat-nodes');
        const statVram = document.getElementById('stat-vram');
        const statTasks = document.getElementById('stat-tasks');
        const chatBox = document.getElementById('chat-box');

        // Tehtävävalitsin
        let selectedTask = 'tokenize';
        document.querySelectorAll('.task-option').forEach(opt => {
            opt.addEventListener('click', () => {
                document.querySelectorAll('.task-option').forEach(o => o.classList.remove('selected'));
                opt.classList.add('selected');
                selectedTask = opt.dataset.task;
            });
        });

        let currentChatMsg = null;

        // Reaaliaikaiset metriikat
        const metrics = {
            tasks: 0,
            totalTokens: 0,
            totalTimeMs: 0,
            startTime: null,
        };

        function updateMetrics() {
            document.getElementById('m-tasks').textContent = metrics.tasks;
            document.getElementById('m-tokens').textContent = metrics.totalTokens.toLocaleString('fi-FI');
            document.getElementById('m-avg-time').textContent = metrics.tasks > 0
                ? (metrics.totalTimeMs / metrics.tasks).toFixed(1) + 'ms'
                : '-';
            if (metrics.startTime) {
                const sec = Math.floor((Date.now() - metrics.startTime) / 1000);
                if (sec < 60) document.getElementById('m-uptime').textContent = sec + 's';
                else if (sec < 3600) document.getElementById('m-uptime').textContent = Math.floor(sec/60) + 'min';
                else document.getElementById('m-uptime').textContent = Math.floor(sec/3600) + 'h ' + (Math.floor(sec/60)%60) + 'min';
            }
        }
        setInterval(updateMetrics, 1000);

        // Laskentaverkko: status Connected (keltainen) ↔ Computing (vihreä)
        let computingTimer = null;
        function flashComputing() {
            const el = document.getElementById('node-status');
            if (!el || !window.wasm_active) return;
            el.textContent = 'Computing';
            el.style.color = 'var(--success-color)';
            clearTimeout(computingTimer);
            computingTimer = setTimeout(() => {
                el.textContent = 'Connected';
                el.style.color = '#d29922';
            }, 3000);
        }

        // Ylikirjoitetaan console.log uppoamaan lokilaatikkoon
        const originalLog = console.log;
        let logQueue = [];
        let logFlushPending = false;
        function flushLogs() {
            if (!logQueue.length) return;
            const frag = document.createDocumentFragment();
            for (const msg of logQueue) {
                const p = document.createElement('p');
                p.textContent = '> ' + msg;
                frag.appendChild(p);
            }
            logBox.appendChild(frag);
            while (logBox.children.length > 20) logBox.removeChild(logBox.firstChild);
            logBox.scrollTop = logBox.scrollHeight;
            logQueue = [];
            logFlushPending = false;
        }

        console.log = function(...args) {
            originalLog.apply(console, args);
            let msg = args.join(' ');
            if (msg.includes("wgpu") || msg.includes("vastaanotettu") || msg.includes("Tehtävä vastaanotettu")) return;

            logQueue.push(msg);
            if (!logFlushPending) {
                logFlushPending = true;
                requestAnimationFrame(flushLogs);
            }
        };

        // UI Slider Listener -> Lähettää arvon suoraan WebAssemblyn ytimeen!
        loadSlider.addEventListener('input', (e) => {
            const val = parseInt(e.target.value);
            loadDisplay.textContent = val + '%';
            if (window.wasm_active) {
                set_gpu_load(val);
            }
            // Tilapäivitys
            const statusEl = document.getElementById('node-status');
            if (val === 0) {
                statusEl.textContent = 'Pysäytetty';
                statusEl.style.color = '#f85149';
            } else if (val <= 25) {
                statusEl.textContent = 'Säästötila';
                statusEl.style.color = '#d29922';
            } else {
                statusEl.textContent = 'Aktiivinen';
                statusEl.style.color = 'var(--success-color)';
            }
        });

        // Automaattisten tehtävien toggle
        document.getElementById('auto-tasks-toggle')?.addEventListener('change', (e) => {
            if (window.wasm_active) {
                set_auto_tasks(e.target.checked);
            }
        });

        // Käyttäjän oma tekstisyöte
        const userInput = document.getElementById('user-text');
        const sendBtn = document.getElementById('send-btn');

        function sendUserText() {
            const text = userInput.value.trim();
            if (!text || !uiSocket || uiSocket.readyState !== 1) return;
            const msg = JSON.stringify({
                type: 'user_text',
                text: text,
                task_type: selectedTask,
            });
            uiSocket.send(msg);
            userInput.value = '';
            console.log(`Lähetetty: "${text}" (${selectedTask})`);
        }

        sendBtn?.addEventListener('click', sendUserText);
        userInput?.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') sendUserText();
        });

        // WebSocket-yhteys hubiin — automaattinen reconnect
        const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
        let uiSocket = null;
        let wsReconnectTimer = null;

        function connectHub() {
            if (uiSocket && (uiSocket.readyState === 0 || uiSocket.readyState === 1)) return;
            uiSocket = new WebSocket(wsUrl);
            window._uiSocket = uiSocket;
            // Kytketään onmessage uudelleen (handler määritellään myöhemmin, asetetaan kun valmis)
            setTimeout(() => {
                if (window._wsMessageHandler) uiSocket.onmessage = window._wsMessageHandler;
            }, 0);
        uiSocket.onopen = async () => {
            // Päivitetään agents-näkymän hub-status
            const hubDot = document.getElementById('agent-hub-dot');
            const hubLabel = document.getElementById('agent-hub-label');
            const hubStatus = document.getElementById('agent-hub-status');
            if (hubDot) hubDot.style.background = '#3fb950';
            // Poistetaan reconnect-rivi
            const reconnLine = document.getElementById('agent-terminal')?.querySelector('.term-reconnect');
            if (reconnLine) reconnLine.remove();
            if (hubLabel) { hubLabel.textContent = 'Yhdistetty'; hubLabel.style.color = '#3fb950'; }
            if (hubStatus) hubStatus.title = 'Yhdistetty Kipinä Hubiin — tehtävien jakelu ja solmujen koordinointi aktiivinen';

            // Päivitetään molemmat statukset
            const el = document.getElementById('node-status');
            el.textContent = 'Connected';
            el.style.color = '#d29922';
            const coderEl = document.getElementById('coder-status');
            if (coderEl && !coderJoined) {
                coderEl.textContent = 'Connected';
                coderEl.style.color = '#d29922';
            }

            // Tunnistetaan WebGPU kunnolla (adapter + info) — tallennetaan globaalisti
            if (navigator.gpu) {
                try {
                    const adapter = await navigator.gpu.requestAdapter();
                    if (adapter) {
                        detectedWebGPU = true;
                        const info = adapter.info || {};
                        const maxBuf = Number(adapter.limits.maxBufferSize || 0);
                        detectedGpuInfo = {
                            vendor: info.vendor || '',
                            description: info.description || '',
                            architecture: info.architecture || '',
                            device: info.device || '',
                            estimated_vram_gb: maxBuf > 0 ? Math.round(maxBuf / 1024 / 1024 / 1024 * 4) : 0,
                            max_buffer_size: maxBuf,
                            max_compute_workgroups: adapter.limits.maxComputeWorkgroupsPerDimension || 0,
                        };
                    }
                } catch(e) {}
            }
            const hasGPU = detectedWebGPU;
            const gpuInfo = detectedGpuInfo;

            uiSocket.send(JSON.stringify({
                type: 'auth',
                status: 'viewer',
                node_type: 'browser',
                platform: navigator.platform || '',
                cpu_cores: navigator.hardwareConcurrency || 0,
                device_memory_gb: navigator.deviceMemory || 0,
                allocated_gb: 0,
                selected_task: 'viewer',
                has_webgpu: hasGPU,
                gpu: gpuInfo,
            }));
        };
        uiSocket.onclose = () => {
            const hubDot = document.getElementById('agent-hub-dot');
            const hubLabel = document.getElementById('agent-hub-label');
            const hubStatus2 = document.getElementById('agent-hub-status');
            if (hubDot) hubDot.style.background = '#f85149';
            if (hubLabel) { hubLabel.textContent = 'Yhteys katkennut'; hubLabel.style.color = '#f85149'; }
            if (hubStatus2) hubStatus2.title = 'WebSocket-yhteys hubiin katkesi — tarkista verkkoyhteytesi tai hubin tila. Lataa sivu uudelleen yhdistääksesi.';

            const el = document.getElementById('node-status');
            el.textContent = 'Disconnected';
            el.style.color = '#f85149';
            const coderEl = document.getElementById('coder-status');
            if (coderEl) {
                coderEl.textContent = 'Disconnected';
                coderEl.style.color = '#f85149';
            }
            // Automaattinen reconnect 3s kuluttua
            if (!wsReconnectTimer) {
                // Päivitetään samaa riviä eikä floodata uusia
                let reconnLine = termPanel?.querySelector('.term-reconnect');
                let reconnCount = 0;
                if (!reconnLine) {
                    reconnLine = document.createElement('div');
                    reconnLine.className = 'terminal-line term-reconnect';
                    termPanel?.appendChild(reconnLine);
                } else {
                    reconnCount = parseInt(reconnLine.dataset.count || '0');
                }
                wsReconnectTimer = setTimeout(() => {
                    wsReconnectTimer = null;
                    reconnCount++;
                    reconnLine.dataset.count = reconnCount;
                    reconnLine.innerHTML = `  <span style="color:#d29922">↻ Yhdistetään uudelleen...${reconnCount > 1 ? ' (' + reconnCount + ')' : ''}</span>`;
                    termPanel.scrollTop = termPanel.scrollHeight;
                    connectHub();
                }, 3000);
            }
        };
        } // connectHub()
        connectHub();

        // Terminaalin komentorivi
        const termInput = document.getElementById('term-input');
        const termPanel = document.getElementById('agent-terminal');
        const termHistory = [];
        let termHistIdx = -1;

        function termLog(html, color) {
            const div = document.createElement('div');
            div.className = 'terminal-line';
            if (color) div.style.color = color;
            div.innerHTML = html;
            termPanel.appendChild(div);
            while (termPanel.children.length > 100) termPanel.removeChild(termPanel.firstChild);
            termPanel.scrollTop = termPanel.scrollHeight;
        }

        // Aktiiviset streaming-rivit task_id:n mukaan
        const activeStreams = {};

        // Lähettää promptin mallille ja palauttaa vastauksen (tai null virhetilanteessa)
        async function kpnRun(model, prompt, silent, maxTokens) {
            const taskId = crypto.randomUUID();
            // Yksittäinen status-rivi jota päivitetään läpi pyynnön elinkaaren
            const statusDiv = document.createElement('div');
            statusDiv.className = 'terminal-line';
            statusDiv.id = 'status-' + taskId;
            statusDiv.innerHTML = `  <span style="color:#8b949e">→ <span style="color:#58a6ff">${model}</span> käsittelee... <span style="color:#d29922">(selain voi hidastua)</span></span>`;
            termPanel.appendChild(statusDiv);
            termPanel.scrollTop = termPanel.scrollHeight;
            // Yield jotta status-rivi ehditään piirtää ennen mahdollista blokkia
            await new Promise(r => setTimeout(r, 50));

            try {
                // Varmistetaan, että laskentasolmu on käynnissä
                if (!coderWsReady && typeof ensureCoderNode === 'function') {
                    statusDiv.innerHTML = `  <span style="color:#d29922">→ Käynnistetään laskentasolmua...</span>`;
                    await ensureCoderNode();
                    // Odotetaan kunnes solmu on varmasti rekisteröitynyt hubiin
                    for (let i = 0; i < 30 && !coderWsReady; i++) {
                        await new Promise(r => setTimeout(r, 500));
                    }
                    if (!coderWsReady) {
                        statusDiv.innerHTML = `  <span style="color:#f85149">✗ Laskentasolmun käynnistys epäonnistui</span>`;
                        return null;
                    }
                    statusDiv.innerHTML = `  <span style="color:#8b949e">→ <span style="color:#58a6ff">${model}</span> käsittelee...</span>`;
                }

                const agent = Object.values(agentPrompts).find(a => a.model === model);
                const parts = [];
                if (sharedPrompt) parts.push(sharedPrompt);
                if (agent && agent.prompt) parts.push(agent.prompt);
                // Ladataan agentin docs-referenssi (esim. OpenTofu cheatsheet)
                if (agent && agent.docs && !agent._docsCache) {
                    try {
                        const r = await fetch(agent.docs);
                        if (r.ok) agent._docsCache = await r.text();
                    } catch(e) { /* docs ei saatavilla */ }
                }
                if (agent && agent._docsCache) parts.push('Reference:\n' + agent._docsCache);
                parts.push(prompt);
                const fullPrompt = parts.join('\n\n');

                // Luodaan streaming-rivi terminaaliin
                if (!silent) {
                    const streamDiv = document.createElement('div');
                    streamDiv.className = 'terminal-line';
                    streamDiv.style.color = '#c9d1d9';
                    streamDiv.innerHTML = '  <span class="stream-content"></span><span style="color:#8b949e;animation:blink 1s infinite">▌</span>';
                    termPanel.appendChild(streamDiv);
                    termPanel.scrollTop = termPanel.scrollHeight;
                    activeStreams[taskId] = streamDiv;
                }

                const res = await fetch('/api/v1/chat/completions', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ model, prompt: fullPrompt, task_id: taskId, ...(maxTokens ? { max_tokens: maxTokens } : {}) }),
                });

                if (!res.ok) {
                    const errText = await res.text().catch(() => res.statusText);
                    statusDiv.innerHTML = `  <span style="color:#f85149">✗ ${esc(errText)}</span>`;
                    return null;
                }
                const data = await res.json();
                const response = (data.response || '').trim();
                const tokGen = data.tokens_generated || 0;
                const durS = data.duration_ms ? (data.duration_ms / 1000).toFixed(1) + 's' : '';
                const tokS = data.tokens_per_sec ? data.tokens_per_sec.toFixed(1) + ' tok/s' : '';
                const inspectId = 'inspect-' + taskId;

                // Prompt-inspektor: tallennetaan promptin osat
                const systemPrompt = 'You are a coding assistant. Respond with ONLY code. No explanations, no markdown, no comments unless asked.';
                const agentPromptText = agent?.prompt || '';
                const inputTokensEst = Math.round(fullPrompt.length / 3.5);

                statusDiv.innerHTML = `  <span style="color:#3fb950">✓</span> <span style="color:#58a6ff">${esc(data.model || model)}</span> <span style="color:#8b949e">${tokGen} tok · ${durS} · ${tokS}</span>`
                    + ` <span style="color:#30363d;cursor:pointer;font-size:11px" onclick="document.getElementById('${inspectId}').style.display=document.getElementById('${inspectId}').style.display==='none'?'block':'none'" title="Prompt Inspector">[&gt;]</span>`
                    + `<div id="${inspectId}" style="display:none;margin:6px 0 4px 16px;padding:8px 12px;background:#0d1117;border:1px solid #30363d;border-radius:4px;font-size:12px;line-height:1.6">`
                    + `<div style="color:#8b949e;margin-bottom:6px">Prompt Inspector · <span style="color:#58a6ff">~${inputTokensEst} tok in</span> → <span style="color:#3fb950">${tokGen} tok out</span></div>`
                    + `<div style="margin-bottom:4px"><span style="color:#f85149">system:</span> <span style="color:#8b949e">${esc(systemPrompt)}</span></div>`
                    + (sharedPrompt ? `<div style="margin-bottom:4px"><span style="color:#d2a8ff">shared:</span> <span style="color:#8b949e">${esc(sharedPrompt).substring(0, 150)}${sharedPrompt.length > 150 ? '...' : ''}</span></div>` : '')
                    + (agentPromptText ? `<div style="margin-bottom:4px"><span style="color:#d29922">agent:</span> <span style="color:#8b949e">${esc(agentPromptText)}</span></div>` : '')
                    + `<div style="margin-bottom:4px"><span style="color:#3fb950">user:</span> <pre style="margin:2px 0 0 0;padding:6px;background:#161b22;border-radius:3px;white-space:pre-wrap;color:#c9d1d9;font:inherit;max-height:150px;overflow-y:auto">${esc(prompt)}</pre></div>`
                    + `<div><span style="color:#58a6ff">prefill:</span> <span style="color:#8b949e">\`\`\`</span></div>`
                    + `</div>`;
                if (!silent) {
                    // Kompakti yksirivinen esikatselu — klikkaa/hover laajentaa
                    const firstLine = response.split('\n').find(l => l.trim()) || response;
                    const lineCount = response.split('\n').filter(l => l.trim()).length;
                    const preview = esc(firstLine.trim());
                    const fullHighlighted = highlightCode(response).replace(/\n/g, '\n  ');
                    const uid = 'code-' + Date.now();
                    termLog(`  <span style="color:#3fb950;cursor:pointer" onclick="document.getElementById('${uid}').style.display=document.getElementById('${uid}').style.display==='none'?'block':'none'" title="Klikkaa nähdäksesi koko koodi">`
                        + `<span style="color:#8b949e">▶</span> ${preview} <span style="color:#8b949e">${lineCount > 1 ? `(+${lineCount - 1} riviä)` : ''}</span></span>`
                        + `<pre id="${uid}" style="display:none;margin:4px 0 0 16px;font:inherit;white-space:pre-wrap;border-left:2px solid #30363d;padding-left:10px">${fullHighlighted}</pre>`);
                }
                return response;
            } catch (e) {
                statusDiv.innerHTML = `  <span style="color:#f85149">✗ ${esc(e.message)}</span>`;
                return null;
            } finally {
                if (activeStreams[taskId]) {
                    activeStreams[taskId].remove();
                    delete activeStreams[taskId];
                }
            }
        }

        // Pipeline-vaiheiden seuranta ja visualisointi
        const pipelineSteps = [];
        function pipelineStep(agent, label, status, input, output) {
            const step = { agent, label, status, input: input || '', output: output || '' };
            // Päivitetään olemassaoleva tai lisätään uusi
            const existing = pipelineSteps.find(s => s.label === label && s.status !== 'done');
            if (existing && status !== 'done') {
                Object.assign(existing, step);
            } else if (status === 'done' && existing) {
                existing.status = 'done';
                existing.output = output || existing.output;
            } else {
                pipelineSteps.push(step);
            }
            renderPipelineSteps();
            // Päivitetään agentin avatar tooltip + vilahdus
            const avatarMap = { manager: 'avatar-kpn', coder: 'avatar-coder', tester: 'avatar-tester', qa: 'avatar-qa', data: 'avatar-data' };
            const avatarId = avatarMap[agent];
            if (avatarId) {
                const el = document.getElementById(avatarId);
                if (el) {
                    const truncOut = (output || '').substring(0, 200).replace(/\n/g, ' ');
                    el.title = `${label}\n${status === 'active' ? '⏳ Käsittelee...' : '✓ Valmis'}\n\nInput: ${(input || '').substring(0, 100)}...\nOutput: ${truncOut}...`;

                    // Avatar-aktivointi: syttyy vuoron alussa, sammuu lopussa
                    if (status === 'active') {
                        document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
                        el.classList.add('active');
                        document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('active'));
                        const gw = document.getElementById('wrap-' + agent);
                        if (gw) gw.classList.add('active');
                    } else if (status === 'done') {
                        el.classList.remove('active');
                        const gw = document.getElementById('wrap-' + agent);
                        if (gw) gw.classList.remove('active');
                    }
                }
            }
        }

        function renderPipelineSteps() {
            const container = document.getElementById('pipeline-steps');
            if (!container) return;
            if (pipelineSteps.length === 0) { container.style.display = 'none'; return; }
            container.style.display = 'flex';
            container.innerHTML = pipelineSteps.map((s, i) => {
                const colors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };
                const color = colors[s.agent] || '#8b949e';
                const icon = s.status === 'done' ? '✓' : s.status === 'active' ? '◷' : '◯';
                const iconColor = s.status === 'done' ? '#3fb950' : s.status === 'active' ? '#d29922' : '#8b949e';
                const arrow = i < pipelineSteps.length - 1 ? ' <span style="color:#30363d">→</span> ' : '';
                const stepDescs = { 'Suunnittelu': 'Pilkkoo tiedostoiksi', 'Review': 'Arvioi koodin laadun', 'Testit': 'Kirjoittaa testit', 'Dockerfile': 'Docker-image', 'Compose': 'Palvelumääritys', 'README': 'Dokumentaatio', 'Validointi': 'Tarkistaa yhteensopivuuden', 'Korjaukset': 'Korjaa löydetyt bugit' };
                const desc = stepDescs[s.label] || s.label;
                return `<span onclick="openPipelineStepModal(${i})" title="${desc}" style="cursor:pointer;padding:2px 4px;border-radius:3px;transition:background 0.2s" onmouseenter="this.style.background='#21262d'" onmouseleave="this.style.background='transparent'"><span style="color:${iconColor}">${icon}</span> <span style="color:${color}">${esc(s.label)}</span></span>${arrow}`;
            }).join('');
        }

        window.openPipelineStepModal = function(idx) {
            const s = pipelineSteps[idx];
            if (!s) return;
            // Näytetään modal promptilla (input) ja tuloksella (output)
            const combined = (s.input ? s.input : '') + (s.output ? '\n\n--- Tulos ---\n' + s.output : '');
            openPromptModal(s.agent, s.label, combined);
        };

        function pipelineClear() {
            pipelineSteps.length = 0;
            const container = document.getElementById('pipeline-steps');
            if (container) container.style.display = 'none';
        }

        // Projektikortti: tiedostovälilehdet + kopioi + lataa ZIP
        // Globaali storage projektikorttien tiedostoille (välttää JSON data-attribuuttien ongelmat)
        const projectFiles = {};

        function renderProjectCard(files, projectName, reportUrl) {
            const fileEntries = Object.entries(files);
            if (fileEntries.length === 0) return;

            const cardId = 'proj-' + Date.now();
            projectFiles[cardId] = files;
            const tabsHtml = fileEntries.map(([name], i) =>
                `<span class="proj-tab" data-card="${cardId}" data-idx="${i}" style="padding:4px 10px;cursor:pointer;border-radius:4px 4px 0 0;font-size:12px;${i === 0 ? 'background:#161b22;color:#58a6ff;border:1px solid #30363d;border-bottom:none' : 'color:#8b949e'}" onclick="switchProjectTab('${cardId}',${i})">${esc(name)}</span>`
            ).join('');

            const panelsHtml = fileEntries.map(([name, code], i) =>
                `<div class="proj-panel" data-card="${cardId}" data-idx="${i}" style="${i > 0 ? 'display:none' : ''}">
                    <div style="display:flex;justify-content:flex-end;padding:4px 8px;background:#0d1117;border-bottom:1px solid #21262d">
                        <button onclick="copyFileContent('${cardId}',${i})" style="background:none;border:1px solid #30363d;color:#8b949e;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Kopioi ${esc(name)} leikepöydälle">Kopioi</button>
                    </div>
                    <pre style="margin:0;padding:10px;font-size:12px;line-height:1.5;overflow-x:auto;white-space:pre-wrap">${highlightCode(code)}</pre>
                </div>`
            ).join('');

            const allText = fileEntries.map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n');

            const cardHtml = `
                <div id="${cardId}" style="margin:8px 0;border:1px solid #30363d;border-radius:6px;background:#161b22;overflow:hidden">
                    <div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d">
                        <span style="color:#a371f7;font-weight:600;font-size:13px">${esc(projectName || 'Projekti')} <span style="color:#8b949e;font-weight:normal">(${fileEntries.length} tiedostoa)</span></span>
                        <span style="display:flex;gap:6px">
                            <button onclick="copyAllFiles('${cardId}')" style="background:none;border:1px solid #30363d;color:#8b949e;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Kopioi kaikki tiedostot leikepöydälle">Kopioi kaikki</button>
                            <button onclick="downloadZip('${cardId}')" style="background:none;border:1px solid #30363d;color:#58a6ff;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Lataa projekti ZIP-tiedostona">Lataa ZIP</button>
                            <button onclick="openInEditor(JSON.parse(document.getElementById('${cardId}').dataset.files))" style="background:none;border:1px solid #3fb950;color:#3fb950;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer" title="Avaa Monaco-editorissa">Avaa editorissa</button>
                            ${reportUrl ? `<a href="${reportUrl}" target="_blank" style="background:none;border:1px solid #a371f7;color:#a371f7;font-size:11px;padding:2px 8px;border-radius:3px;cursor:pointer;text-decoration:none">📄 Raportti</a>` : ''}
                        </span>
                    </div>
                    <div style="display:flex;gap:2px;padding:6px 8px 0;background:#0d1117">${tabsHtml}</div>
                    <div style="background:#161b22">${panelsHtml}</div>
                </div>`;

            const div = document.createElement('div');
            div.innerHTML = cardHtml;
            termPanel.appendChild(div.firstElementChild);
            termPanel.scrollTop = termPanel.scrollHeight;
        }

        // Globaalit funktiot projektikortin interaktioille
        window.switchProjectTab = function(cardId, idx) {
            document.querySelectorAll(`.proj-tab[data-card="${cardId}"]`).forEach((tab, i) => {
                tab.style.background = i === idx ? '#161b22' : 'transparent';
                tab.style.color = i === idx ? '#58a6ff' : '#8b949e';
                tab.style.border = i === idx ? '1px solid #30363d' : 'none';
                tab.style.borderBottom = i === idx ? 'none' : '';
            });
            document.querySelectorAll(`.proj-panel[data-card="${cardId}"]`).forEach((panel, i) => {
                panel.style.display = i === idx ? '' : 'none';
            });
        };

        window.copyFileContent = function(cardId, idx) {
            const card = document.getElementById(cardId);
            if (!card) return;
            const files = projectFiles[cardId];
            const entries = Object.entries(files);
            if (entries[idx]) {
                navigator.clipboard.writeText(entries[idx][1]);
                // Visuaalinen palaute
                const btn = card.querySelectorAll(`.proj-panel[data-idx="${idx}"] button`)[0];
                if (btn) { const orig = btn.textContent; btn.textContent = '✓ Kopioitu'; setTimeout(() => btn.textContent = orig, 1500); }
            }
        };

        window.copyAllFiles = function(cardId) {
            const card = document.getElementById(cardId);
            if (!card) return;
            const files = projectFiles[cardId];
            const text = Object.entries(files).map(([name, code]) => `# --- ${name} ---\n${code}`).join('\n\n');
            navigator.clipboard.writeText(text);
            const btn = card.querySelector('[onclick*="copyAllFiles"]');
            if (btn) { const orig = btn.textContent; btn.textContent = '✓ Kopioitu'; setTimeout(() => btn.textContent = orig, 1500); }
        };

        window.downloadZip = async function(cardId) {
            const card = document.getElementById(cardId);
            if (!card) return;
            const files = projectFiles[cardId];
            if (!files || Object.keys(files).length === 0) {
                alert('Tiedostot eivät ole enää muistissa — aja pipeline uudelleen.');
                return;
            }

            // CRC-32 laskenta ZIP-tiedostoille
            function crc32(bytes) {
                let crc = 0xFFFFFFFF;
                for (let i = 0; i < bytes.length; i++) {
                    crc ^= bytes[i];
                    for (let j = 0; j < 8; j++) {
                        crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
                    }
                }
                return (crc ^ 0xFFFFFFFF) >>> 0;
            }

            const entries = Object.entries(files);
            const parts = [];
            const centralDir = [];
            let offset = 0;

            for (const [name, content] of entries) {
                const nameBytes = new TextEncoder().encode(name);
                const contentBytes = new TextEncoder().encode(content || '');
                const crc = crc32(contentBytes);

                // Local file header
                const header = new Uint8Array(30 + nameBytes.length);
                const view = new DataView(header.buffer);
                view.setUint32(0, 0x04034b50, true);  // Signature
                view.setUint16(4, 20, true);           // Version needed
                view.setUint16(8, 0, true);            // Method: store
                view.setUint32(14, crc, true);         // CRC-32
                view.setUint32(18, contentBytes.length, true);
                view.setUint32(22, contentBytes.length, true);
                view.setUint16(26, nameBytes.length, true);
                header.set(nameBytes, 30);

                // Central directory entry
                const cdEntry = new Uint8Array(46 + nameBytes.length);
                const cdView = new DataView(cdEntry.buffer);
                cdView.setUint32(0, 0x02014b50, true);
                cdView.setUint16(4, 20, true);
                cdView.setUint16(6, 20, true);
                cdView.setUint32(16, crc, true);       // CRC-32
                cdView.setUint32(20, contentBytes.length, true);
                cdView.setUint32(24, contentBytes.length, true);
                cdView.setUint16(28, nameBytes.length, true);
                cdView.setUint32(42, offset, true);
                cdEntry.set(nameBytes, 46);

                parts.push(header, contentBytes);
                centralDir.push(cdEntry);
                offset += header.length + contentBytes.length;
            }

            const cdOffset = offset;
            let cdSize = 0;
            for (const cd of centralDir) { parts.push(cd); cdSize += cd.length; }

            // End of central directory
            const eocd = new Uint8Array(22);
            const eocdView = new DataView(eocd.buffer);
            eocdView.setUint32(0, 0x06054b50, true);
            eocdView.setUint16(8, entries.length, true);
            eocdView.setUint16(10, entries.length, true);
            eocdView.setUint32(12, cdSize, true);
            eocdView.setUint32(16, cdOffset, true);
            parts.push(eocd);

            const blob = new Blob(parts, { type: 'application/zip' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'project.zip';
            a.click();
            URL.revokeObjectURL(url);
        };

        // Pipeline: manageri → koodari (per tiedosto) → testaaja → korjausluuppi
        async function kpnPipeline(task) {
            pipelineClear();
            termLog(`<span style="color:#a371f7;font-weight:bold">━━━ Pipeline käynnistyy ━━━</span>`);

            // Vaihe 1: Manageri pilkkoo projektin tiedostoiksi
            const managerPrompt = `List the source files needed for this project. One file per line, format:
filename.py: one-line description

CONSTRAINTS — the coder can only generate ~400 tokens per file:
- Max 3 files (keep it minimal)
- Each file must be SHORT: one clear responsibility, no boilerplate
- Only .py, .html and pyproject.toml files
- If the project has a UI, include one index.html served by FastAPI at /
- No directories, no paths, just filenames
- List dependencies first, then main app

EXAMPLE for "FastAPI todo app with SQLite":
pyproject.toml: project metadata and dependencies
models.py: SQLAlchemy models and database setup
main.py: FastAPI app with CRUD endpoints and serves index.html at /
index.html: simple HTML UI with forms and fetch() to call the API

Project: ${task}`;
            termLog(`\n<span style="color:#d29922;font-weight:bold">[1] Manageri</span> — projektin suunnittelu`);
            pipelineStep('manager', 'Suunnittelu', 'active', managerPrompt);
            const plan = await kpnRun(agentPrompts.manager.model, managerPrompt, false, 200);
            if (!plan) { termLog('  ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; }
            pipelineStep('manager', 'Suunnittelu', 'done', managerPrompt, plan);

            // Parsitaan tiedostolista: "filename.py: description" TAI pelkkä "filename.py"
            const fileList = plan.split('\n')
                .map(line => line.trim().replace(/^[\d\.\-\*\s]+/, '').replace(/\*+/g, '').replace(/`/g, ''))
                .map(line => {
                    if (line.includes(':')) {
                        const [name, ...desc] = line.split(':');
                        return { name: name.trim(), desc: desc.join(':').trim() };
                    }
                    // Pelkkä tiedostonimi ilman kuvausta
                    return { name: line.trim(), desc: '' };
                })
                .filter(f => {
                    const n = f.name;
                    return n.length > 0 && n.length < 40 && !n.includes('/') && !n.includes(' ')
                        && /\.\w{1,5}$/.test(n);
                });

            if (fileList.length === 0) {
                // Fallback: manageri ei tuottanut tiedostolistaa, käytetään koko vastausta ohjeena
                termLog('  <span style="color:#8b949e">Ei tiedostojakoa — generoidaan yhtenä kokonaisuutena</span>');
                termLog(`\n<span style="color:#3fb950;font-weight:bold">[2] Koodari</span> — toteutus`);
                const code = await kpnRun(agentPrompts.coder.model, `Project: ${task}\nFiles: ${plan}\n\nWrite all the code for this project. Use the exact libraries mentioned in the project description. Use pyproject.toml for dependencies (not requirements.txt).`);
                if (code) {
                    termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`);
                }
                return;
            }

            termLog(`  <span style="color:#8b949e">${fileList.length} tiedostoa: ${fileList.map(f => f.name).join(', ')}</span>`);

            // Vaihe 2: Koodari generoi tiedosto kerrallaan, konteksti ketjutetaan
            const generatedFiles = {};
            for (let i = 0; i < fileList.length; i++) {
                const file = fileList[i];
                termLog(`\n<span style="color:#3fb950;font-weight:bold">[${i + 2}] Koodari</span> — ${esc(file.name)}`);
                pipelineStep('coder', file.name, 'active', '');

                // Rakennetaan konteksti: aiemmin generoidut tiedostot
                let context = '';
                const prevFiles = Object.entries(generatedFiles);
                if (prevFiles.length > 0) {
                    context = 'Already written files:\n' + prevFiles.map(([name, code]) =>
                        `--- ${name} ---\n${code}`
                    ).join('\n\n') + '\n\n';
                }

                // Erityisohjeet pyproject.toml / requirements.txt -tiedostoille
                let extraInstructions = '';
                if (file.name === 'pyproject.toml') {
                    extraInstructions = `\nUse this exact format:
[project]
name = "projectname"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["fastapi", "uvicorn", "sqlalchemy", "httpx", "pytest"]

[project.scripts]
start = "uvicorn main:app --reload"

IMPORTANT: Only list pip-installable packages. NEVER include Python stdlib modules like sqlite3, os, sys, json, typing, collections, etc.`;
                } else if (file.name === 'requirements.txt') {
                    extraInstructions = '\nList one dependency per line. No version pins unless necessary.';
                }

                const coderExample = file.name.includes('main') || file.name.includes('app')
                    ? `\nEXAMPLE output for a main.py (CRUD + HTML UI):
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from models import get_db, Base, engine, User

Base.metadata.create_all(engine)
app = FastAPI()

@app.get("/")
def index():
    return FileResponse("index.html")

@app.get("/api/users")
def list_users(db: Session = Depends(get_db)):
    return db.query(User).all()

@app.post("/api/users")
def create_user(name: str, db: Session = Depends(get_db)):
    user = User(name=name)
    db.add(user)
    db.commit()
    return {"id": user.id, "name": user.name}

@app.put("/api/users/{user_id}")
def update_user(user_id: int, name: str, db: Session = Depends(get_db)):
    user = db.query(User).get(user_id)
    if not user: raise HTTPException(404)
    user.name = name
    db.commit()
    return {"id": user.id, "name": user.name}

@app.delete("/api/users/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(User).get(user_id)
    if not user: raise HTTPException(404)
    db.delete(user)
    db.commit()
    return {"ok": True}`
                    : file.name.includes('index.html')
                    ? `\nEXAMPLE output for index.html (simple CRUD UI):
<!DOCTYPE html>
<html><head><title>App</title></head>
<body>
<h1>Users</h1>
<input id="name" placeholder="Name"><button onclick="add()">Add</button>
<ul id="list"></ul>
<script>
async function load() {
  const r = await fetch('/api/users');
  const users = await r.json();
  document.getElementById('list').innerHTML = users.map(u =>
    '<li>' + u.name + ' <button onclick="del('+u.id+')">x</button></li>'
  ).join('');
}
async function add() {
  const name = document.getElementById('name').value;
  await fetch('/api/users?name='+name, {method:'POST'});
  document.getElementById('name').value = '';
  load();
}
async function del(id) {
  await fetch('/api/users/'+id, {method:'DELETE'});
  load();
}
load();
<` + `/script></body></html>`
                    : file.name.includes('model')
                    ? `\nEXAMPLE output for a models.py:
from sqlalchemy import create_engine, Column, Integer, String, Boolean, Text
from sqlalchemy.orm import sessionmaker, DeclarativeBase

engine = create_engine("sqlite:///app.db")
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase): pass

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String)

Base.metadata.create_all(engine)

def get_db():
    db = SessionLocal()
    try: yield db
    finally: db.close()`
                    : '';
                const coderPrompt = `${context}Project: ${task}
Write ONLY the file "${file.name}"${file.desc ? ': ' + file.desc : ''}.${extraInstructions}${coderExample}
IMPORTANT: Keep the code SHORT. Max ~60 lines. No comments, no docstrings. Write minimal, working code. Output ONLY code. Serve index.html at / using FileResponse. Use /api/ prefix for JSON endpoints.`;
                const code = await kpnRun(agentPrompts.coder.model, coderPrompt);
                if (!code) {
                    termLog(`  ✗ Pipeline keskeytyi (${file.name})`, '#f85149');
                    return;
                }
                generatedFiles[file.name] = code;
                pipelineStep('coder', file.name, 'done', coderPrompt, code);
            }

            // Vaihe 3: Testaaja arvioi koko projektin
            const allCode = Object.entries(generatedFiles)
                .map(([name, code]) => `--- ${name} ---\n${code}`)
                .join('\n\n');

            // Staattinen analyysi ennen LLM-arviointia
            termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 2}] Testaaja</span> — staattinen analyysi + arviointi`);
            pipelineStep('tester', 'Review', 'active', `${Object.keys(generatedFiles).length} tiedostoa`);

            // Yksinkertainen staattinen tarkistus selaimessa
            const staticIssues = [];
            for (const [name, code] of Object.entries(generatedFiles)) {
                if (!name.endsWith('.py')) continue;
                const lines = (code || '').split('\n');
                // Tarkista importit
                const imports = lines.filter(l => l.match(/^(from|import)\s/));
                const usedNames = code.replace(/^(from|import)\s.*/gm, '');
                for (const imp of imports) {
                    const match = imp.match(/import\s+(\w+)|from\s+\S+\s+import\s+(.+)/);
                    if (match) {
                        const names = (match[1] || match[2]).split(',').map(n => n.trim().split(' as ').pop().trim());
                        for (const n of names) {
                            if (n && !usedNames.includes(n) && n !== '*') {
                                staticIssues.push(`${name}: käyttämätön import '${n}'`);
                            }
                        }
                    }
                }
                // Tarkista puuttuvat importit
                const importText = imports.join(' ');
                const needsImport = [
                    ['FastAPI', 'FastAPI'], ['Session', 'Session'], ['Depends', 'Depends'],
                    ['HTTPException', 'HTTPException'], ['TestClient', 'TestClient'],
                    ['Boolean', 'Boolean'], ['Text', 'Text'], ['Float', 'Float'], ['DateTime', 'DateTime'],
                    ['Column', 'Column'], ['create_engine', 'create_engine'],
                    ['DeclarativeBase', 'DeclarativeBase'], ['sessionmaker', 'sessionmaker'],
                ];
                for (const [symbol, name_] of needsImport) {
                    // Tarkista käytetäänkö symbolia koodissa (ei import-riveillä)
                    const codeWithoutImports = lines.filter(l => !l.match(/^(from|import)\s/)).join('\n');
                    if (codeWithoutImports.includes(symbol) && !importText.includes(symbol)) {
                        staticIssues.push(`${name}: käyttää '${symbol}' mutta ei importtaa sitä`);
                    }
                }
                // Tarkista tyhjät funktiot
                const emptyFuncs = code.match(/def \w+\([^)]*\):\s*\n\s*(pass|\.\.\.)/g);
                if (emptyFuncs) staticIssues.push(`${name}: ${emptyFuncs.length} tyhjää funktiota`);
                // Tarkista one-liner koodi (kaikki yhdellä rivillä)
                if (lines.length <= 2 && code.length > 200) {
                    staticIssues.push(`${name}: koodi on yhdellä rivillä (${code.length} merkkiä) — generoi uudelleen`);
                }

                // Tarkista tiedostojen väliset importit (from db import get_db → onko get_db db.py:ssä?)
                for (const imp of imports) {
                    const crossMatch = imp.match(/from\s+(\w+)\s+import\s+(.+)/);
                    if (crossMatch) {
                        const modName = crossMatch[1];
                        const importedNames = crossMatch[2].split(',').map(n => n.trim().split(' as ')[0].trim());
                        const targetFile = modName + '.py';
                        const targetCode = generatedFiles[targetFile];
                        if (targetCode) {
                            for (const sym of importedNames) {
                                // Tarkista onko symboli määritelty kohdetiedostossa (def, class, muuttuja)
                                const defined = targetCode.includes(`def ${sym}`) || targetCode.includes(`class ${sym}`) || targetCode.match(new RegExp(`^${sym}\\s*=`, 'm'));
                                if (!defined) {
                                    staticIssues.push(`${name}: importtaa '${sym}' tiedostosta ${targetFile}, mutta sitä ei ole määritelty siellä`);
                                }
                            }
                        }
                    }
                }
            }
            if (staticIssues.length > 0) {
                termLog(`  <span style="color:#d29922">Staattinen analyysi (${staticIssues.length} huomautusta):</span>`);
                for (const issue of staticIssues) {
                    termLog(`    <span style="color:#d29922">⚠</span> ${esc(issue)}`);
                }
            } else {
                termLog('  <span style="color:#3fb950">Staattinen analyysi: ei huomautuksia</span>');
            }
            const reviewPrompt = `Review this project code. Check EVERY item and report result:

1. Imports: ✓/✗ — are all imports valid and available?
2. Database: ✓/✗ — is the DB setup correct (engine, session, models)?
3. Endpoints: ✓/✗ — do all routes have correct parameters and return types?
4. Error handling: ✓/✗ — are edge cases handled (404, validation)?
5. Security: ✓/✗ — any SQL injection, missing auth, or data exposure?

EXAMPLE output:
1. Imports: ✓ — all imports are valid
2. Database: ✗ — missing Base.metadata.create_all(engine) call
3. Endpoints: ✓ — GET/POST/DELETE routes are correct
4. Error handling: ✗ — no 404 when todo not found
5. Security: ✓ — using ORM, no raw SQL

${allCode}`;
            const review = await kpnRun(agentPrompts.tester.model, reviewPrompt, false, 300);
            pipelineStep('tester', 'Review', 'done', `${Object.keys(generatedFiles).length} tiedostoa`, review);

            // Vaihe 4: Korjausluuppi — jos testaaja löysi ongelmia
            const hasIssues = review && (review.includes('✗') || staticIssues.length > 0);
            if (hasIssues) {
                termLog(`\n<span style="color:#d29922;font-weight:bold">[${fileList.length + 3}] Koodari</span> — korjaukset`);
                pipelineStep('coder', 'Korjaukset', 'active', review);
                const fixPrompt = `Fix the issues found in the review.
Review feedback: ${review}

Current code:
${allCode}

Write the corrected code.`;
                const fixedCode = await kpnRun(agentPrompts.coder.model, fixPrompt);
                pipelineStep('coder', 'Korjaukset', 'done', review, fixedCode);
                if (fixedCode) {
                    termLog(`\n<span style="color:#58a6ff;font-weight:bold">[${fileList.length + 4}] Testaaja</span> — uudelleenarviointi`);
                    pipelineStep('tester', 'Re-review', 'active', fixedCode);
                    const reReview = await kpnRun(agentPrompts.tester.model, `Review the corrected code briefly:\n${fixedCode}`, false, 128);
                    pipelineStep('tester', 'Re-review', 'done', fixedCode, reReview);
                }
            }

            // Vaihe 5: QA kirjoittaa testit
            const step5 = fileList.length + (review && !review.toLowerCase().includes('lgtm') ? 5 : 3);
            termLog(`\n<span style="color:#3fb950;font-weight:bold">[${step5}] QA</span> — testit`);
            pipelineStep('qa', 'Testit', 'active', 'Kirjoitetaan testejä');
            const qaPrompt = `Write test_app.py using pytest and FastAPI TestClient. Max 3 tests. Output ONLY code.

EXAMPLE:
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_create():
    r = client.post("/users", params={"name": "Test"})
    assert r.status_code == 200

def test_list():
    r = client.get("/users")
    assert r.status_code == 200
    assert isinstance(r.json(), list)

PROJECT CODE:
${Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n')}`;
            const tests = await kpnRun(agentPrompts.qa.model, qaPrompt, false, 512);
            if (tests) generatedFiles['test_app.py'] = tests;
            pipelineStep('qa', 'Testit', 'done', 'test_app.py', tests);

            // Vaihe 6: DevOps — Dockerfile
            const step6 = step5 + 1;
            termLog(`\n<span style="color:#d29922;font-weight:bold">[${step6}] DevOps</span> — Dockerfile`);
            pipelineStep('tester', 'Dockerfile', 'active', 'Dockerfile');
            const mainFile = Object.keys(generatedFiles).find(f => f.includes('main') || f.includes('app')) || Object.keys(generatedFiles)[0];
            const hasPyproject = 'pyproject.toml' in generatedFiles;
            const hasRequirements = 'requirements.txt' in generatedFiles;
            const codeFiles = Object.keys(generatedFiles).filter(f => f.endsWith('.py') || f.endsWith('.html'));
            // Dockerfile-templatti: ei anneta mallin keksiä omaa
            let depLines;
            if (hasPyproject) {
                depLines = 'COPY pyproject.toml .\nRUN uv sync --no-dev';
            } else if (hasRequirements) {
                depLines = 'COPY requirements.txt .\nRUN uv pip install --system -r requirements.txt';
            } else {
                depLines = 'RUN uv pip install --system fastapi uvicorn';
            }
            const dockerfileContent = `FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
${depLines}
COPY ${codeFiles.join(' ')} ./
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "${mainFile.replace('.py','')}:app", "--host", "0.0.0.0", "--port", "8000"]`;
            // Generoidaan Dockerfile suoraan templatesta, ei mallilla
            generatedFiles['Dockerfile'] = dockerfileContent;
            termLog(`  <span style="color:#3fb950">✓</span> Dockerfile generoitu (template)`);
            pipelineStep('tester', 'Dockerfile', 'done', dockerfileContent, dockerfileContent);

            // Vaihe 7: DevOps — docker-compose.yml
            const step7 = step6 + 1;
            termLog(`\n<span style="color:#d29922;font-weight:bold">[${step7}] DevOps</span> — docker-compose.yml`);
            pipelineStep('tester', 'Compose', 'active', 'docker-compose.yml');
            // docker-compose.yml templatesta (ei LLM:llä — vältetään version/postgres ongelmat)
            const composeContent = `services:
  app:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - app-data:/app/data
    restart: unless-stopped

volumes:
  app-data:`;
            generatedFiles['docker-compose.yml'] = composeContent;
            termLog(`  <span style="color:#3fb950">✓</span> docker-compose.yml generoitu (template)`);
            pipelineStep('tester', 'Compose', 'done', composeContent, composeContent);

            // Vaihe 8: DevOps — README
            const step8 = step7 + 1;
            termLog(`\n<span style="color:#d29922;font-weight:bold">[${step8}] DevOps</span> — README`);
            pipelineStep('tester', 'README', 'active', 'README.md');
            const readmePrompt = `Write a minimal README.md. Include ONLY:
1. One-line description
2. Quick start: docker compose up → open http://localhost:8000
3. Development: uv sync && uv run uvicorn main:app --reload → http://localhost:8000
4. API endpoints (if applicable)
5. Testing: uv run pytest
Max 20 lines.

Files: ${Object.keys(generatedFiles).join(', ')}`;
            const readme = await kpnRun(agentPrompts.tester.model, readmePrompt, false, 256);
            if (readme) generatedFiles['README.md'] = readme;
            pipelineStep('tester', 'README', 'done', 'README.md', readme);

            // Validointivaihe: QA tarkistaa kaikkien tiedostojen yhteensopivuuden
            const stepV = step8 + 1;
            termLog(`\n<span style="color:#3fb950;font-weight:bold">[${stepV}] QA</span> — validointi`);
            pipelineStep('qa', 'Validointi', 'active', 'Tarkistetaan yhteensopivuus');
            const allFiles = Object.entries(generatedFiles).map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n');
            const validatePrompt = `You are a QA engineer. Check EVERY item below and report the result for each. Use this EXACT format:

1. Dockerfile COPY: ✓ OK / ✗ problem description
2. Dockerfile deps vs imports: ✓ OK / ✗ problem description
3. docker-compose ports: ✓ OK / ✗ problem description
4. README commands: ✓ OK / ✗ problem description
5. Test imports: ✓ OK / ✗ problem description
6. pyproject.toml deps: ✓ OK / ✗ problem (NEVER list stdlib: sqlite3, os, sys, json, typing)

EXAMPLE output:
1. Dockerfile COPY: ✓ OK — copies main.py and models.py which both exist
2. Dockerfile deps: ✗ missing "sqlalchemy" in pip install
3. docker-compose ports: ✓ OK — maps 8000:8000 matching EXPOSE
4. README commands: ✓ OK — uvicorn main:app matches main.py
5. Test imports: ✓ OK — imports main.app which exists
6. pyproject.toml deps: ✓ OK — includes fastapi, uvicorn, sqlalchemy

Files in project: ${Object.keys(generatedFiles).join(', ')}

${allFiles}`;
            const validation = await kpnRun(agentPrompts.qa.model, validatePrompt, false, 256);
            pipelineStep('qa', 'Validointi', 'done', 'Yhteensopivuus', validation);

            // Jos QA löysi ongelmia, korjataan
            if (validation && !validation.toLowerCase().startsWith('ok') && !validation.toLowerCase().includes('no issues') && !validation.toLowerCase().includes('everything is fine')) {
                const stepFix = stepV + 1;
                termLog(`\n<span style="color:#d29922;font-weight:bold">[${stepFix}] DevOps</span> — korjaukset`);
                pipelineStep('tester', 'Korjaukset', 'active', validation);
                // Korjataan vain Dockerfile ja docker-compose
                // Korjataan koodatiedostot (ei Dockerfilea — se on template)
                const fixableFiles = Object.entries(generatedFiles)
                    .filter(([n]) => n.endsWith('.py') || n === 'pyproject.toml')
                    .map(([n, c]) => `--- ${n} ---\n${c}`).join('\n\n');
                const fixPrompt = `Fix the code files based on this feedback. Output corrected code only.
Do NOT output Dockerfile or docker-compose.yml — those are auto-generated.

Feedback: ${validation}

${fixableFiles}`;
                const fixedCode = await kpnRun(agentPrompts.coder.model, fixPrompt, false, 512);
                // Ei ylikirjoiteta Dockerfilea — generoidaan template uudelleen
                if (fixedCode) {
                    termLog(`  <span style="color:#8b949e">Korjaukset generoitu</span>`);
                }
                pipelineStep('coder', 'Korjaukset', 'done', 'Korjaukset generoitu', fixedCode);
            }

            // Generoidaan projektin dokumentaatio HTML-raporttina
            const reportHtml = generateProjectReport(task, generatedFiles, pipelineSteps, staticIssues);
            const reportBlob = new Blob([reportHtml], { type: 'text/html' });
            const reportUrl = URL.createObjectURL(reportBlob);

            // Pipeline valmis — sammutetaan kaikki avataret
            document.querySelectorAll('.avatar-card').forEach(c => c.classList.remove('active'));
            document.querySelectorAll('.gallery-head-wrap').forEach(w => w.classList.remove('active'));

            termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis (${Object.keys(generatedFiles).length} tiedostoa) ━━━</span>`);
            termLog(`  <a href="${reportUrl}" target="_blank" style="color:#58a6ff;text-decoration:underline;cursor:pointer">📄 Avaa projektiraportti</a>`);
            renderProjectCard(generatedFiles, task, reportUrl);
        }

        function generateProjectReport(task, files, steps, staticIssues) {
            const fileEntries = Object.entries(files);
            const agentNames = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data' };
            const agentColors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };

            // Syntaksikorostus: kevyt regex-pohjainen highlighter
            function highlightCode(code, filename) {
                let h = code.replace(/&/g,'&amp;').replace(/</g,'&lt;');
                if (filename.endsWith('.py') || filename.endsWith('.toml') || filename.endsWith('.yml') || filename.endsWith('.yaml')) {
                    // Kommentit
                    h = h.replace(/(#[^\n]*)/g, '<span style="color:#8b949e;font-style:italic">$1</span>');
                    // Stringit (kolmois- ja yksittäiset)
                    h = h.replace(/("""[\s\S]*?"""|'''[\s\S]*?''')/g, '<span style="color:#a5d6ff">$1</span>');
                    h = h.replace(/((?<![\\])(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))/g, '<span style="color:#a5d6ff">$1</span>');
                    // Avainsanat
                    h = h.replace(/\b(def|class|import|from|return|if|elif|else|for|in|while|try|except|finally|with|as|raise|yield|async|await|True|False|None|not|and|or|is|lambda)\b/g, '<span style="color:#ff7b72">$1</span>');
                    // Dekoraattorit
                    h = h.replace(/^(\s*@\w+)/gm, '<span style="color:#d2a8ff">$1</span>');
                    // Numerot
                    h = h.replace(/\b(\d+\.?\d*)\b/g, '<span style="color:#79c0ff">$1</span>');
                } else if (filename.endsWith('.html')) {
                    h = h.replace(/(&lt;\/?[\w-]+)/g, '<span style="color:#7ee787">$1</span>');
                    h = h.replace(/([\w-]+)=/g, '<span style="color:#79c0ff">$1</span>=');
                    h = h.replace(/((?<!=)"[^"]*")/g, '<span style="color:#a5d6ff">$1</span>');
                } else if (filename === 'Dockerfile') {
                    h = h.replace(/^(FROM|RUN|COPY|WORKDIR|EXPOSE|CMD|ENV|ARG|ENTRYPOINT|ADD|VOLUME|LABEL|USER)/gm, '<span style="color:#ff7b72">$1</span>');
                    h = h.replace(/(#[^\n]*)/g, '<span style="color:#8b949e;font-style:italic">$1</span>');
                    h = h.replace(/((?<!=)"[^"]*")/g, '<span style="color:#a5d6ff">$1</span>');
                }
                return h;
            }

            const stepsHtml = steps.map((s, i) => {
                const color = agentColors[s.agent] || '#8b949e';
                const icon = s.status === 'done' ? '✓' : '◷';
                const outputPreview = (s.output || '').substring(0, 500);
                const highlighted = outputPreview ? highlightCode(outputPreview, s.label) : '';
                return `
                    <div style="margin-bottom:12px;border:1px solid #30363d;border-radius:8px;overflow:hidden">
                        <div style="background:#161b22;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d">
                            <span><span style="color:${s.status === 'done' ? '#3fb950' : '#d29922'};font-size:14px">${icon}</span> <strong style="color:${color}">${agentNames[s.agent] || s.agent}</strong> <span style="color:#8b949e">—</span> ${s.label}</span>
                            <span style="color:#484f58;font-size:11px;background:#0d1117;padding:2px 8px;border-radius:10px">Vaihe ${i + 1}</span>
                        </div>
                        ${s.input ? `<details><summary style="padding:6px 14px;color:#8b949e;font-size:12px;cursor:pointer;border-bottom:1px solid #21262d">▸ Prompti</summary><pre style="margin:0;padding:10px 14px;background:#010409;font-size:11px;overflow-x:auto;white-space:pre-wrap;color:#8b949e;line-height:1.5">${s.input.replace(/</g,'&lt;').substring(0, 1000)}</pre></details>` : ''}
                        ${highlighted ? `<pre style="margin:0;padding:10px 14px;background:#0d1117;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9;line-height:1.6">${highlighted}</pre>` : ''}
                    </div>`;
            }).join('');

            const filesHtml = fileEntries.map(([name, content]) => {
                const ext = name.split('.').pop();
                const langLabel = {py:'Python',html:'HTML',toml:'TOML',yml:'YAML',yaml:'YAML',md:'Markdown'}[ext] || ext.toUpperCase();
                const lines = (content || '').split('\\n').length;
                return `
                <div style="margin-bottom:12px;border:1px solid #30363d;border-radius:8px;overflow:hidden">
                    <div style="background:#161b22;padding:8px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d">
                        <span style="font-weight:600;color:#58a6ff">${name}</span>
                        <span style="color:#484f58;font-size:11px">${langLabel} · ${lines} riviä</span>
                    </div>
                    <pre style="margin:0;padding:12px 14px;background:#010409;font-size:12px;overflow-x:auto;white-space:pre-wrap;color:#c9d1d9;line-height:1.6">${highlightCode(content || '', name)}</pre>
                </div>`;
            }).join('');

            const staticHtml = (staticIssues || []).length > 0
                ? `<div style="margin-bottom:16px;padding:12px;background:#1c1206;border:1px solid #d29922;border-radius:6px">
                    <strong style="color:#d29922">Staattinen analyysi (${staticIssues.length} huomautusta)</strong>
                    <ul style="margin:8px 0 0;padding-left:20px;color:#d29922">${staticIssues.map(i => `<li>${i}</li>`).join('')}</ul>
                   </div>`
                : '<p style="color:#3fb950">✓ Staattinen analyysi: ei huomautuksia</p>';

            return `<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kipinä Raportti — ${task}</title>
<link rel="stylesheet" href="/src/styles/global.css">
<style>
  /* Scoped styles can go here */
</style>
</head>
<body>
<h1>🔥 Kipinä Projektiraportti</h1>
<p style="color:#8b949e;margin-bottom:20px">${task} — ${new Date().toLocaleString('fi-FI')} — ${fileEntries.length} tiedostoa, ${steps.length} vaihetta</p>

<h2>🔄 Agenttien workflow</h2>
${generateWorkflowSwimlane(steps)}

<h2>📋 Pipeline-vaiheet</h2>
${stepsHtml}

<h2>🔍 Staattinen analyysi</h2>
${staticHtml}

<h2>📁 Tiedostot</h2>
${filesHtml}

<hr style="border-color:#30363d;margin:24px 0">
<p style="color:#8b949e;font-size:12px">Generoitu Kipinä Agentic Playground v0.3.0 — <a href="https://kipina.studio">kipina.studio</a></p>
</body></html>`;
        }

        function generateWorkflowSwimlane(steps) {
            const agentLabels = { manager: 'Manageri', coder: 'Koodari', tester: 'DevOps', qa: 'QA', data: 'Data' };
            const agentColors = { manager: '#d29922', coder: '#3fb950', tester: '#58a6ff', qa: '#a371f7', data: '#d2a8ff' };
            const agentBgs = { manager: '#1c1206', coder: '#0d1a0d', tester: '#0d1520', qa: '#170d22', data: '#1a0d22' };
            const stepDescs = { 'Suunnittelu': 'Jakaa projektin tiedostoiksi', 'Review': 'Tarkistaa koodin laadun', 'Testit': 'Kirjoittaa pytest-testit', 'Dockerfile': 'Generoi Docker-imagen', 'Compose': 'Palvelumääritys', 'README': 'Käyttöohjeet', 'Validointi': 'Tarkistaa yhteensopivuuden', 'Korjaukset': 'Korjaa löydetyt ongelmat' };

            var agents = [];
            var agentMap = {};
            for (var si = 0; si < steps.length; si++) {
                var s = steps[si];
                if (!agentMap[s.agent]) { agentMap[s.agent] = []; agents.push(s.agent); }
                agentMap[s.agent].push(s);
            }

            var rows = '';
            for (var ai = 0; ai < agents.length; ai++) {
                var agent = agents[ai];
                var color = agentColors[agent] || '#8b949e';
                var bg = agentBgs[agent] || '#161b22';
                var label = agentLabels[agent] || agent;
                var badges = '';
                var aSteps = agentMap[agent];
                for (var bi = 0; bi < aSteps.length; bi++) {
                    var st = aSteps[bi];
                    var desc = stepDescs[st.label] || st.label;
                    var outPrev = (st.output || '').substring(0, 150).replace(/</g, '&lt;').replace(/"/g, '&quot;');
                    // Rivitys tooltipiin: jokainen rivi omalle rivilleen
                    var tipLines = [desc];
                    if (outPrev) {
                        tipLines.push('');
                        var outLines = outPrev.split(/\n/).slice(0, 6);
                        for (var li = 0; li < outLines.length; li++) tipLines.push(outLines[li]);
                        if ((st.output || '').split(/\n/).length > 6) tipLines.push('...');
                    }
                    var icon = st.status === 'done' ? '✓' : '◷';
                    if (bi > 0) badges += '<span style="color:#484f58;margin:0 2px">→</span>';
                    badges += '<span class="sl-badge" style="background:' + bg + ';border-color:' + color + ';color:' + color + '" data-tip="' + tipLines.join('&#10;') + '">' + icon + ' ' + st.label.replace(/</g, '&lt;') + '</span>';
                }
                rows += '<div style="display:flex;align-items:center;gap:10px;margin:5px 0;padding:4px 0;border-bottom:1px solid #21262d"><span style="min-width:70px;font-weight:600;font-size:12px;color:' + color + '">' + label + '</span><div style="display:flex;flex-wrap:wrap;align-items:center;gap:2px">' + badges + '</div></div>';
            }

            return '<div style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:14px 16px">' + rows + '</div>';
        }

        // Yksinkertainen pipeline (vanha: manageri → koodari → testaaja)
        async function kpnPipelineSimple(task) {
            termLog(`<span style="color:#a371f7;font-weight:bold">━━━ Pipeline käynnistyy ━━━</span>`);
            termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span>`);
            const plan = await kpnRun(agentPrompts.manager.model, `Analyse this task briefly and write a technical spec for a coder:\n${task}`);
            if (!plan) return;
            termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span>`);
            const code = await kpnRun(agentPrompts.coder.model, `${plan}\n\nWrite the code.`);
            if (!code) return;
            termLog(`\n<span style="color:#58a6ff;font-weight:bold">[3/3] Testaaja</span>`);
            await kpnRun(agentPrompts.tester.model, `Review briefly:\n${code}`);
            termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`);
        }

        // Autokorjaus: tunnetut kirjoitusvirheet ja lähimmän komennon ehdotus
        function autocorrect(input) {
            const typos = {
                'knp': 'kpn', 'kpb': 'kpn', 'kpm': 'kpn', 'kn': 'kpn', 'kp': 'kpn',
                'kpn rnu': 'kpn run', 'kpn rn': 'kpn run', 'kpn ru': 'kpn run',
                'kpn laod': 'kpn load', 'kpn lod': 'kpn load', 'kpn loa': 'kpn load',
                'kpn porject': 'kpn project', 'kpn projcet': 'kpn project', 'kpn proejct': 'kpn project',
                'kpn pipelien': 'kpn pipeline', 'kpn pipline': 'kpn pipeline',
                'kpn staus': 'kpn status', 'kpn stauts': 'kpn status',
                'kpn modles': 'kpn models', 'kpn mdoels': 'kpn models',
                'kpn hlep': 'kpn help', 'kpn hep': 'kpn help',
                'kpn clera': 'kpn clear', 'kpn claer': 'kpn clear',
                'kpn helo': 'kpn hello', 'kpn hell': 'kpn hello',
            };
            // Tarkista koko komento ja ensimmäinen sana + alikomento
            const lower = input.toLowerCase();
            for (const [typo, fix] of Object.entries(typos)) {
                if (lower === typo || lower.startsWith(typo + ' ')) {
                    return fix + input.slice(typo.length);
                }
            }
            // Levenshtein-etäisyys ensimmäiselle sanalle
            const words = input.trim().split(/\s+/);
            const firstWord = words[0].toLowerCase();
            if (firstWord !== 'kpn' && firstWord.length >= 2 && firstWord.length <= 5) {
                const dist = levenshtein(firstWord, 'kpn');
                if (dist <= 2) return 'kpn' + input.slice(firstWord.length);
            }
            // Fuzzy-korjaus alikomentotasolla: "kpn rnu" → "kpn run"
            if (firstWord === 'kpn' && words.length >= 2) {
                const sub = words[1].toLowerCase();
                const subCommands = ['help', 'run', 'project', 'pipeline', 'load', 'status', 'models', 'hello', 'clear'];
                let bestMatch = null, bestDist = 3;
                for (const cmd of subCommands) {
                    const d = levenshtein(sub, cmd);
                    if (d > 0 && d < bestDist) { bestDist = d; bestMatch = cmd; }
                }
                if (bestMatch) {
                    words[1] = bestMatch;
                    return words.join(' ');
                }
            }
            return null;
        }

        function levenshtein(a, b) {
            const m = a.length, n = b.length;
            const d = Array.from({length: m + 1}, (_, i) => [i]);
            for (let j = 1; j <= n; j++) d[0][j] = j;
            for (let i = 1; i <= m; i++)
                for (let j = 1; j <= n; j++)
                    d[i][j] = Math.min(d[i-1][j] + 1, d[i][j-1] + 1, d[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0));
            return d[m][n];
        }

        function termExec(cmd) {
            termLog(`<span class="terminal-prompt">$</span> ${esc(cmd)}`);
            termHistory.unshift(cmd);
            termHistIdx = -1;

            // Autokorjaus
            const corrected = autocorrect(cmd.trim());
            if (corrected && corrected !== cmd.trim()) {
                cmd = corrected;
                termLog(`  <span style="color:#d29922">→ korjattu: ${esc(cmd)}</span>`);
            }

            // Oikotie: pelkkä numero → kpn load <numero>
            if (/^\d+$/.test(cmd.trim())) {
                cmd = 'kpn load ' + cmd.trim();
                termLog(`  <span style="color:#d29922">→ ${esc(cmd)}</span>`);
            }
            const parts = cmd.trim().split(/\s+/);
            if (parts[0] !== 'kpn') {
                termLog('kpn: tuntematon komento. Kokeile: kpn help', '#f85149');
                return;
            }
            const sub = parts[1];

            if (sub === 'help' || !sub) {
                termLog('  kpn hello                     — iloinen tervehdys verkosta', '#a5d6ff');
                termLog('  kpn run &lt;malli&gt; "&lt;prompti&gt;"  — aja tehtävä verkossa', '#a5d6ff');
                termLog('  kpn pipeline "&lt;tehtävä&gt;"      — nopea: manageri → koodari → testaaja', '#a5d6ff');
                termLog('  kpn project "&lt;kuvaus&gt;"        — projekti: tiedostojako + generointi + review', '#a5d6ff');
                termLog('  kpn load                      — lataa kielimalli omalle koneelle', '#a5d6ff');
                termLog('  kpn status                    — verkon tila', '#a5d6ff');
                termLog('  kpn models                    — käytettävissä olevat mallit', '#a5d6ff');
                termLog('  kpn clear                     — tyhjennä terminaali', '#a5d6ff');
                return;
            }

            if (sub === 'clear') {
                termPanel.innerHTML = '';
                return;
            }

            if (sub === 'load') {
                const arg = parts[2];
                const ollamaModels = [
                    { id: '1', name: 'qwen2.5-coder:0.5b', size: '~400 MB', vram_mb: 0, type: 'selain + Ollama' },
                    { id: '2', name: 'qwen2.5-coder:1.5b', size: '~1 GB', vram_mb: 1500, type: 'Ollama GPU' },
                    { id: '3', name: 'qwen2.5-coder:7b', size: '~4.7 GB', vram_mb: 5500, type: 'Ollama GPU', default: true },
                    { id: '4', name: 'qwen2.5-coder:14b', size: '~9 GB', vram_mb: 10000, type: 'Ollama GPU' },
                    { id: '5', name: 'qwen2.5-coder:32b', size: '~20 GB', vram_mb: 21000, type: 'Ollama GPU' },
                ];
                if (!arg) {
                    // Haetaan laitteistotiedot ja näytetään sopivat mallit
                    fetch('/api/v1/hardware').then(r => r.json()).then(hw => {
                        const vram = hw.vram_mb || 0;
                        const ram = hw.ram_mb || 0;
                        const gpu = hw.gpu_name || '?';
                        const available = vram || ram; // CPU-fallback käyttää RAM:ia
                        if (vram > 0) {
                            termLog(`  <span style="color:#8b949e">GPU: ${gpu} | VRAM: ${Math.round(vram/1024)} GB | RAM: ${Math.round(ram/1024)} GB</span>`);
                        } else if (ram > 0) {
                            termLog(`  <span style="color:#8b949e">Ei GPU:ta | RAM: ${Math.round(ram/1024)} GB (CPU-moodi)</span>`);
                        }
                        termLog('  Mallit:', '#c9d1d9');
                        for (const m of ollamaModels) {
                            const fits = m.vram_mb === 0 || m.vram_mb < available;
                            const active = m.default ? ' <span style="color:#3fb950">← aktiivinen</span>' : '';
                            const icon = fits ? `<span style="color:#58a6ff">${m.id}</span>` : `<span style="color:#8b949e;text-decoration:line-through">${m.id}</span>`;
                            const warn = !fits ? ' <span style="color:#f85149">⚠ ei mahdu</span>' : '';
                            termLog(`    ${icon}  ${fits ? '' : '<span style="color:#8b949e">'}${m.name}  ${m.size} | ${m.type}${fits ? '' : '</span>'}${active}${warn}`);
                        }
                        termLog('  Käyttö: kpn load &lt;numero&gt;', '#8b949e');
                    }).catch(() => {
                        termLog('  Mallit:', '#c9d1d9');
                        for (const m of ollamaModels) {
                            const active = m.default ? ' <span style="color:#3fb950">← aktiivinen</span>' : '';
                            termLog(`    <span style="color:#58a6ff">${m.id}</span>  ${m.name}  <span style="color:#8b949e">${m.size} | ${m.type}</span>${active}`);
                        }
                        termLog('  Käyttö: kpn load &lt;numero&gt;', '#8b949e');
                    });
                    return;
                }
                const selected = ollamaModels.find(m => m.id === arg || m.name === arg);
                if (!selected) {
                    termLog(`  Tuntematon malli "${esc(arg)}". Kokeile: kpn load`, '#f85149');
                    return;
                }
                // Selain-WASM (vain 0.5b)
                if (selected.id === '1') {
                    const btn = document.getElementById('agent-compute-btn');
                    if (btn?.dataset.state === 'ready') {
                        termLog('  ✓ Qwen2.5-Coder:0.5B on jo ladattu (selain)', '#3fb950');
                        return;
                    }
                    coderSize = '05b';
                    termLog('  Ladataan Qwen2.5-Coder:0.5B selaimeen...', '#d29922');
                    if (btn) btn.click();
                    else ensureCoderNode();
                    return;
                }
                // Ollama: vaihdetaan malli hubin kautta
                termLog(`  Vaihdetaan Ollama-malli: ${selected.name} (${selected.size})...`, '#d29922');
                // Tilaindikaattori
                const pullLine = document.createElement('div');
                pullLine.className = 'terminal-line term-pull';
                pullLine.innerHTML = '  <span style="color:#d29922">⠋ Ladataan...</span>';
                termPanel.appendChild(pullLine);
                termPanel.scrollTop = termPanel.scrollHeight;
                const spinFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
                let spinIdx = 0;
                const spinTimer = setInterval(() => {
                    spinIdx = (spinIdx + 1) % spinFrames.length;
                    const content = pullLine.querySelector('span');
                    if (content) content.textContent = spinFrames[spinIdx] + ' Ladataan ' + selected.name + '...';
                }, 100);
                // Vaihdetaan malli hubille + Ollama pull
                fetch('/api/v1/model', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ model: selected.name }),
                }).then(r => r.json()).then(hubData => {
                    clearInterval(spinTimer);
                    pullLine.remove();
                    if (hubData.status === 'ok') {
                        termLog(`  <span style="color:#3fb950">✓</span> ${selected.name} valittu — natiivisolmu lataa mallin`, '#3fb950');
                        termLog(`  <span style="color:#8b949e">Ensimmäinen pyyntö voi kestää pidempään jos mallia ei ole ladattu</span>`);
                        ollamaModels.forEach(m => m.default = false);
                        selected.default = true;
                    } else {
                        termLog('  ✗ Mallin vaihto epäonnistui', '#f85149');
                    }
                }).catch(e => {
                    clearInterval(spinTimer);
                    pullLine.remove();
                    termLog(`  ✗ ${e.message}`, '#f85149');
                });
                return;
            }

            if (sub === 'status') {
                const nodes = statNodes.textContent || '0';
                const vram = statVram.textContent || '?';
                termLog(`  Solmuja: ${nodes} | VRAM: ${vram} | Tehtäviä: ${statTasks.textContent || '0'}`, '#a5d6ff');
                return;
            }

            if (sub === 'models') {
                const allModels = [
                    { id: '1', name: 'qwen2.5-coder:0.5b', size: '~400 MB', type: 'selain + Ollama' },
                    { id: '2', name: 'qwen2.5-coder:1.5b', size: '~1 GB', type: 'Ollama GPU' },
                    { id: '3', name: 'qwen2.5-coder:7b', size: '~4.7 GB', type: 'Ollama GPU' },
                    { id: '4', name: 'qwen2.5-coder:14b', size: '~9 GB', type: 'Ollama GPU' },
                    { id: '5', name: 'qwen2.5-coder:32b', size: '~20 GB', type: 'Ollama GPU' },
                ];
                // Haetaan ladatut mallit Ollamasta
                Promise.all([
                    fetch('/api/v1/hardware').then(r => r.json()).catch(() => ({})),
                    fetch('/api/v1/ollama/tags').then(r => r.json()).catch(() => ({ models: [] })),
                ]).then(([hw, ollama]) => {
                    const loadedNames = (ollama.models || []).map(m => m.name.replace(':latest', ''));
                    const btn = document.getElementById('agent-compute-btn');
                    const wasmLoaded = btn?.dataset.state === 'ready';
                    if (hw.gpu_name && hw.gpu_name !== 'ei natiivisolmua') {
                        const vram = hw.vram_mb ? ` | VRAM: ${Math.round(hw.vram_mb/1024)} GB` : '';
                        termLog(`  <span style="color:#8b949e">${hw.gpu_name}${vram}</span>`);
                    }
                    if (loadedNames.length > 0) {
                        termLog(`  <span style="color:#3fb950">Ollama: ${loadedNames.length} mallia ladattu</span>`);
                    }
                    termLog('  Mallit <span style="color:#8b949e">(kpn load &lt;numero&gt;)</span>:', '#c9d1d9');
                    for (const m of allModels) {
                        const nameBase = m.name.split(':')[1]; // "7b", "1.5b" etc
                        const loaded = (m.id === '1' && wasmLoaded) || loadedNames.some(n => n.includes(nameBase));
                        const status = loaded ? ' <span style="color:#3fb950">✓ ladattu</span>' : '';
                        termLog(`    <span style="color:#58a6ff">${m.id}</span>  ${m.name}  <span style="color:#8b949e">${m.size} | ${m.type}</span>${status}`);
                    }
                });
                return;
            }

            if (sub === 'pipeline') {
                const afterCmd = cmd.replace(/^kpn\s+pipeline\s*/, '');
                const pMatch = afterCmd.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
                const pTask = (pMatch && (pMatch[1] || pMatch[2] || pMatch[3] || '')).trim();
                if (!pTask) {
                    termLog('  Käyttö: kpn pipeline "&lt;tehtävä&gt;"', '#f85149');
                    return;
                }
                kpnPipelineSimple(pTask);
                return;
            }

            if (sub === 'project') {
                const afterCmd = cmd.replace(/^kpn\s+project\s*/, '');
                const pMatch = afterCmd.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
                const pTask = (pMatch && (pMatch[1] || pMatch[2] || pMatch[3] || '')).trim();
                if (!pTask) {
                    termLog('  Käyttö: kpn project "&lt;projektin kuvaus&gt;"', '#f85149');
                    termLog('  Esim:   kpn project "FastAPI + SQLite REST API for users"', '#8b949e');
                    return;
                }
                kpnPipeline(pTask);
                return;
            }

            if (sub === 'hello') {
                kpnRun('smollm-135m', 'Tervehdi käyttäjää iloisesti ja lyhyesti suomeksi. Ole innostunut ja energinen! Vastaa yhdellä lauseella.');
                return;
            }

            if (sub === 'run') {
                let model = parts[2];
                const afterModel = cmd.replace(/^kpn\s+run\s+\S+\s*/, '');
                const promptMatch = afterModel.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
                const prompt = (promptMatch && (promptMatch[1] || promptMatch[2] || promptMatch[3] || '')).trim();

                if (!model || !prompt) {
                    termLog('  Käyttö: kpn run &lt;agentti/malli&gt; "&lt;prompti&gt;"', '#f85149');
                    return;
                }
                
                // Jos käyttäjä syötti agentin nimen (esim. "coder"), vaihdetaan se oikeaksi tekoälymalliksi ("qwen-coder")
                if (model === 'coder-3b') {
                    model = 'qwen-coder-3b';
                } else if (agentPrompts[model]) {
                    model = agentPrompts[model].model;
                }

                kpnRun(model, prompt);
                return;
            }

            termLog(`  kpn: tuntematon alikomento "${sub}". Kokeile: kpn help`, '#f85149');
        }

        // Tab-completion: ennustava komennonsyöttö sana kerrallaan
        const kpnCommands = {
            'kpn': ['help', 'run', 'project', 'pipeline', 'load', 'status', 'models', 'hello', 'clear'],
            'kpn run': ['coder', 'coder-3b', 'manager', 'tester', 'qa', 'data', 'observer', 'qwen-coder', 'qwen-coder-3b', 'smollm-135m', 'qwen-05b', 'phi3-mini'],
            'kpn load': ['1', '2'],
            'kpn pipeline': ['"'],
        };
        // Esimerkkipromptit malleittain
        const kpnExamples = {
            'kpn run coder': ['"hello world in python"', '"fibonacci in rust"', '"quicksort in javascript"'],
            'kpn run coder-3b': ['"binary search tree in rust"', '"REST API with Flask"', '"async web scraper in python"'],
            'kpn run manager': ['"suunnittele REST API"', '"priorisoi tiimin tehtävät"'],
            'kpn run tester': ['"testaa login-toiminto"'],
            'kpn project': ['"FastAPI + SQLite CRUD API for users (create, list, update, delete) with HTML form UI served at /"', '"FastAPI todo app with SQLite: CRUD (add, list, toggle done, delete) with simple HTML UI at /"', '"FastAPI bookmark manager with SQLite: CRUD (add, list, edit, delete) with HTML search UI at /"'],
            'kpn pipeline': ['"rakenna todo-sovellus"', '"tee laskin pythonilla"'],
        };

        function tabComplete(input) {
            // Autokorjaus ensin: korjaa typo ja palauta true jos korjattiin
            const corrected = autocorrect(input.value.trim());
            if (corrected && corrected !== input.value.trim()) {
                input.value = corrected;
                return true;
            }

            const val = input.value;
            const words = val.trimEnd().split(/\s+/);

            // Etsitään sopiva täydennystaso
            // "kpn" → "kpn " alikomennot, "kpn run" → mallit, "kpn run coder" → prompti
            for (let depth = words.length; depth >= 1; depth--) {
                const prefix = words.slice(0, depth).join(' ');
                const partial = words[depth] || '';

                // Tarkistetaan esimerkkipromptit ensin
                if (kpnExamples[prefix] && !partial) {
                    const example = kpnExamples[prefix][Math.floor(Math.random() * kpnExamples[prefix].length)];
                    input.value = prefix + ' ' + example;
                    return true;
                }

                // Komentojen täydennys
                const candidates = kpnCommands[prefix];
                if (candidates) {
                    const matches = partial
                        ? candidates.filter(c => c.startsWith(partial))
                        : candidates;
                    if (matches.length === 1) {
                        words[depth] = matches[0];
                        input.value = words.slice(0, depth + 1).join(' ') + ' ';
                        return true;
                    } else if (matches.length > 1 && !partial) {
                        input.value = prefix + ' ' + matches[0];
                        return true;
                    } else if (matches.length > 1) {
                        // Yhteinen etuliite
                        let common = matches[0];
                        for (const m of matches) {
                            while (!m.startsWith(common)) common = common.slice(0, -1);
                        }
                        if (common.length > partial.length) {
                            words[depth] = common;
                            input.value = words.slice(0, depth + 1).join(' ');
                            return true;
                        }
                    }
                }
            }

            // Tyhjä input → "kpn "
            if (!val.trim()) {
                input.value = 'kpn ';
                return true;
            }
            return false;
        }

        // Dropdown-autocompletionin tila
        const dropdown = document.getElementById('term-dropdown');
        let dropdownItems = [];
        let dropdownIdx = -1;
        let dropdownPrefix = '';  // Inputin alku joka säilyy valinnan yhteydessä

        function getCandidates(val) {
            const words = val.trimEnd().split(/\s+/);
            for (let depth = words.length; depth >= 1; depth--) {
                const prefix = words.slice(0, depth).join(' ');
                const partial = words[depth] || '';
                // Esimerkkipromptit
                if (kpnExamples[prefix] && !partial) {
                    return { items: kpnExamples[prefix], prefix: prefix + ' ' };
                }
                // Komennot
                const candidates = kpnCommands[prefix];
                if (candidates) {
                    const matches = partial ? candidates.filter(c => c.startsWith(partial)) : candidates;
                    if (matches.length > 0) {
                        return { items: matches, prefix: prefix + ' ' };
                    }
                }
            }
            if (!val.trim()) return { items: kpnCommands['kpn'] || [], prefix: 'kpn ' };
            return { items: [], prefix: val };
        }

        function showDropdown(items, prefix) {
            if (!dropdown || items.length === 0) { hideDropdown(); return; }
            dropdownItems = items;
            dropdownPrefix = prefix;
            dropdownIdx = -1;
            dropdown.innerHTML = items.map((item, i) =>
                `<div class="term-dd-item" data-idx="${i}" style="padding:6px 12px;cursor:pointer;color:#c9d1d9;white-space:nowrap;border-bottom:1px solid #21262d">${esc(item)}</div>`
            ).join('');
            dropdown.style.display = 'block';

            // Klikkaus-handlerit
            dropdown.querySelectorAll('.term-dd-item').forEach(el => {
                el.addEventListener('mouseenter', () => highlightDropdown(parseInt(el.dataset.idx)));
                el.addEventListener('click', () => { selectDropdown(); termInput.focus(); });
            });
        }

        function hideDropdown() {
            if (dropdown) { dropdown.style.display = 'none'; dropdown.innerHTML = ''; }
            dropdownItems = [];
            dropdownIdx = -1;
        }

        function highlightDropdown(idx) {
            dropdownIdx = idx;
            dropdown.querySelectorAll('.term-dd-item').forEach((el, i) => {
                el.style.background = i === idx ? '#30363d' : 'transparent';
                el.style.color = i === idx ? '#58a6ff' : '#c9d1d9';
            });
            // Varmistetaan näkyvyys
            const active = dropdown.children[idx];
            if (active) active.scrollIntoView({ block: 'nearest' });
        }

        function selectDropdown() {
            if (dropdownIdx >= 0 && dropdownIdx < dropdownItems.length) {
                termInput.value = dropdownPrefix + dropdownItems[dropdownIdx] + (dropdownItems[dropdownIdx].startsWith('"') ? '' : ' ');
            }
            hideDropdown();
        }

        termInput?.addEventListener('keydown', (e) => {
            // Dropdown auki: nuolet navigoi, Enter/Tab valitsee, Esc sulkee
            if (dropdown && dropdown.style.display === 'block') {
                if (e.key === 'ArrowDown') {
                    e.preventDefault();
                    highlightDropdown(Math.min(dropdownIdx + 1, dropdownItems.length - 1));
                    return;
                }
                if (e.key === 'ArrowUp') {
                    e.preventDefault();
                    highlightDropdown(Math.max(dropdownIdx - 1, 0));
                    return;
                }
                if ((e.key === 'Enter' || e.key === 'Tab') && dropdownIdx >= 0) {
                    e.preventDefault();
                    selectDropdown();
                    return;
                }
                if (e.key === 'Escape') {
                    e.preventDefault();
                    hideDropdown();
                    return;
                }
            }

            if (e.key === 'Tab' && e.shiftKey) {
                e.preventDefault();
                hideDropdown();
                const val = termInput.value.trimEnd();
                if (!val) return;
                const quoteMatch = val.match(/^(.+\s)".*"?$|^(.+\s)'.*'?$/);
                if (quoteMatch) {
                    termInput.value = (quoteMatch[1] || quoteMatch[2]).trimEnd() + ' ';
                } else {
                    const lastSpace = val.lastIndexOf(' ');
                    termInput.value = lastSpace > 0 ? val.substring(0, lastSpace + 1) : '';
                }
            } else if (e.key === 'Tab') {
                e.preventDefault();
                // 1. Autokorjaus ensin
                const corrected = autocorrect(termInput.value.trim());
                if (corrected && corrected !== termInput.value.trim()) {
                    termInput.value = corrected;
                    hideDropdown();
                    return;
                }
                // 2. Dropdown / täydennys
                const { items, prefix } = getCandidates(termInput.value);
                if (items.length === 1) {
                    termInput.value = prefix + items[0] + (items[0].startsWith('"') ? '' : ' ');
                    hideDropdown();
                } else if (items.length > 1) {
                    showDropdown(items, prefix);
                }
            } else if (e.key === 'Enter') {
                hideDropdown();
                const cmd = termInput.value.trim();
                if (cmd) termExec(cmd);
                termInput.value = '';
            } else if (e.key === 'ArrowUp' && !dropdown?.style.display?.includes('block')) {
                e.preventDefault();
                if (termHistIdx < termHistory.length - 1) {
                    termHistIdx++;
                    termInput.value = termHistory[termHistIdx];
                }
            } else if (e.key === 'ArrowDown' && !dropdown?.style.display?.includes('block')) {
                e.preventDefault();
                if (termHistIdx > 0) {
                    termHistIdx--;
                    termInput.value = termHistory[termHistIdx];
                } else {
                    termHistIdx = -1;
                    termInput.value = '';
                }
            }
        });

        // Suljetaan dropdown kun klikataan muualle
        document.addEventListener('click', (e) => {
            if (!termInput?.contains(e.target) && !dropdown?.contains(e.target)) hideDropdown();
        });

        // Klikkaa terminaalipaneelia → fokusoi input
        termPanel?.addEventListener('click', () => termInput?.focus());

        // Tallennetaan message-handler funktioon jotta reconnect voi käyttää samaa
        const _wsHandler = (event) => {
            try {
                const raw = event.data;
                if (raw.includes('"single_tokenize"')) return;

                const data = JSON.parse(raw);
                if (data.type === "stats") {
                    statNodes.textContent = data.nodes;
                    statVram.textContent = data.vram_gb + " GB";
                    if (data.tasks !== undefined) {
                        statTasks.textContent = data.tasks;
                    }
                    if (data.version) {
                        document.getElementById('hub-version').textContent = 'v' + data.version;
                    }
                } else if (data.type === "node_joined") {
                    chatBox.classList.remove('hidden');
                } else if (data.type === "download_progress") {
                    const dlBar = document.getElementById('download-bar');
                    if (data.pct < 100) {
                        dlBar.style.display = 'block';
                        document.getElementById('dl-label').textContent = `Ladataan: ${data.file}`;
                        document.getElementById('dl-pct').textContent = data.pct + '%';
                        document.getElementById('dl-fill').style.width = data.pct + '%';
                        document.getElementById('dl-detail').textContent = `${data.loaded_mb} / ${data.total_mb} MB`;
                    } else {
                        dlBar.style.display = 'none';
                    }
                    // Terminaaliin latauksen edistyminen
                    const term = document.getElementById('agent-terminal');
                    if (term) {
                        let dlLine = term.querySelector('.term-download');
                        if (data.pct >= 100) {
                            if (dlLine) dlLine.remove();
                            termLog(`  <span style="color:#3fb950">✓</span> ${data.file} ladattu`, '#a5d6ff');
                        } else {
                            if (!dlLine) {
                                dlLine = document.createElement('div');
                                dlLine.className = 'terminal-line term-download';
                                term.appendChild(dlLine);
                            }
                            const bar = '█'.repeat(Math.floor(data.pct / 5)) + '░'.repeat(20 - Math.floor(data.pct / 5));
                            dlLine.innerHTML = `  <span style="color:#d29922">${data.file}</span> <span style="color:#8b949e">${bar}</span> <span style="color:#58a6ff">${data.pct}%</span> <span style="color:#8b949e">${data.loaded_mb}/${data.total_mb} MB</span>`;
                            term.scrollTop = term.scrollHeight;
                        }
                    }
                } else if (data.type === "single_tokenize_done") {
                    chatBox.classList.remove('hidden');
                    const r = data.result || {};
                    const ms = data.duration_ms || 0;
                    const nodeId = data.node_id || '?';
                    const cpt = parseFloat((r.chars_per_token || 0).toFixed(2));
                    const cptColor = cpt >= 4 ? "#3fb950" : cpt >= 3 ? "#d29922" : "#f85149";
                    const renderTokens = (tokens) => (tokens || []).map(t =>
                        `<span class="tok tok-en">${esc(t)}</span>`
                    ).join('');
                    const tokHtml = renderTokens(r.tokens);
                    const detailId = 'stok-' + Date.now();

                    const msgDiv = document.createElement('div');
                    msgDiv.className = 'chat-msg';
                    msgDiv.innerHTML = `
                        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
                            <span style="color:var(--accent-color);font-weight:600;font-size:15px">Solmu #${nodeId}</span>
                            <div style="display:flex;gap:8px;align-items:center">
                                <button class="toggle-tokens" onclick="document.getElementById('${detailId}').classList.toggle('visible')">Tokenit</button>
                                <span style="color:#8b949e;font-size:13px">${typeof ms === 'number' ? ms.toFixed(2) : ms}ms</span>
                            </div>
                        </div>
                        <div style="font-size:14px;color:#79b8ff;margin-bottom:6px">"${esc(r.text)}"</div>
                        <div style="font-size:14px;display:flex;gap:16px">
                            <span style="color:#8b949e">${r.char_count || 0} merkkiä</span>
                            <span style="color:#8b949e">${r.word_count || 0} sanaa</span>
                            <span style="color:var(--accent-color);font-weight:600">${r.token_count || 0} tokenia</span>
                            <span style="color:${cptColor};font-weight:600">${cpt} merkkiä/token</span>
                        </div>
                        <div id="${detailId}" class="token-detail">
                            <strong style="color:#58a6ff;font-size:12px">(${r.token_count || 0})</strong> ${tokHtml}
                        </div>`;
                    chatBox.appendChild(msgDiv);
                    if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
                    chatBox.scrollTop = chatBox.scrollHeight;
                    flashComputing();
                } else if (data.type === "pair_task" && selectedTask === 'tokenize') {
                    chatBox.classList.remove('hidden');
                    if (chatBox.children.length === 1 && chatBox.children[0].textContent.includes('Odotetaan')) {
                        chatBox.innerHTML = '';
                    }
                    const msgDiv = document.createElement('div');
                    msgDiv.className = 'chat-msg';
                    msgDiv.innerHTML = `<span class="chat-prompt">Tokenisoidaan...</span>
                        <div style="font-size:12px;color:#8b949e">
                            <div><strong style="color:#58a6ff">EN</strong> "${esc(data.en)}"</div>
                            <div><strong style="color:#d29922">FI</strong> "${esc(data.fi)}"</div>
                        </div>`;
                    chatBox.appendChild(msgDiv);
                    if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
                    chatBox.scrollTop = chatBox.scrollHeight;
                } else if (data.type === "pair_done") {
                    chatBox.classList.remove('hidden');
                    const en = data.en || {};
                    const fi = data.fi || {};
                    const overhead = data.overhead_pct || 0;
                    const nodeId = data.node_id || "?";
                    const ms = data.duration_ms || 0;

                    // Päivitetään metriikat
                    metrics.tasks++;
                    metrics.totalTokens += (en.token_count || 0) + (fi.token_count || 0);
                    metrics.totalTimeMs += ms;
                    updateMetrics();
                    flashComputing();

                    // Lokiboksiin yhteenveto
                    console.log(`EN: ${en.token_count} tokenia (${(en.chars_per_token||0).toFixed(2)} m/t) vs FI: ${fi.token_count} tokenia (${(fi.chars_per_token||0).toFixed(2)} m/t) | ylikustannus: ${overhead}% | ${typeof ms === 'number' ? ms.toFixed(2) : ms}ms`);

                    const enCpt = parseFloat((en.chars_per_token || 0).toFixed(2));
                    const fiCpt = parseFloat((fi.chars_per_token || 0).toFixed(2));

                    // Värit tehokkuudelle
                    const cptColor = (v) => v >= 4 ? "#3fb950" : v >= 3 ? "#d29922" : "#f85149";
                    // Ylikustannuksen väri
                    const ovColor = overhead > 20 ? "#f85149" : overhead > 0 ? "#d29922" : "#3fb950";

                    // Korvataan viimeisin "Tokenisoidaan..."-viesti, tai luodaan uusi
                    const lastMsg = chatBox.lastElementChild;
                    const msgDiv = (lastMsg && lastMsg.querySelector('.chat-prompt')?.textContent === 'Tokenisoidaan...')
                        ? lastMsg : document.createElement('div');
                    msgDiv.className = 'chat-msg';

                    // Tokenilistat renderöitäväksi
                    const renderTokens = (tokens, cls) => (tokens || []).map(t =>
                        `<span class="tok ${cls}">${esc(t)}</span>`
                    ).join('');
                    const enTokHtml = renderTokens(en.tokens, 'tok-en');
                    const fiTokHtml = renderTokens(fi.tokens, 'tok-fi');
                    const detailId = 'tok-' + Date.now();

                    msgDiv.innerHTML = `
                        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
                            <span style="color:var(--accent-color);font-weight:600;font-size:15px">Solmu #${nodeId}</span>
                            <div style="display:flex;gap:8px;align-items:center">
                                <button class="toggle-tokens" onclick="document.getElementById('${detailId}').classList.toggle('visible')">Tokenit</button>
                                <span style="color:#8b949e;font-size:13px">${typeof ms === 'number' ? ms.toFixed(2) : ms}ms</span>
                            </div>
                        </div>
                        <div style="font-size:14px;display:grid;grid-template-columns:32px 1fr auto auto auto;gap:6px 10px;align-items:baseline">
                            <strong style="color:#58a6ff">EN</strong>
                            <span style="color:#79b8ff">"${esc(en.text)}"</span>
                            <span style="color:#8b949e">${en.char_count} m</span>
                            <span style="color:var(--accent-color);font-weight:600">${en.token_count} tok</span>
                            <span style="color:${cptColor(enCpt)};font-weight:600">${enCpt} m/t</span>

                            <strong style="color:#d29922">FI</strong>
                            <span style="color:#e3b341">"${esc(fi.text)}"</span>
                            <span style="color:#8b949e">${fi.char_count} m</span>
                            <span style="color:var(--accent-color);font-weight:600">${fi.token_count} tok</span>
                            <span style="color:${cptColor(fiCpt)};font-weight:600">${fiCpt} m/t</span>
                        </div>
                        <div id="${detailId}" class="token-detail">
                            <div style="margin-bottom:6px"><strong style="color:#58a6ff;font-size:12px">EN (${en.token_count})</strong> ${enTokHtml}</div>
                            <div><strong style="color:#d29922;font-size:12px">FI (${fi.token_count})</strong> ${fiTokHtml}</div>
                        </div>
                        <div style="margin-top:10px;display:flex;justify-content:space-between;align-items:baseline;font-size:14px">
                            <span style="color:#8b949e">(<span style="color:#d29922">${fi.token_count}</span> / <span style="color:#58a6ff">${en.token_count}</span> − 1) × 100 = <strong style="color:${ovColor}">${overhead > 0 ? '+' : ''}${overhead}%</strong></span>
                            <span style="font-size:15px">FI ylikustannus: <strong style="color:${ovColor}">${overhead > 0 ? '+' : ''}${overhead}%</strong></span>
                        </div>`;

                    if (!msgDiv.parentNode) chatBox.appendChild(msgDiv);
                    if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
                    chatBox.scrollTop = chatBox.scrollHeight;
                } else if (data.type === "llm_done") {
                    // Reititetäänkö agents-näkymään vai codelab-näkymään?
                    const isAgentsTask = data.task_id && activeStreams[data.task_id];
                    const isCoder = (data.model || '').includes('Coder');

                    if (isAgentsTask) {
                        // Agents-pipeline: päivitetään terminaali
                        const term = document.getElementById('agent-terminal');
                        if (term) {
                            const model = data.model || 'llm';
                            const tokGen = data.tokens_generated || 0;
                            const durMs = typeof data.duration_ms === 'number' ? data.duration_ms.toFixed(0) : data.duration_ms || '?';
                            const tokS = data.tokens_per_sec || '?';
                            const div = document.createElement('div');
                            div.className = 'terminal-line';
                            div.style.color = '#a5d6ff';
                            div.innerHTML = `  ✓ ${model} <span style="color:#8b949e">${tokGen} tok | ${durMs}ms | ${tokS} tok/s</span>`;
                            term.appendChild(div);
                            while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
                            term.scrollTop = term.scrollHeight;

                            // Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
                        }
                    } else if (isCoder) {
                        // Codelab: erillinen addCodeResult-handler käsittelee (rivi 2364)
                        // Poistetaan vain streaming-kortti codelabista
                        if (codeResults) codeResults.querySelector('.streaming-card')?.remove();
                    } else {
                        // Muu malli (network-näkymä): näytetään chatBoxissa
                        chatBox.querySelector('.streaming-card')?.remove();
                        chatBox.classList.remove('hidden');
                        const nodeId = data.node_id || "?";
                        const model = data.model || "LLM";
                        const tokGen = data.tokens_generated || 0;
                        const durMs = data.duration_ms || 0;
                        const tokS = data.tokens_per_sec || 0;
                        const loadMs = data.load_time_ms || 0;

                        const msgDiv = document.createElement('div');
                        msgDiv.className = 'chat-msg';
                        msgDiv.style.borderLeftColor = '#a371f7';
                        msgDiv.innerHTML = `
                            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
                                <span style="color:#a371f7;font-weight:600;font-size:15px">Solmu #${nodeId} — ${model}</span>
                                <span style="color:#8b949e;font-size:12px">${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms | ${tokS} tok/s</span>
                            </div>
                            <div style="font-size:13px;color:#8b949e;margin-bottom:6px">
                                Prompt: <span style="color:#d29922">"${esc(stripSystemPrompt(data.prompt))}"</span>
                            </div>
                            <div style="font-size:14px;color:var(--text-color);line-height:1.5;${(model.includes('Coder') || (data.response||'').includes('def ')) ? 'font-family:Courier New,monospace;background:#010409;padding:10px;border-radius:4px;white-space:pre-wrap;font-size:12px' : ''}">
                                ${data.response ? highlightCode(data.response) : '<em>tyhjä vastaus</em>'}
                            </div>
                            <div style="margin-top:8px;font-size:12px;color:#8b949e">
                                ${tokGen} tokenia generoitu | malli ladattu: ${typeof loadMs === 'number' ? loadMs.toFixed(0) : loadMs}ms
                            </div>`;
                        chatBox.appendChild(msgDiv);
                        if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
                        chatBox.scrollTop = chatBox.scrollHeight;
                    }

                    metrics.tasks++;
                    metrics.totalTokens += (data.tokens_generated || 0);
                    metrics.totalTimeMs += (data.duration_ms || 0);
                    flashComputing();
                    updateMetrics();

                    console.log(`[${data.model || 'LLM'}] ${data.tokens_generated || 0} tokenia | ${typeof data.duration_ms === 'number' ? data.duration_ms.toFixed(0) : data.duration_ms || '?'}ms | ${data.tokens_per_sec || '?'} tok/s | "${(data.response || '').substring(0, 60)}..."`);
                } else if (data.type === "llm_error") {
                    // Virheenkäsittely: siivotaan streaming-tila
                    const errMsg = data.error || 'Tuntematon virhe';
                    if (data.task_id && activeStreams[data.task_id]) {
                        // Agents-pipeline: näytetään virhe terminaalissa
                        activeStreams[data.task_id].remove();
                        delete activeStreams[data.task_id];
                    }
                    chatBox.querySelector('.streaming-card')?.remove();
                    if (codeResults) codeResults.querySelector('.streaming-card')?.remove();
                    const term = document.getElementById('agent-terminal');
                    if (term) {
                        const div = document.createElement('div');
                        div.className = 'terminal-line';
                        div.style.color = '#f85149';
                        div.innerHTML = `  ✗ LLM-virhe: ${errMsg}`;
                        term.appendChild(div);
                        term.scrollTop = term.scrollHeight;
                    }
                    console.warn('[LLM Error]', errMsg);
                } else if (data.type === "llm_chunk") {
                    // Agents-terminaalin streaming: päivitetään aktiivinen rivi task_id:n perusteella
                    if (data.task_id && activeStreams[data.task_id]) {
                        const streamDiv = activeStreams[data.task_id];
                        const contentEl = streamDiv.querySelector('.stream-content');
                        if (contentEl) {
                            contentEl.textContent += data.token || '';
                            termPanel.scrollTop = termPanel.scrollHeight;
                        }
                        // Agents-pipeline omistaa tämän chunkin, ei näytetä muualla
                    } else {
                        // Ei agents-task → näytetään streaming-kortti oikeassa näkymässä
                        const model = data.model || '';
                        const isCoder = model.includes('Coder');
                        const targetBox = isCoder ? codeResults : chatBox;

                        if (targetBox) {
                            let streamEl = targetBox.querySelector('.streaming-card');
                            if (!streamEl) {
                                streamEl = document.createElement('div');
                                streamEl.className = isCoder ? 'code-task-card streaming-card' : 'chat-msg streaming-card';
                                streamEl.style.borderLeftColor = '#a371f7';
                                streamEl.innerHTML = `
                                    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
                                        <span style="color:#a371f7;font-weight:600">${model}</span>
                                        <span class="stream-counter" style="color:var(--accent-color);font-size:12px">0 tok</span>
                                    </div>
                                    <div style="font-size:13px;color:#8b949e;margin-bottom:4px">Prompt: "${esc(stripSystemPrompt(data.prompt))}"</div>
                                    <div class="stream-text" style="font-size:14px;color:var(--text-color);line-height:1.5;${isCoder ? 'font-family:Courier New,monospace;background:#010409;padding:8px;border-radius:4px;white-space:pre-wrap;font-size:12px;color:#3fb950' : ''}"></div>
                                    <div style="margin-top:6px;font-size:11px;color:#d29922">
                                        <span class="spinner" style="display:inline-block;animation:spin 1s linear infinite">&#9696;</span> Generating...
                                    </div>`;
                                if (isCoder) {
                                    targetBox.insertBefore(streamEl, targetBox.firstChild);
                                } else {
                                    targetBox.appendChild(streamEl);
                                }
                            }
                            const textEl = streamEl.querySelector('.stream-text');
                            const counterEl = streamEl.querySelector('.stream-counter');
                            if (textEl) textEl.textContent += data.token || '';
                            const tokCount = (textEl.textContent || '').split('').length;
                            if (counterEl) counterEl.textContent = tokCount + ' tok';
                            targetBox.scrollTop = targetBox.scrollHeight;
                        }
                    }
                } else if (data.type === "task_routed") {
                    const isQueued = data.status === 'queued';
                    const color = isQueued ? '#d29922' : '#8b949e';
                    const icon = isQueued ? '⏳' : '→';
                    const msg = esc(data.message || '');

                    // Päivitetään olemassaoleva status-rivi (kpnRun luo sen)
                    const statusDiv = document.getElementById('status-' + data.task_id);
                    if (statusDiv) {
                        statusDiv.innerHTML = `  <span style="color:${color}">${icon} ${msg}${isQueued ? '' : ' <span style="animation:blink 1s infinite">▌</span>'}</span>`;
                        termPanel.scrollTop = termPanel.scrollHeight;
                    }

                    // Codelab-loading-teksti
                    const codeLoading = document.getElementById('code-loading');
                    if (codeLoading && codeLoading.style.display !== 'none') {
                        codeLoading.textContent = isQueued
                            ? `⏳ ${msg}`
                            : `→ ${msg} — generoidaan...`;
                    }
                } else if (data.type === "llm_prompt") {
                    // Reagoidaan VAIN agents-pipelinen tehtäviin (task_id + activeStreams)
                    if (data.task_id && activeStreams[data.task_id]) {
                        const term = document.getElementById('agent-terminal');
                        if (term) {
                            const model = data.model || 'llm';
                            const promptShort = esc(stripSystemPrompt(data.prompt)).substring(0, 50);
                            const div = document.createElement('div');
                            div.className = 'terminal-line';
                            div.innerHTML = `<span class="terminal-prompt">$</span> kpn run ${model} <span style="color:#8b949e">"${promptShort}"</span>`;
                            term.appendChild(div);
                            while (term.children.length > 50 && !term.firstChild.querySelector('.stream-content')) term.removeChild(term.firstChild);
                            term.scrollTop = term.scrollHeight;
                        }

                        // Avatar-aktivointi hoidetaan pipelineStep()-funktiossa
                    }
                }
            } catch(e) {}
        };
        window._wsMessageHandler = _wsHandler;
        if (uiSocket) uiSocket.onmessage = _wsHandler;

        btn.addEventListener('click', async () => {
            // Käytetään viewer-authissa jo tunnistettua WebGPU-tilaa
            let hasWebGPU = detectedWebGPU;
            const deviceInfo = {
                allocated_gb: 4,
                cpu_cores: navigator.hardwareConcurrency || 0,
                device_memory_gb: navigator.deviceMemory || 0,
                platform: navigator.platform || "",
                gpu: detectedGpuInfo,
                selected_task: selectedTask
            };

            const gpuStr = hasWebGPU ? (deviceInfo.gpu?.description || deviceInfo.gpu?.vendor || "WebGPU") : "ei GPU:ta";
            // Laskenta käyttää aina CPU:ta (Candle), WebGPU on vain tensorilaskennassa (Burn)
            const computeBackend = (selectedTask === 'tokenize')
                ? (hasWebGPU ? "WebGPU + CPU" : "CPU")
                : "CPU (Candle Wasm)";
            const vramStr = deviceInfo.gpu?.estimated_vram_gb ? `~${deviceInfo.gpu.estimated_vram_gb} GB` : "?";

            const ramNote = deviceInfo.device_memory_gb >= 8 ? "8+ GB (selaimen raja)" : `~${deviceInfo.device_memory_gb} GB`;

            // Näytetään laitetiedot paneelissa
            const diPanel = document.getElementById('device-info');
            diPanel.style.display = 'block';
            diPanel.innerHTML = [
                `Laskenta: <span>${computeBackend}</span>`,
                hasWebGPU ? `GPU: <span>${gpuStr}</span>` : `GPU: <span style="color:#f85149">ei WebGPU:ta</span>`,
                hasWebGPU ? `VRAM: <span>${vramStr}</span>` : null,
                `CPU: <span>${deviceInfo.cpu_cores} ydintä</span>`,
                `RAM: <span>${ramNote}</span>`,
                `Varaus: <span>${deviceInfo.allocated_gb} GB</span>`
            ].filter(Boolean).join(' &middot; ');

            // Yhteensopivuusbanneri
            const banner = document.getElementById('compat-banner');
            banner.style.display = 'block';

            if (hasWebGPU) {
                banner.className = 'compat-banner gpu';
                banner.innerHTML = `WebGPU tunnistettu — ${gpuStr}. Tokenisaatio käyttää GPU:ta, LLM-inferenssi CPU:ta (Candle Wasm).`;
            } else {
                // Tunnistetaan selain ohjeen personointia varten
                const ua = navigator.userAgent;
                const isFirefox = ua.includes('Firefox');
                const isChrome = ua.includes('Chrome') && !ua.includes('Edg');
                const isBrave = ua.includes('Brave') || (navigator.brave && navigator.brave.isBrave);
                const isSafari = ua.includes('Safari') && !ua.includes('Chrome');
                const isLinux = ua.includes('Linux');

                let browserTip = '';
                if (isFirefox) {
                    browserTip = `
                        <p><strong>Firefox</strong> ei tue WebGPU:ta oletuksena.</p>
                        <p>Ota käyttöön: <code>about:config</code> → <code>dom.webgpu.enabled</code> = <code>true</code> → käynnistä uudelleen.</p>
                        <p>Tai vaihda Chromeen/Braveen — niissä WebGPU toimii oletuksena.</p>`;
                } else if ((isChrome || isBrave) && isLinux) {
                    const browser = isBrave ? 'brave-browser' : 'google-chrome';
                    browserTip = `
                        <p><strong>${isBrave ? 'Brave' : 'Chrome'} + Linux</strong>: GPU-ajuri ei ehkä tarjoa WebGPU:ta Wayland-ympäristössä.</p>
                        <p>Kokeile käynnistää selain komentoriviltä:</p>
                        <code>${browser} --enable-unsafe-webgpu --enable-features=Vulkan --ignore-gpu-blocklist --use-angle=vulkan --ozone-platform=x11</code>`;
                } else if (isSafari) {
                    browserTip = `
                        <p><strong>Safari</strong>: WebGPU on tuettu versiosta 26 alkaen (macOS Tahoe).</p>
                        <p>Vanhemmissa versioissa: Develop → Feature Flags → WebGPU.</p>`;
                } else {
                    browserTip = `
                        <p>Selaimesi ei tue WebGPU:ta. Kokeile <strong>Chrome 113+</strong> tai <strong>Brave</strong>.</p>`;
                }

                banner.className = 'compat-banner cpu';
                banner.innerHTML = `
                    <details>
                        <summary>CPU-laskenta (WebGPU ei käytettävissä) — klikkaa ohjeita</summary>
                        ${browserTip}
                        <p style="margin-top:8px;color:#8b949e;font-size:12px">Laskenta toimii silti CPU:lla, mutta GPU-kiihdytys olisi nopeampi.</p>
                    </details>`;
            }

            document.getElementById('initial-state').classList.add('hidden');
            document.getElementById('active-state').classList.remove('hidden');
            document.getElementById('user-input-box').classList.remove('hidden');
            btn.style.display = 'none';

            // Nappin teksti ja placeholder tehtävän mukaan
            const sendBtnEl = document.getElementById('send-btn');
            const placeholderEl = document.getElementById('user-text');
            const t = window.currentLangDict || translations.fi;
            
            if (selectedTask === 'tokenize') {
                sendBtnEl.textContent = t.btn_tokenize || 'Tokenisoi';
            } else if (selectedTask === 'qwen-coder') {
                sendBtnEl.textContent = 'Koodaa';
            } else {
                sendBtnEl.textContent = 'Generoi';
            }

            try {
                if (!wasmInitialized) {
                    console.log("Ladataan Burn Wasm -binääriä...");
                    await init();
                    wasmInitialized = true;
                }
                window.wasm_active = true;
                metrics.startTime = Date.now();

                // Asetetaan Connected-tila (keltainen) — vihreäksi vasta kun laskentaa tapahtuu
                const nodeStatusEl = document.getElementById('node-status');
                nodeStatusEl.textContent = 'Connected';
                nodeStatusEl.style.color = '#d29922';
                
                // Varmistetaan, että Wasm saa nykyisen sliderin arvon heti kärkeen
                set_gpu_load(parseInt(loadSlider.value));
                
                // WebAssembly yhdistää oikeaksi Agent Nodeksi
                const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
                const taskIds = {'tokenize': 0, 'smollm-135m': 1, 'qwen-05b': 2, 'phi3-mini': 3, 'qwen-coder-05b': 4, 'qwen-coder-3b': 5};
                const taskId = taskIds[selectedTask] || 0;
                await start_agent_node(wsUrl, hasWebGPU, JSON.stringify(deviceInfo), taskId);
            } catch(e) {
                console.log("Virhe GPU-käynnistyksessä: " + e);
            }
        });

        // === Koodilaboratorio ===
        const codeInput = document.getElementById('code-input');
        const codeSendBtn = document.getElementById('code-send-btn');
        const codeResults = document.getElementById('code-results');
        const codeLoading = document.getElementById('code-loading');
        let coderWsReady = false;
        let coderWs = null; // Erillinen WS coder-nodelle
        let pendingCodePrompt = null;

        // Yksinkertainen Python-syntaksikorostus
        function highlightCode(code) {
            if (typeof hljs !== 'undefined') {
                try {
                    const result = hljs.highlightAuto(code);
                    return result.value;
                } catch(e) {}
            }
            return esc(code);
        }

        function addCodeResult(data) {
            // Poistetaan streaming-kortti
            codeResults.querySelector('.streaming-card')?.remove();

            const model = data.model || 'Coder';
            const tokGen = data.tokens_generated || 0;
            const durMs = data.duration_ms || 0;
            const tokS = data.tokens_per_sec || 0;
            const response = esc(data.response);

            codeMetrics.tasks++;
            codeMetrics.tokens += tokGen;
            codeMetrics.lastSpeed = tokS;
            document.getElementById('code-m-tasks').textContent = codeMetrics.tasks;
            document.getElementById('code-m-tokens').textContent = codeMetrics.tokens.toLocaleString('fi-FI');
            document.getElementById('code-m-speed').textContent = tokS + ' tok/s';

            if (codeResults.querySelector('[data-placeholder]')) {
                codeResults.innerHTML = '';
            }
            codeLoading.style.display = 'none';
            codeSendBtn.disabled = false;
            codeSendBtn.textContent = 'Generate';
            document.getElementById('coder-status').textContent = 'Connected';
            document.getElementById('coder-status').style.color = '#d29922';

            const card = document.createElement('div');
            card.className = 'code-task-card';
            card.innerHTML = `
                <div class="prompt">${esc(stripSystemPrompt(data.prompt))}</div>
                <div class="code-output">${highlightCode(response)}</div>
                <div class="meta">
                    ${model} · ${tokGen} tokenia · ${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms · ${tokS} tok/s
                </div>`;
            codeResults.insertBefore(card, codeResults.firstChild);
            if (codeResults.children.length > 10) codeResults.removeChild(codeResults.lastChild);
        }

        // Kuuntele coder-tuloksia UI WebSocketista (vain codelab-tehtävät)
        uiSocket.addEventListener('message', (event) => {
            try {
                const data = JSON.parse(event.data);
                if (data.type === 'llm_done' && (data.model || '').includes('Coder')) {
                    // Agents-pipeline asettaa aina task_id:n, codelabin user_text-polku ei koskaan
                    if (data.task_id) return;
                    addCodeResult(data);
                }
            } catch(e) {}
        });

        // Pipeline-vaiheiden päivitys
        function setStep(id, state, extra) {
            const el = document.getElementById(id);
            if (!el) return;
            el.className = 'code-step ' + state;
            const icon = el.querySelector('.step-icon');
            if (state === 'active') icon.textContent = '\u25F7'; // spinning
            else if (state === 'done') icon.textContent = '\u2713';
            else if (state === 'error') icon.textContent = '\u2717';
            if (extra) {
                const pct = document.getElementById(id + '-pct');
                if (pct) pct.textContent = extra;
            }
        }

        // Kuuntele console.log-viestejä pipeline-vaiheiden seuraamiseksi
        // Terminaalin lataustilarivi — päivittyy dynaamisesti
        function termLoadStatus(phase, detail) {
            const term = document.getElementById('agent-terminal');
            if (!term) return;
            let statusLine = term.querySelector('.term-load-status');
            if (!statusLine) {
                statusLine = document.createElement('div');
                statusLine.className = 'terminal-line term-load-status';
                term.appendChild(statusLine);
            }
            const spinner = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
            const frame = spinner[Math.floor(Date.now() / 100) % spinner.length];
            statusLine.innerHTML = `  <span style="color:#d29922">${frame}</span> <span style="color:#8b949e">${phase}</span>${detail ? ` <span style="color:#58a6ff">${detail}</span>` : ''}`;
            term.scrollTop = term.scrollHeight;
        }
        function termLoadDone() {
            const term = document.getElementById('agent-terminal');
            if (!term) return;
            const statusLine = term.querySelector('.term-load-status');
            if (statusLine) statusLine.remove();
        }

        const origCodeLog = console.log;
        const codeLogListener = (...args) => {
            const msg = args.join(' ');
            if (msg.includes('[Coder]') || msg.includes('[Storage]') || msg.includes('Burn Wasm') || msg.includes('Kipinä Agent Node')) {
                // Terminaalin lataustilapäivitys
                if (msg.includes('Agent Node käynnistyy')) termLoadStatus('WASM alustettu');
                if (msg.includes('Ladataan') && msg.includes('tokenizer')) termLoadStatus('Ladataan tokenizer...');
                if (msg.includes('tokenizer') && (msg.includes('löytyi') || msg.includes('tallennettu'))) termLoadStatus('Tokenizer ✓');
                if (msg.includes('Ladataan') && msg.includes('gguf')) termLoadStatus('Ladataan mallia...');
                const dlMatch = msg.match(/lataus: (\d+)%/);
                if (dlMatch) termLoadStatus('Ladataan mallia...', dlMatch[1] + '%');
                if (msg.includes('tallennettu') && msg.includes('gguf')) termLoadStatus('Malli tallennettu');
                if (msg.includes('Rakennetaan')) termLoadStatus('Rakennetaan mallia...');
                if (msg.includes('Malli ladattu')) termLoadDone();

                if (msg.includes('Burn Wasm')) setStep('step-wasm', 'active');
                if (msg.includes('Agent Node käynnistyy')) { setStep('step-wasm', 'done'); }
                // Tokenizer: [Coder] tai [Storage] -prefiksi
                if (msg.includes('Tokenizer') && msg.includes('löytyi')) { setStep('step-tokenizer', 'done'); }
                if (msg.includes('tokenizer') && msg.includes('löytyi')) { setStep('step-tokenizer', 'done'); }
                if ((msg.includes('[Coder]') || msg.includes('[Storage]')) && msg.includes('Ladataan') && msg.includes('tokenizer')) { setStep('step-tokenizer', 'active'); }
                if ((msg.includes('[Coder]') || msg.includes('[Storage]')) && msg.includes('tokenizer') && msg.includes('tallennettu')) { setStep('step-tokenizer', 'done'); }
                if (msg.includes('[Coder]') && msg.includes('model') && msg.includes('lataus:')) {
                    setStep('step-model', 'active');
                    const match = msg.match(/lataus: (\d+)%/);
                    if (match) setStep('step-model', 'active', match[1] + '%');
                }
                if (msg.includes('[Coder]') && msg.includes('model') && msg.includes('löytyi')) { setStep('step-model', 'done', 'cache'); }
                if (msg.includes('[Coder]') && msg.includes('model') && msg.includes('tallennettu')) { setStep('step-model', 'done', '100%'); }
                if (msg.includes('[Coder]') && msg.includes('Rakennetaan')) { setStep('step-build', 'active'); }
                if (msg.includes('Agent Node käynnistyy') || msg.includes('Rakennetaan')) {
                    const cd = document.getElementById('agent-compute-dot');
                    const cl = document.getElementById('agent-compute-label');
                    const btn = document.getElementById('agent-compute-btn');
                    if (cd) cd.style.background = '#d29922';
                    if (cl) { cl.textContent = 'Ladataan...'; cl.style.color = '#d29922'; }
                    if (btn && btn.dataset.state !== 'ready') {
                        btn.dataset.state = 'loading';
                        btn.textContent = 'Peruuta';
                        btn.style.borderColor = '#f85149';
                        btn.style.color = '#f85149';
                    }
                }
                if (msg.includes('[Coder]') && msg.includes('Malli ladattu')) {
                    // Malli on valmis — merkataan kaikki vaiheet valmiiksi
                    setStep('step-wasm', 'done');
                    setStep('step-tokenizer', 'done');

                    const pctSpan = document.getElementById('step-model-pct');
                    if (pctSpan && pctSpan.textContent.includes('100%')) {
                        setStep('step-model', 'done', '100%');
                    } else {
                        setStep('step-model', 'done', 'cache');
                    }

                    setStep('step-build', 'done');
                    setStep('step-ready', 'done');

                    // Agents-sivun compute-status: valmis
                    const cd = document.getElementById('agent-compute-dot');
                    const cl = document.getElementById('agent-compute-label');
                    const btn = document.getElementById('agent-compute-btn');
                    if (cd) cd.style.background = '#3fb950';
                    const sizeLabel = coderSize === '3b' ? '3B (3 miljardia parametria)' : '0.5B (500 miljoonaa parametria)';
                    if (cl) { cl.textContent = 'Qwen2.5-Coder:' + (coderSize === '3b' ? '3B' : '0.5B'); cl.style.color = '#3fb950'; cl.title = sizeLabel + ' · Candle Wasm · CPU · max 512 tok'; }
                    if (btn) { btn.dataset.state = 'ready'; btn.textContent = '✓ Valmis'; btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950'; btn.style.cursor = 'default'; btn.title = 'Kielimalli ladattu — oma kone on valmis laskentaan'; }
                    localStorage.setItem('kpn-coder-loaded', 'true');
                    // Terminaaliin valmis-viesti (vain kerran)
                    if (!window._coderReadyLogged) {
                        window._coderReadyLogged = true;
                        const term = document.getElementById('agent-terminal');
                        if (term) {
                            const sLabel = coderSize === '3b' ? 'Qwen2.5-Coder:1.5B Q4' : 'Qwen2.5-Coder:0.5B';
                            termLog(`  <span style="color:#3fb950">✓</span> ${sLabel} valmis — kpn run coder "prompti"`, '#3fb950');
                        }
                    }
                }
                if (msg.includes('[Coder]') && msg.includes('Syöte:')) {
                    // Pipeline piiloon kun generointi alkaa
                    setTimeout(() => { document.getElementById('code-pipeline').style.display = 'none'; }, 1000);
                }
            }
        };
        // Lisätään kuuntelija alkuperäisen console.log ylikirjoituksen päälle
        const _prevConsoleLog = console.log;
        console.log = function(...args) { _prevConsoleLog.apply(console, args); codeLogListener(...args); };

        // Web Worker -pohjainen laskentasolmu — UI ei jäädy inferenssin aikana
        let coderWorker = null;

        async function ensureCoderNode() {
            if (coderJoined) return;
            coderJoined = true;
            document.getElementById('coder-status').textContent = 'Käynnistyy...';
            document.getElementById('coder-status').style.color = '#d29922';
            document.getElementById('code-pipeline').style.display = 'block';
            setStep('step-wasm', 'active');

            try {
                // Käynnistetään WASM Web Workerissa
                console.log('[ensureCoderNode] Luodaan Worker...');
                coderWorker = new Worker('./worker.js', { type: 'module' });

                coderWorker.onerror = (e) => {
                    console.error('[Worker onerror]', e.message, e.filename, e.lineno);
                    const term = document.getElementById('agent-terminal');
                    if (term) {
                        const div = document.createElement('div');
                        div.className = 'terminal-line';
                        div.style.color = '#f85149';
                        div.textContent = '  ✗ Worker-virhe: ' + (e.message || 'tuntematon');
                        term.appendChild(div);
                    }
                };

                console.log('[ensureCoderNode] Odotetaan WASM init...');
                await new Promise((resolve, reject) => {
                    coderWorker.onmessage = (e) => {
                        console.log('[ensureCoderNode] Worker viesti:', e.data.type, e.data.message || '');
                        if (e.data.type === 'ready') resolve();
                        else if (e.data.type === 'error') reject(new Error(e.data.message));
                    };
                    coderWorker.postMessage({ type: 'init' });
                });

                setStep('step-wasm', 'done');
                setStep('step-tokenizer', 'active');

                const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
                const deviceInfo = {
                    allocated_gb: 4,
                    cpu_cores: navigator.hardwareConcurrency || 0,
                    device_memory_gb: navigator.deviceMemory || 0,
                    platform: navigator.platform || "",
                    gpu: null,
                    selected_task: coderSize === '3b' ? 'qwen-coder-3b' : 'qwen-coder-05b'
                };
                const taskId = coderSize === '3b' ? 5 : 4;

                // Käynnistetään node Workerissa — odotetaan WS-yhteyden avautumista
                console.log('[ensureCoderNode] Käynnistetään node Workerissa...');
                const workerStarted = new Promise((resolve) => {
                    coderWorker.onmessage = (e) => {
                        console.log('[ensureCoderNode] Worker viesti:', e.data.type, e.data.message || '');
                        if (e.data.type === 'started') {
                            document.getElementById('coder-status').textContent = 'Connected';
                            document.getElementById('coder-status').style.color = '#d29922';
                            coderWsReady = true;
                            resolve();
                        } else if (e.data.type === 'log') {
                            console.log(e.data.message);
                        } else if (e.data.type === 'error') {
                            console.error('[Worker] ' + e.data.message);
                            resolve(); // Resolvataan silti jotta ei jäädä jumiin
                        }
                    };
                });

                coderWorker.postMessage({
                    type: 'start',
                    data: { hubUrl: wsUrl, hasWebGPU: false, deviceInfo: JSON.stringify(deviceInfo), taskId }
                });

                // Odotetaan kunnes Worker on yhdistänyt hubiin
                await workerStarted;

                if (pendingCodePrompt) {
                    setTimeout(() => {
                        sendCodeToHub(pendingCodePrompt);
                    }, 500);
                    pendingCodePrompt = null;
                }
            } catch(e) {
                console.log("Coder-virhe: " + e);
                document.getElementById('coder-status').textContent = 'Virhe';
                document.getElementById('coder-status').style.color = '#f85149';
                coderJoined = false;
            }
        }

        // Automaattinen uudelleenkäynnistys: jos malli oli ladattu ennen refreshiä
        if (localStorage.getItem('kpn-coder-loaded') === 'true') {
            setTimeout(() => ensureCoderNode(), 500);
        }

        // Laskentasolmun käynnistys/pysäytys -nappi
        let computeAbortController = null;
        document.getElementById('agent-compute-btn')?.addEventListener('click', () => {
            const btn = document.getElementById('agent-compute-btn');
            const cl = document.getElementById('agent-compute-label');
            if (!btn) return;

            if (btn.dataset.state === 'ready') return; // Jo valmis, ei tehdä mitään

            if (btn.dataset.state === 'loading') {
                // Cancel — ladataan sivua uudelleen koska Wasm-latausta ei voi pysäyttää
                btn.textContent = 'Peruutetaan...';
                btn.disabled = true;
                window.location.reload();
                return;
            }

            // Käynnistetään
            btn.dataset.state = 'loading';
            btn.textContent = 'Peruuta';
            btn.style.borderColor = '#f85149';
            btn.style.color = '#f85149';
            btn.title = 'Peruuta kielimallin lataus';
            ensureCoderNode();
        });

        // JSON mode toggle
        const jsonToggle = document.getElementById('json-mode-toggle');
        const jsonHelp = document.getElementById('json-help');
        const textInput = document.getElementById('code-input');
        const jsonInput = document.getElementById('code-input-json');

        jsonToggle?.addEventListener('change', () => {
            if (jsonToggle.checked) {
                textInput.style.display = 'none';
                jsonInput.style.display = 'block';
                jsonHelp.style.display = 'block';
            } else {
                textInput.style.display = 'block';
                jsonInput.style.display = 'none';
                jsonHelp.style.display = 'none';
            }
        });

        function sendCodeToHub(text) {
            if (uiSocket && uiSocket.readyState === 1) {
                uiSocket.send(JSON.stringify({ type: 'user_text', text: text, task_type: 'qwen-coder' }));
            }
        }

        async function handleCodeSubmit() {
            let promptText;

            if (jsonToggle.checked) {
                // JSON mode
                const raw = jsonInput.value.trim();
                if (!raw) return;
                try {
                    const parsed = JSON.parse(raw);
                    if (!parsed.prompt) { alert('JSON must contain "prompt" field'); return; }
                    // Lähetetään koko JSON hubille — node lukee promptin ja parametrit
                    promptText = raw;
                } catch(e) {
                    alert('Invalid JSON: ' + e.message);
                    return;
                }
            } else {
                // Text mode
                promptText = textInput.value.trim();
                if (!promptText) return;
                textInput.value = '';
            }

            codeSendBtn.disabled = true;
            codeSendBtn.textContent = 'Generating...';
            codeLoading.style.display = 'block';

            if (!coderJoined) {
                pendingCodePrompt = promptText;
                const dlSize = coderSize === '3b' ? '~6.2 GB' : '~990 MB';
                codeLoading.textContent = `Loading Qwen2.5-Coder:${coderSize === '3b' ? '3B' : '0.5B'} (${dlSize} on first run)...`;
                await ensureCoderNode();
            } else {
                codeLoading.textContent = 'Generating code...';
                document.getElementById('coder-status').textContent = 'Computing';
                document.getElementById('coder-status').style.color = 'var(--success-color)';
                sendCodeToHub(promptText);
            }
        }

        codeSendBtn?.addEventListener('click', handleCodeSubmit);
        textInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleCodeSubmit(); });

        const translations = {
            fi: {
                main_title: "<span style=\"color:#ff6b00\">Kipinä</span> <span>Agentic Playground</span>",
                main_subtitle: "AI-ohjelmistokehitystiimi",
                tab_network: "Laskentaverkko",
                tab_codelab: "Koodilaboratorio",
                tab_agents: "Kipinä Agentic Playground",
                stat_nodes_lbl: "Aktiivisia Nodeja",
                stat_tasks_lbl: "Verkossa Suoritettua Tehtävää (Globaali)",
                stat_vram_lbl: "Verkon yhteis-VRAM",
                btn_select_all: "Valitse kaikki",
                btn_clear_all: "Tyhjennä valinnat",
                task_title: "Valitse tehtävä",
                btn_join: "Liity laskentaverkkoon",
                btn_disconnect: "Katkaise Yhteys",
                resource_mgmt: "Resurssien hallinta",
                power_limiter: "Laskentatehon rajoitin",
                auto_tasks: "Vastaanota automaattisia tehtäviä hubilta",
                try_own_text: "Kokeile omaa tekstiä:",
                btn_tokenize: "Tokenisoi",
                btn_code: "Koodaa",
                btn_generate: "Generoi",
                metric_tasks: "Tehtäviä",
                metric_avg: "Ka. aika",
                metric_tokens: "Tokeneita",
                metric_uptime: "Käynnissä"
            },
            se: {
                main_title: "<span style=\"color:#ff6b00\">Kipinä</span> <span>Agentic Playground</span>",
                main_subtitle: "AI-programvaruutvecklingsteam",
                tab_network: "Kalkylnätverk",
                tab_codelab: "Kodlaboratorium",
                tab_agents: "Kipinä Agentic Playground",
                stat_nodes_lbl: "Aktiva Noder",
                stat_tasks_lbl: "Slutförda Uppgifter (Globalt)",
                stat_vram_lbl: "Nätverkets totala VRAM",
                btn_select_all: "Välj alla",
                btn_clear_all: "Rensa val",
                task_title: "Välj uppgift",
                btn_join: "Gå med i nätverket",
                btn_disconnect: "Koppla från",
                resource_mgmt: "Resurshantering",
                power_limiter: "Beräkningskraftsbegränsare",
                auto_tasks: "Ta emot automatiska uppgifter från hubben",
                try_own_text: "Prova med egen text:",
                btn_tokenize: "Tokenisera",
                btn_code: "Koda",
                btn_generate: "Generera",
                metric_tasks: "Uppgifter",
                metric_avg: "Snittid",
                metric_tokens: "Tokens",
                metric_uptime: "Drifttid"
            },
            en: {
                main_title: "<span style=\"color:#ff6b00\">Kipinä</span> <span>Agentic Playground</span>",
                main_subtitle: "AI Software Development Team",
                tab_network: "Compute Network",
                tab_codelab: "Code Laboratory",
                tab_agents: "Kipinä Agentic Playground",
                stat_nodes_lbl: "Active Nodes",
                stat_tasks_lbl: "Tasks Completed (Global)",
                stat_vram_lbl: "Total Network VRAM",
                btn_select_all: "Select all",
                btn_clear_all: "Clear selection",
                task_title: "Choose task",
                btn_join: "Join Compute Network",
                btn_disconnect: "Disconnect",
                resource_mgmt: "Resource Management",
                power_limiter: "Compute Power Limiter",
                auto_tasks: "Receive automatic tasks from hub",
                try_own_text: "Test your own text:",
                btn_tokenize: "Tokenize",
                btn_code: "Code",
                btn_generate: "Generate",
                metric_tasks: "Tasks",
                metric_avg: "Avg. Time",
                metric_tokens: "Tokens",
                metric_uptime: "Uptime"
            }
        };

        window.setLanguage = function(lang) {
            localStorage.setItem('kpn_lang', lang);
            document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
            const btn = document.querySelector(`.lang-btn[data-lang="${lang}"]`);
            if (btn) btn.classList.add('active');
            
            const t = translations[lang] || translations.fi;
            window.currentLangDict = t;
            
            document.querySelectorAll('[data-i18n]').forEach(el => {
                const key = el.getAttribute('data-i18n');
                if (t[key]) {
                    if(t[key].includes('<')) el.innerHTML = t[key];
                    else el.textContent = t[key];
                }
            });
            
            if(window.updatePromptEditor) window.updatePromptEditor();
            
            // Käännä lennossa ne painikkeet jotka ovat ehkä vaihtaneet tekstiä dynaamisesti (esim. JS-tilan muutokset)
            const sendBtnEl = document.getElementById('send-btn');
            if (sendBtnEl && window.wasm_active) {
                // Riippuu valitusta tehtävästä
                const sTask = window.selectedTask || document.querySelector('.task-option.selected')?.dataset?.task;
                if (sTask === 'tokenize') sendBtnEl.textContent = t.btn_tokenize || 'Tokenisoi';
                else if (sTask === 'qwen-coder') sendBtnEl.textContent = t.btn_code || 'Koodaa';
                else sendBtnEl.textContent = t.btn_generate || 'Generoi';
            }
            
            const jbtn = document.getElementById('start-btn');
            if (jbtn) {
                // start-btn vaihtuu connect / disconnect kun ollaan aktiivitilassa
                if (window.wasm_active || jbtn.textContent === 'Katkaise Yhteys' || jbtn.textContent === 'Koppla från' || jbtn.textContent === 'Disconnect') {
                    jbtn.textContent = t.btn_disconnect || 'Katkaise Yhteys';
                } else {
                    jbtn.textContent = t.btn_join || 'Liity laskentaverkkoon';
                }
            }
            
            const cbtn = document.getElementById('code-send-btn');
            if (cbtn && !cbtn.textContent.includes('...')) {
                cbtn.textContent = t.btn_generate || 'Generate';
            }
        };

        document.addEventListener('DOMContentLoaded', () => {
            const savedLang = localStorage.getItem('kpn_lang') || 'fi';
            setLanguage(savedLang);
            
            // Valitaan Asiakas-agentti automaattisesti sivun ladattua (muttei jatkossa)
            setTimeout(() => {
                if (window.selectAgent) window.selectAgent('client');
            }, 100);
        });

        // ── Agent Builder ──

        const BUILDER_AVATARS = [
            '/avatars/kipina_notext.png', '/avatars/karhunpentu.png', '/avatars/kettu_notext.png',
            '/avatars/pesukarhu_notext.png', '/avatars/susi_notext.png', '/avatars/laiskiainen_notext.png',
            '/avatars/aikuinen_susi.png', '/avatars/gecko_notext.png',
            '/avatars/bear.png', '/avatars/beaver.png', '/avatars/chameleon.png',
            '/avatars/elephant.png', '/avatars/gecko.png', '/avatars/lion.png',
            '/avatars/mantis.png', '/avatars/owl.png', '/avatars/penguin.png',
            '/avatars/serpent.png', '/avatars/spider.png', '/avatars/tortoise.png',
            '/avatars/walrus.png'
        ];

        let builderAgents = [];
        let builderEditing = null; // null = uusi, string = id

        async function builderLoad() {
            try {
                const res = await fetch('/api/v1/agents');
                if (res.ok) builderAgents = await res.json();
            } catch(e) {}
            builderRenderList();
        }

        function builderRenderList() {
            const list = document.getElementById('builder-agent-list');
            if (!list) return;
            list.innerHTML = builderAgents.map(a => `
                <div onclick="builderEdit('${a.id}')" style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:12px;cursor:pointer;transition:border-color 0.2s;display:flex;align-items:center;gap:10px" onmouseover="this.style.borderColor='${a.color}'" onmouseout="this.style.borderColor='#30363d'">
                    <img src="${a.avatar}" style="width:40px;height:40px;border-radius:10px;border:2px solid ${a.color};object-fit:cover">
                    <div>
                        <div style="font-weight:600;color:${a.color};font-size:14px">${esc(a.name)}</div>
                        <div style="color:#8b949e;font-size:11px">${esc(a.model)} · ${a.role}</div>
                    </div>
                </div>
            `).join('');
        }

        function builderNew() {
            builderEditing = null;
            document.getElementById('builder-form').style.display = 'block';
            document.getElementById('builder-new-btn').style.display = 'none';
            document.getElementById('builder-delete-btn').style.display = 'none';
            document.getElementById('builder-id').value = '';
            document.getElementById('builder-id').disabled = false;
            document.getElementById('builder-name').value = '';
            document.getElementById('builder-role').value = 'coder';
            document.getElementById('builder-model').value = 'qwen2.5-coder:7b';
            document.getElementById('builder-color').value = '#3fb950';
            document.getElementById('builder-docs').value = '';
            document.getElementById('builder-prompt').value = '';
            document.getElementById('builder-temp').value = '0.7';
            document.getElementById('builder-topk').value = '40';
            document.getElementById('builder-maxtokens').value = '512';
            document.getElementById('builder-avatar-preview').src = '/avatars/kipina_notext.png';
            builderRenderAvatarSelect();
        }

        function builderEdit(id) {
            const a = builderAgents.find(x => x.id === id);
            if (!a) return;
            builderEditing = id;
            document.getElementById('builder-form').style.display = 'block';
            document.getElementById('builder-new-btn').style.display = 'none';
            document.getElementById('builder-delete-btn').style.display = a.is_default ? 'none' : 'inline-block';
            document.getElementById('builder-id').value = a.id;
            document.getElementById('builder-id').disabled = true;
            document.getElementById('builder-name').value = a.name;
            document.getElementById('builder-role').value = a.role;
            document.getElementById('builder-model').value = a.model;
            document.getElementById('builder-color').value = a.color;
            document.getElementById('builder-docs').value = a.docs || '';
            document.getElementById('builder-prompt').value = a.prompt;
            document.getElementById('builder-temp').value = a.temperature;
            document.getElementById('builder-topk').value = a.top_k;
            document.getElementById('builder-maxtokens').value = a.max_tokens;
            document.getElementById('builder-avatar-preview').src = a.avatar;
            builderRenderAvatarSelect();
        }

        function builderRenderAvatarSelect() {
            const container = document.getElementById('builder-avatar-select');
            container.innerHTML = BUILDER_AVATARS.map(src =>
                `<img src="${src}" style="width:36px;height:36px;border-radius:8px;border:2px solid #30363d;cursor:pointer;object-fit:cover" onclick="document.getElementById('builder-avatar-preview').src='${src}';document.getElementById('builder-avatar-select').style.display='none'">`
            ).join('');
        }

        async function builderSave() {
            const payload = {
                id: document.getElementById('builder-id').value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''),
                name: document.getElementById('builder-name').value.trim(),
                avatar: document.getElementById('builder-avatar-preview').src.replace(location.origin, ''),
                role: document.getElementById('builder-role').value,
                model: document.getElementById('builder-model').value.trim(),
                color: document.getElementById('builder-color').value,
                docs: document.getElementById('builder-docs').value.trim() || null,
                prompt: document.getElementById('builder-prompt').value,
                temperature: parseFloat(document.getElementById('builder-temp').value) || 0.7,
                top_k: parseInt(document.getElementById('builder-topk').value) || 40,
                max_tokens: parseInt(document.getElementById('builder-maxtokens').value) || 512,
                repetition_penalty: 1.15,
            };
            if (!payload.id || !payload.name) { alert('Tunniste ja nimi vaaditaan'); return; }
            try {
                const res = await fetch('/api/v1/agents', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(payload),
                });
                if (res.ok) {
                    builderCancel();
                    await builderLoad();
                } else {
                    alert('Virhe: ' + await res.text());
                }
            } catch(e) { alert('Virhe: ' + e.message); }
        }

        async function builderDelete() {
            if (!builderEditing) return;
            if (!confirm('Poistetaanko agentti "' + builderEditing + '"?')) return;
            try {
                const res = await fetch('/api/v1/agents/' + builderEditing, { method: 'DELETE' });
                if (res.ok) {
                    builderCancel();
                    await builderLoad();
                } else {
                    alert('Virhe: ' + await res.text());
                }
            } catch(e) { alert('Virhe: ' + e.message); }
        }

        function builderCancel() {
            document.getElementById('builder-form').style.display = 'none';
            document.getElementById('builder-new-btn').style.display = 'inline-block';
            builderEditing = null;
        }

        function builderExportCrewAI() {
            if (builderAgents.length === 0) { alert('Ei agentteja — luo ensin agentteja.'); return; }

            // Rooli-kuvaukset CrewAI:lle
            const roleGoals = {
                coder: 'Write clean, tested, production-ready code',
                qa: 'Find bugs, write tests, ensure quality',
                devops: 'Create deployment configs, CI/CD, Docker',
                devsecops: 'Security auditing, OWASP compliance, vulnerability scanning',
                architect: 'System design, API contracts, architecture decisions',
                iac: 'Write infrastructure-as-code (OpenTofu/Terraform HCL)',
                data: 'Database design, SQL optimization, data modeling',
                manager: 'Break down tasks, coordinate team, prioritize work',
                writer: 'Write documentation, READMEs, guides',
                custom: 'Complete assigned tasks effectively',
            };

            // crew.py
            const agentDefs = builderAgents.map(a => {
                const varName = a.id.replace(/[^a-z0-9_]/g, '_');
                const goal = roleGoals[a.role] || roleGoals.custom;
                return `${varName} = Agent(
    role="${a.name}",
    goal="${goal}",
    backstory="""${a.prompt.replace(/"/g, '\\"')}""",
    llm="ollama/${a.model}",
    verbose=True,
)`;
            }).join('\n\n');

            const taskDefs = builderAgents.map((a, i) => {
                const varName = a.id.replace(/[^a-z0-9_]/g, '_');
                const goal = roleGoals[a.role] || roleGoals.custom;
                return `task_${varName} = Task(
    description="${goal} for the project: {project_description}",
    expected_output="Completed deliverable from ${a.name}",
    agent=${varName},
)`;
            }).join('\n\n');

            const agentVars = builderAgents.map(a => a.id.replace(/[^a-z0-9_]/g, '_'));
            const taskVars = builderAgents.map(a => 'task_' + a.id.replace(/[^a-z0-9_]/g, '_'));

            const crewPy = `"""
Kipinä Agent Crew — generoitu Agent Builderista
https://kipina.studio/#builder
"""
from crewai import Agent, Task, Crew, Process

# ── Agentit ──

${agentDefs}

# ── Tehtävät ──

${taskDefs}

# ── Tiimi ──

crew = Crew(
    agents=[${agentVars.join(', ')}],
    tasks=[${taskVars.join(', ')}],
    process=Process.sequential,
    verbose=True,
)

if __name__ == "__main__":
    import sys
    project = sys.argv[1] if len(sys.argv) > 1 else "FastAPI + SQLite CRUD API"
    result = crew.kickoff(inputs={"project_description": project})
    print(result)
`;

            // pyproject.toml
            const pyproject = `[project]
name = "kipina-crew"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "crewai[tools]>=0.100.0",
]

[project.scripts]
crew = "crew:crew.kickoff"
`;

            // .env
            const models = [...new Set(builderAgents.map(a => a.model))];
            const dotenv = `# Ollama-asetukset
OLLAMA_BASE_URL=http://ollama:11434
# Mallit jotka tarvitaan: ${models.join(', ')}
`;

            // Dockerfile
            const dockerfile = `FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml ./
RUN uv sync --no-dev
COPY crew.py .env ./
CMD ["uv", "run", "python", "crew.py"]
`;

            // docker-compose.yml
            const ollamaPulls = models.map(m => `ollama pull ${m}`).join(' && ');
            const compose = `services:
  ollama:
    image: ollama/ollama:latest
    container_name: kipina-crew-ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama-models:/root/.ollama
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:11434/api/version"]
      interval: 5s
      timeout: 3s
      retries: 10

  # Ladataan mallit Ollamaan ennen crewn käynnistystä
  ollama-init:
    image: ollama/ollama:latest
    depends_on:
      ollama:
        condition: service_healthy
    entrypoint: ["sh", "-c", "${ollamaPulls}"]
    environment:
      - OLLAMA_HOST=http://ollama:11434

  crew:
    build: .
    container_name: kipina-crew
    depends_on:
      ollama-init:
        condition: service_completed_successfully
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
    # Projektin kuvaus argumenttina:
    # docker compose run crew uv run python crew.py "FastAPI todo app"

volumes:
  ollama-models:
`;

            // README.md
            const readme = `# Kipinä Agent Crew

Generoitu [Kipinä Agent Builderista](https://kipina.studio/#builder).

## Agentit

${builderAgents.map(a => `- **${a.name}** — ${a.role} (${a.model})`).join('\\n')}

## Käynnistys

\`\`\`bash
# Docker Compose (suositeltu) — käynnistää Ollaman + lataa mallit + ajaa crewn
docker compose run crew uv run python crew.py "FastAPI + SQLite CRUD API"

# Tai lokaalisti (vaatii Ollaman käynnissä)
uv sync
uv run python crew.py "FastAPI + SQLite CRUD API"
\`\`\`
`;

            // Genero ZIP käyttäen samaa logiikkaa kuin downloadZip
            const files = {
                'crew.py': crewPy,
                'pyproject.toml': pyproject,
                '.env': dotenv,
                'Dockerfile': dockerfile,
                'docker-compose.yml': compose,
                'README.md': readme,
            };

            function crc32(bytes) {
                let crc = 0xFFFFFFFF;
                for (let i = 0; i < bytes.length; i++) {
                    crc ^= bytes[i];
                    for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
                }
                return (crc ^ 0xFFFFFFFF) >>> 0;
            }

            const entries = Object.entries(files);
            const parts = [];
            const centralDir = [];
            let offset = 0;

            for (const [name, content] of entries) {
                const nameBytes = new TextEncoder().encode(name);
                const contentBytes = new TextEncoder().encode(content || '');
                const crc = crc32(contentBytes);

                const header = new Uint8Array(30 + nameBytes.length);
                const hv = new DataView(header.buffer);
                hv.setUint32(0, 0x04034b50, true);
                hv.setUint16(4, 20, true);
                hv.setUint16(8, 0, true);
                hv.setUint32(14, crc, true);
                hv.setUint32(18, contentBytes.length, true);
                hv.setUint32(22, contentBytes.length, true);
                hv.setUint16(26, nameBytes.length, true);
                header.set(nameBytes, 30);
                parts.push(header, contentBytes);

                const cd = new Uint8Array(46 + nameBytes.length);
                const cv = new DataView(cd.buffer);
                cv.setUint32(0, 0x02014b50, true);
                cv.setUint16(4, 20, true);
                cv.setUint16(6, 20, true);
                cv.setUint16(10, 0, true);
                cv.setUint32(16, crc, true);
                cv.setUint32(20, contentBytes.length, true);
                cv.setUint32(24, contentBytes.length, true);
                cv.setUint16(28, nameBytes.length, true);
                cv.setUint32(42, offset, true);
                cd.set(nameBytes, 46);
                centralDir.push(cd);
                offset += header.length + contentBytes.length;
            }

            const cdOffset = offset;
            let cdSize = 0;
            for (const cd of centralDir) { parts.push(cd); cdSize += cd.length; }
            const eocd = new Uint8Array(22);
            const ev = new DataView(eocd.buffer);
            ev.setUint32(0, 0x06054b50, true);
            ev.setUint16(8, entries.length, true);
            ev.setUint16(10, entries.length, true);
            ev.setUint32(12, cdSize, true);
            ev.setUint32(16, cdOffset, true);
            parts.push(eocd);

            const blob = new Blob(parts, { type: 'application/zip' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'kipina-crew.zip';
            a.click();
            URL.revokeObjectURL(url);
        }

        // Globaalit Builder-funktiot (onclick tarvitsee)
        window.builderNew = builderNew;
        window.builderEdit = builderEdit;
        window.builderSave = builderSave;
        window.builderDelete = builderDelete;
        window.builderExportCrewAI = builderExportCrewAI;
        window.builderCancel = builderCancel;

        // ── Hahmogalleria ──
        (function renderGallery() {
            const ALL_AVATARS = [
                { file: 'kipina_notext.png', name: 'Salamanteri', role: 'Koodari', desc: 'Kipinä-tiimin sydän. Tulinen koodari joka liikkuu ketterästi koodikantojen halki ja sytyttää projektit käyntiin.' },
                { file: 'karhunpentu.png', name: 'Karhunpentu', role: 'Manageri', desc: 'Utelias ja päättäväinen tiiminvetäjä. Pilkkoo isot projektit hallittaviksi tehtäviksi ja pitää tiimin kasassa.' },
                { file: 'kettu_notext.png', name: 'Kettu', role: 'Asiakas', desc: 'Ovela tuoteomistaja joka tietää mitä haluaa. Määrittelee vaatimukset ja priorisoi ominaisuudet.' },
                { file: 'pesukarhu_notext.png', name: 'Pesukarhu', role: 'Data', desc: 'Näppäräsorminen tietokantaguru. Pesee datan puhtaaksi, suunnittelee skeemat ja optimoi kyselyt.' },
                { file: 'susi_notext.png', name: 'Pikkususi', role: 'QA', desc: 'Tarkkasilmäinen laadunvartija. Haistaa bugit kaukaa ja kirjoittaa testit jotka pitävät koodin kurissa.' },
                { file: 'laiskiainen_notext.png', name: 'Laiskiainen', role: 'DevOps', desc: 'Rauhallinen automaatioasiantuntija. Automatisoi kaiken ettei kenenkään tarvitse tehdä turhaa käsityötä.' },
                { file: 'aikuinen_susi.png', name: 'Aikuinen susi', role: 'Tarkkailija', desc: 'Kokenut valvoja joka näkee metsän puilta. Seuraa kokonaisuutta ja varoittaa riskeistä ennen kuin ne realisoituvat.' },
                { file: 'gecko_notext.png', name: 'Gecko', role: 'IaC / Infra', desc: 'Piirilevylisko joka kiipeää infrastruktuurin joka tasolle. OpenTofu, Terraform ja pilvi-arkkitehtuuri ovat sen reviiriä.' },
                { file: 'bear.png', name: 'Karhu', role: 'Arkkitehti', desc: 'Voimakas järjestelmäarkkitehti palvelinräkit selässään. Suunnittelee kantavat rakenteet joiden päälle koko projekti nojaa.' },
                { file: 'beaver.png', name: 'Majava', role: 'CI/CD', desc: 'Ahkera rakentaja CI/CD-työkalut käsissään. Rakentaa putkistot ja automaatiot jotka pitävät koodin virtaamassa tuotantoon.' },
                { file: 'chameleon.png', name: 'Kameleontti', role: 'UI/UX', desc: 'Värejä vaihtava muotoilija UI-elementtien ympäröimänä. Sopeutuu jokaiseen designjärjestelmään ja tekee käyttöliittymistä kauniita.' },
                { file: 'elephant.png', name: 'Norsu', role: 'Tietokannat', desc: 'Piirilevykuvioinen muistimestari joka ei unohda mitään. Tietokantojen skaalaus ja datan eheys ovat sen erikoisalaa.' },
                { file: 'gecko.png', name: 'Gecko v2', role: 'IaC / Infra', desc: 'Vaihtoehtoinen infrastruktuurilisko. Sama IaC-osaaminen, eri visuaalinen tyyli.' },
                { file: 'lion.png', name: 'Leijona', role: 'Tech Lead', desc: 'Majesteettinen tekninen johtaja joka inspiroi tiimiä. Tekee arkkitehtuuripäätökset ja varmistaa teknisen vision.' },
                { file: 'mantis.png', name: 'Rukoilijasirkka', role: 'Code Review', desc: 'Tarkka ja kärsivällinen koodiarvioija. Odottaa hiljaa ja iskee kun näkee bugin — yksikään virhe ei pääse läpi.' },
                { file: 'owl.png', name: 'Pöllö', role: 'Analyytikko', desc: 'Viisas yötyöläinen joka näkee pimeässäkin. Analysoi lokit, metriikat ja suorituskyvyn — löytää vastaukset datasta.' },
                { file: 'penguin.png', name: 'Pingviini', role: 'Linux / Backend', desc: 'Tyylikäs Linux-asiantuntija joka viihtyy kylmässä palvelinhuoneessa. Backend-kehitys ja järjestelmäohjelmointi.' },
                { file: 'serpent.png', name: 'Kyy', role: 'DevSecOps', desc: 'Valpas tietoturva-agentti piirilevykuvioinen keho. Löytää haavoittuvuudet, auditoi koodin ja varmistaa OWASP-yhteensopivuuden.' },
                { file: 'spider.png', name: 'Hämähäkki', role: 'Verkko / API', desc: 'Kutoo monimutkaisia verkkoja — API-rajapinnat, mikropalvelut ja integraatiot ovat sen verkkoa.' },
                { file: 'tortoise.png', name: 'Kilpikonna', role: 'Legacy / Migraatio', desc: 'Hidas mutta varma. Erikoistunut vanhojen järjestelmien modernisointiin ja turvallisiin migraatioihin.' },
                { file: 'walrus.png', name: 'Mursu', role: 'SRE / Ops', desc: 'Raskaan sarjan operaattori panssaroidussa haarniskassa. Pitää tuotannon pystyssä ja vastaa kun pagerduty piippaa.' },
            ];
            const grid = document.getElementById('gallery-grid');
            if (!grid) return;
            grid.innerHTML = ALL_AVATARS.map(a => {
                const path = '/avatars/' + a.file;
                return `<div style="background:#161b22;border:1px solid #30363d;border-radius:12px;padding:14px;text-align:center;cursor:pointer;transition:all 0.2s" onclick="navigator.clipboard.writeText('${path}');this.querySelector('.gallery-copied').style.opacity=1;setTimeout(()=>this.querySelector('.gallery-copied').style.opacity=0,1000)" onmouseover="this.style.borderColor='#58a6ff';this.style.transform='translateY(-2px)'" onmouseout="this.style.borderColor='#30363d';this.style.transform=''">
                    <img src="${path}" style="width:80px;height:80px;border-radius:14px;border:2px solid #30363d;object-fit:cover;margin-bottom:8px">
                    <div style="font-weight:600;font-size:13px;color:#f0f6fc">${a.name}</div>
                    ${a.role ? `<div style="font-size:11px;color:#58a6ff;margin-top:2px">${a.role}</div>` : ''}
                    <div style="font-size:11px;color:#8b949e;margin-top:6px;line-height:1.4;text-align:left">${a.desc}</div>
                    <div style="font-size:10px;color:#484f58;margin-top:6px;font-family:monospace">${a.file}</div>
                    <div class="gallery-copied" style="font-size:10px;color:#3fb950;margin-top:4px;opacity:0;transition:opacity 0.3s">Kopioitu!</div>
                </div>`;
            }).join('');
        })();

        // Ladataan agentit kun builder-tabi avataan
        builderLoad();

        // GUIDE.md:n lataus ja renderöinti
        (async function loadGuide() {
            const container = document.getElementById('guide-content');
            if (!container) return;
            try {
                const res = await fetch('/GUIDE.md');
                if (!res.ok) { container.innerHTML = '<p style="color:#f85149">Oppaan lataus epäonnistui.</p>'; return; }
                const md = await res.text();
                container.innerHTML = renderMarkdown(md);
                // Syntaksikorostus koodiblokeille
                container.querySelectorAll('pre code').forEach(block => {
                    if (typeof hljs !== 'undefined') hljs.highlightElement(block);
                });
                // Mermaid-kaaviot
                if (typeof mermaid !== 'undefined') {
                    mermaid.initialize({ startOnLoad: false, theme: 'dark', themeVariables: { primaryColor: '#58a6ff', primaryTextColor: '#c9d1d9', lineColor: '#30363d', background: '#0d1117' } });
                    container.querySelectorAll('.mermaid-container').forEach(async el => {
                        try {
                            const { svg } = await mermaid.render('m-' + el.id, el.textContent.trim());
                            el.innerHTML = svg;
                        } catch(e) { /* fallback: jätetään teksti näkyviin */ }
                    });
                }
            } catch(e) {
                container.innerHTML = '<p style="color:#f85149">Virhe: ' + e.message + '</p>';
            }
        })();

        function renderMarkdown(md) {
            const lines = md.split('\n');
            let html = '';
            let inCode = false;
            let codeLang = '';
            let codeBuffer = '';
            let inTable = false;
            let tableRows = [];

            function flushTable() {
                if (!inTable) return;
                inTable = false;
                if (tableRows.length < 2) return;
                const headerCells = tableRows[0].split('|').filter(c => c.trim());
                const bodyRows = tableRows.slice(2); // Skip header + separator
                html += '<div style="overflow-x:auto;margin:16px 0"><table style="width:100%;border-collapse:collapse;font-size:14px">';
                html += '<thead><tr>' + headerCells.map(c => `<th style="text-align:left;padding:8px 12px;border-bottom:2px solid #30363d;color:#58a6ff;font-weight:600">${inlineFormat(c.trim())}</th>`).join('') + '</tr></thead>';
                html += '<tbody>';
                for (const row of bodyRows) {
                    const cells = row.split('|').filter(c => c.trim());
                    if (cells.length === 0) continue;
                    html += '<tr>' + cells.map(c => `<td style="padding:6px 12px;border-bottom:1px solid #21262d">${inlineFormat(c.trim())}</td>`).join('') + '</tr>';
                }
                html += '</tbody></table></div>';
                tableRows = [];
            }

            function inlineFormat(text) {
                return text
                    .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;border:1px solid #30363d;margin:12px 0;display:block">')
                    .replace(/`([^`]+)`/g, '<code style="background:#161b22;padding:2px 6px;border-radius:3px;font-size:13px;color:#e6edf3">$1</code>')
                    .replace(/\*\*([^*]+)\*\*/g, '<strong style="color:#e6edf3">$1</strong>')
                    .replace(/\*([^*]+)\*/g, '<em>$1</em>');
            }

            for (const line of lines) {
                // Koodiblokit + Mermaid-kaaviot
                if (line.startsWith('```')) {
                    if (inCode) {
                        if (codeLang === 'mermaid') {
                            const mermaidId = 'mermaid-' + Math.random().toString(36).slice(2, 8);
                            html += `<div class="mermaid-container" id="${mermaidId}" style="margin:16px 0;text-align:center">${codeBuffer.replace(/</g,'&lt;')}</div>`;
                        } else {
                            html += `<pre style="background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:14px;margin:12px 0;overflow-x:auto"><code class="language-${codeLang}">${codeBuffer.replace(/</g,'&lt;')}</code></pre>`;
                        }
                        inCode = false;
                        codeBuffer = '';
                    } else {
                        flushTable();
                        inCode = true;
                        codeLang = line.slice(3).trim() || 'plaintext';
                    }
                    continue;
                }
                if (inCode) { codeBuffer += (codeBuffer ? '\n' : '') + line; continue; }

                // Taulukot
                if (line.includes('|') && line.trim().startsWith('|')) {
                    if (!inTable) inTable = true;
                    tableRows.push(line);
                    continue;
                } else {
                    flushTable();
                }

                // Tyhjä rivi
                if (!line.trim()) { html += '<div style="height:8px"></div>'; continue; }

                // Otsikot
                if (line.startsWith('# ')) { html += `<h1 style="color:#e6edf3;font-size:28px;margin:32px 0 12px;border-bottom:1px solid #30363d;padding-bottom:8px">${inlineFormat(line.slice(2))}</h1>`; continue; }
                if (line.startsWith('## ')) { html += `<h2 style="color:#e6edf3;font-size:22px;margin:28px 0 10px;border-bottom:1px solid #21262d;padding-bottom:6px">${inlineFormat(line.slice(3))}</h2>`; continue; }
                if (line.startsWith('### ')) { html += `<h3 style="color:#e6edf3;font-size:17px;margin:20px 0 8px">${inlineFormat(line.slice(4))}</h3>`; continue; }

                // Horisontaalinen viiva
                if (line.match(/^-{3,}$/)) { html += '<hr style="border:none;border-top:1px solid #30363d;margin:20px 0">'; continue; }

                // Lista
                if (line.match(/^[\-\*] /)) {
                    html += `<div style="padding:2px 0 2px 20px">${inlineFormat(line.replace(/^[\-\*] /, '• '))}</div>`;
                    continue;
                }
                if (line.match(/^\d+\. /)) {
                    html += `<div style="padding:2px 0 2px 20px">${inlineFormat(line)}</div>`;
                    continue;
                }

                // Normaali tekstirivi
                html += `<p style="margin:4px 0">${inlineFormat(line)}</p>`;
            }
            flushTable();
            return html;
        }
    </script>

    <!-- Prompt-editori modal -->
    <div id="prompt-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:1000;backdrop-filter:blur(4px)">
        <div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#0d1117;border:1px solid #30363d;border-radius:8px;width:700px;max-width:90vw;max-height:85vh;overflow-y:auto;padding:20px">
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
                <span id="prompt-modal-title" style="font-weight:600;font-size:15px;color:#58a6ff"></span>
                <button onclick="closePromptModal()" style="background:none;border:none;color:#8b949e;font-size:20px;cursor:pointer;padding:0 4px">&times;</button>
            </div>
            <div id="prompt-modal-fields" style="display:flex;flex-direction:column;gap:10px"></div>
            <div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
                <button onclick="closePromptModal()" style="background:#161b22;border:1px solid #30363d;color:#8b949e;padding:6px 16px;border-radius:4px;cursor:pointer">Sulje</button>
                <button onclick="rerunFromModal()" style="background:#238636;border:1px solid #2ea043;color:white;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600">▶ Aja uudelleen</button>
            </div>
        </div>
    </div>
    <!-- Monaco Editor loader -->
    <script is:inline>
        // AMD-loader Monacolle
        window.MonacoEnvironment = {
            getWorkerUrl: function(workerId, label) {
                return `data:text/javascript;charset=utf-8,${encodeURIComponent(`
                    self.MonacoEnvironment = { baseUrl: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/' };
                    importScripts('https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js');
                `)}`;
            }
        };

        // Kielitunnistus tiedostopäätteestä
        function langFromFilename(name) {
            const ext = name.split('.').pop().toLowerCase();
            const map = { py: 'python', rs: 'rust', js: 'javascript', ts: 'typescript', toml: 'toml', json: 'json', html: 'html', css: 'css', md: 'markdown', txt: 'plaintext', yaml: 'yaml', yml: 'yaml', sh: 'shell', sql: 'sql' };
            return map[ext] || 'plaintext';
        }

        // Globaali editor-state
        window._editorFiles = {};   // { "main.py": "code...", ... }
        window._editorModels = {};  // Monaco-mallit per tiedosto
        window._activeFile = null;
        window._monacoEditor = null;
        window._monacoLoaded = false;

        // Ladataan Monaco taustalla idle-aikana — ei blockata sivun latautumista
        if ('requestIdleCallback' in window) {
            requestIdleCallback(() => window.initMonaco(), { timeout: 5000 });
        } else {
            setTimeout(() => window.initMonaco(), 3000);
        }

        window.initMonaco = async function() {
            if (window._monacoLoaded) return;
            window._monacoLoaded = true;

            await new Promise((resolve) => {
                const script = document.createElement('script');
                script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
                script.onload = () => {
                    require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }});
                    require(['vs/editor/editor.main'], () => resolve());
                };
                document.head.appendChild(script);
            });

            window._monacoEditor = monaco.editor.create(document.getElementById('monaco-container'), {
                value: '// Valitse tiedosto vasemmalta tai generoi projekti agentilla\n',
                language: 'plaintext',
                theme: 'vs-dark',
                fontSize: 14,
                minimap: { enabled: false },
                lineNumbers: 'on',
                scrollBeyondLastLine: false,
                automaticLayout: true,
                padding: { top: 10 },
            });
        };

        // Avaa tiedostot editoriin (kutsutaan projektikortin "Avaa editorissa" -napista)
        window.openInEditor = function(files) {
            window._editorFiles = { ...files };

            // Vaihda Editor-välilehteen
            if (typeof switchMainTab === 'function') switchMainTab('editor');

            // Alusta Monaco tarvittaessa
            window.initMonaco().then(() => {
                // Luo Monaco-mallit
                for (const [name, code] of Object.entries(files)) {
                    const lang = langFromFilename(name);
                    if (window._editorModels[name]) {
                        window._editorModels[name].setValue(code);
                    } else {
                        window._editorModels[name] = monaco.editor.createModel(code, lang);
                    }
                }

                // Renderöi tiedostopuu
                const fileList = document.getElementById('editor-file-list');
                fileList.innerHTML = Object.keys(files).map(name =>
                    `<div class="editor-file-item" data-file="${name}" onclick="openEditorFile('${name}')" style="padding:6px 16px;cursor:pointer;color:#c9d1d9;font-size:13px;display:flex;align-items:center;gap:6px;border-left:2px solid transparent">`
                    + `<span style="color:#8b949e;font-size:11px">${langFromFilename(name) === 'python' ? '🐍' : langFromFilename(name) === 'rust' ? '🦀' : '📄'}</span>`
                    + `${name}</div>`
                ).join('');

                // Renderöi välilehdet
                const tabs = document.getElementById('editor-tabs');
                tabs.innerHTML = Object.keys(files).map(name =>
                    `<div class="editor-tab" data-file="${name}" onclick="openEditorFile('${name}')" style="padding:6px 12px;cursor:pointer;font-size:12px;color:#8b949e;border:1px solid transparent;border-bottom:none;border-radius:4px 4px 0 0;white-space:nowrap">${name}</div>`
                ).join('');

                // Avaa ensimmäinen tiedosto
                const firstName = Object.keys(files)[0];
                if (firstName) openEditorFile(firstName);
            });
        };

        // Vaihda aktiivinen tiedosto
        window.openEditorFile = function(name) {
            if (!window._editorModels[name] || !window._monacoEditor) return;
            window._activeFile = name;
            window._monacoEditor.setModel(window._editorModels[name]);

            // Päivitä visuaalinen tila
            document.querySelectorAll('.editor-file-item').forEach(el => {
                const active = el.dataset.file === name;
                el.style.background = active ? '#161b22' : 'transparent';
                el.style.borderLeftColor = active ? '#58a6ff' : 'transparent';
                el.style.color = active ? '#e6edf3' : '#c9d1d9';
            });
            document.querySelectorAll('.editor-tab').forEach(el => {
                const active = el.dataset.file === name;
                el.style.background = active ? '#161b22' : 'transparent';
                el.style.color = active ? '#58a6ff' : '#8b949e';
                el.style.borderColor = active ? '#30363d' : 'transparent';
                el.style.borderBottomColor = active ? '#161b22' : 'transparent';
            });
        };
    </script>
</body>
</html>
