838 lines
41 KiB
HTML
838 lines
41 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fi">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Kipinä Agent Dashboard</title>
|
||
<style>
|
||
:root {
|
||
--bg-color: #0d1117;
|
||
--panel-bg: #161b22;
|
||
--text-color: #c9d1d9;
|
||
--accent-color: #58a6ff;
|
||
--success-color: #3fb950;
|
||
--border-color: #30363d;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||
background-color: var(--bg-color);
|
||
color: var(--text-color);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
padding: 20px;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.container {
|
||
background-color: var(--panel-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 30px;
|
||
width: 100%;
|
||
max-width: 1400px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.device-info {
|
||
background-color: #0d1117;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 12px 16px;
|
||
margin-bottom: 20px;
|
||
font-family: 'Courier New', Courier, monospace;
|
||
font-size: 14px;
|
||
color: #8b949e;
|
||
text-align: left;
|
||
display: none;
|
||
}
|
||
|
||
.device-info span { color: var(--text-color); }
|
||
|
||
.dashboard-panel {
|
||
background-color: #0d1117;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 15px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-box {
|
||
text-align: center;
|
||
flex-grow: 1;
|
||
}
|
||
|
||
.stat-box h3 {
|
||
margin: 0;
|
||
color: var(--accent-color);
|
||
font-size: 28px;
|
||
}
|
||
|
||
.stat-box p {
|
||
margin: 5px 0 0 0;
|
||
font-size: 14px;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.slider-container {
|
||
margin: 20px 0;
|
||
text-align: left;
|
||
}
|
||
|
||
input[type=range] {
|
||
width: 100%;
|
||
margin-top: 10px;
|
||
accent-color: var(--accent-color);
|
||
}
|
||
|
||
h1 { margin-bottom: 5px; }
|
||
h1 span { color: var(--accent-color); }
|
||
.sub { color: #8b949e; margin-bottom: 25px; }
|
||
|
||
.status-box {
|
||
font-family: 'Courier New', Courier, monospace;
|
||
background-color: #010409;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 15px;
|
||
height: 120px;
|
||
overflow-y: auto;
|
||
text-align: left;
|
||
}
|
||
|
||
.status-box p {
|
||
margin: 0 0 5px 0;
|
||
color: var(--success-color);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.btn {
|
||
background-color: #238636;
|
||
color: #ffffff;
|
||
border: 1px solid rgba(240, 246, 252, 0.1);
|
||
border-radius: 6px;
|
||
padding: 10px 20px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.btn:hover { background-color: #2ea043; }
|
||
.hidden { display: none; }
|
||
|
||
.compat-banner {
|
||
border-radius: 6px;
|
||
padding: 14px 18px;
|
||
margin-bottom: 20px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
display: none;
|
||
}
|
||
.compat-banner.gpu {
|
||
background: #23392020;
|
||
border: 1px solid #3fb95040;
|
||
color: var(--success-color);
|
||
}
|
||
.compat-banner.cpu {
|
||
background: #d2992215;
|
||
border: 1px solid #d2992240;
|
||
color: #d29922;
|
||
}
|
||
.compat-banner code {
|
||
background: #0d1117;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 12px;
|
||
color: var(--text-color);
|
||
}
|
||
.compat-banner summary {
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
}
|
||
.compat-banner details[open] summary {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.chat-box {
|
||
background-color: var(--panel-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 15px;
|
||
height: 500px;
|
||
overflow-y: auto;
|
||
text-align: left;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.chat-msg {
|
||
background-color: #0d1117;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
border-left: 3px solid var(--accent-color);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.chat-prompt {
|
||
color: #8b949e;
|
||
font-size: 13px;
|
||
margin-bottom: 5px;
|
||
display: block;
|
||
}
|
||
|
||
.token-detail {
|
||
background: #010409;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
padding: 10px 12px;
|
||
margin-top: 8px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
display: none;
|
||
}
|
||
.token-detail.visible { display: block; }
|
||
.token-detail .tok {
|
||
background: #1c2333;
|
||
border: 1px solid #30363d;
|
||
border-radius: 3px;
|
||
padding: 2px 5px;
|
||
margin: 2px;
|
||
display: inline-block;
|
||
color: var(--text-color);
|
||
}
|
||
.token-detail .tok-en { border-color: #58a6ff44; }
|
||
.token-detail .tok-fi { border-color: #d2992244; }
|
||
.toggle-tokens {
|
||
background: none;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
color: #8b949e;
|
||
font-size: 12px;
|
||
padding: 3px 8px;
|
||
cursor: pointer;
|
||
}
|
||
.toggle-tokens:hover { color: var(--text-color); border-color: #8b949e; }
|
||
|
||
.task-option {
|
||
background: var(--panel-bg);
|
||
border: 2px solid var(--border-color);
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s;
|
||
position: relative;
|
||
}
|
||
.task-option:hover { border-color: #8b949e; }
|
||
.task-option.selected { border-color: var(--accent-color); background: #58a6ff10; }
|
||
.task-title { font-weight: 600; font-size: 15px; color: var(--text-color); margin-bottom: 4px; }
|
||
.task-desc { font-size: 12px; color: #8b949e; line-height: 1.4; margin-bottom: 8px; }
|
||
.task-size { font-size: 11px; color: #6e7681; }
|
||
.task-badge {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
}
|
||
.task-ready { background: #23392050; color: var(--success-color); border: 1px solid #23392080; }
|
||
.task-soon { background: #d2992215; color: #d29922; border: 1px solid #d2992240; }
|
||
.task-info {
|
||
display: none;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--border-color);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
color: #8b949e;
|
||
}
|
||
.task-info strong { color: var(--text-color); }
|
||
.task-info em { color: var(--accent-color); font-style: normal; }
|
||
.task-option.selected .task-info { display: block; }
|
||
|
||
.download-bar {
|
||
background: #0d1117;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 12px 16px;
|
||
margin-bottom: 16px;
|
||
display: none;
|
||
}
|
||
.download-bar .bar-track {
|
||
background: #21262d;
|
||
border-radius: 4px;
|
||
height: 8px;
|
||
margin-top: 8px;
|
||
overflow: hidden;
|
||
}
|
||
.download-bar .bar-fill {
|
||
background: var(--accent-color);
|
||
height: 100%;
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.metric-card {
|
||
background: var(--panel-bg);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
text-align: center;
|
||
}
|
||
.metric-val {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--accent-color);
|
||
}
|
||
.metric-label {
|
||
font-size: 11px;
|
||
color: #8b949e;
|
||
margin-top: 2px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>Kipinä <span>Agent Dashboard</span></h1>
|
||
<p class="sub">Hajautettu WebGPU Laskentaverkko · <span id="hub-version" style="color:#58a6ff">-</span></p>
|
||
|
||
<!-- 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>Aktiivisia Nodeja</p>
|
||
</div>
|
||
<div class="stat-box" style="border-right: 1px solid #30363d;">
|
||
<h3 id="stat-tasks">0</h3>
|
||
<p>Verkossa Suoritettua Tehtävää (Globaali)</p>
|
||
</div>
|
||
<div class="stat-box">
|
||
<h3 id="stat-vram">0 GB</h3>
|
||
<p>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">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">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">Resurssien hallinta</span>
|
||
<span id="node-status" style="font-size:12px;color:var(--success-color)">Aktiivinen</span>
|
||
</div>
|
||
|
||
<!-- Kuormitussäädin -->
|
||
<div style="margin-bottom:14px">
|
||
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px">
|
||
<span>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>
|
||
|
||
<!-- 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">Tehtäviä</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-val" id="m-avg-time">-</div>
|
||
<div class="metric-label">Ka. aika</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-val" id="m-tokens">0</div>
|
||
<div class="metric-label">Tokeneita</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-val" id="m-uptime">0s</div>
|
||
<div class="metric-label">Käynnissä</div>
|
||
</div>
|
||
</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>
|
||
|
||
<script type="module">
|
||
import init, { start_agent_node, set_gpu_load } from './pkg/node.js';
|
||
|
||
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);
|
||
|
||
// Ylikirjoitetaan console.log uppoamaan lokilaatikkoon
|
||
const originalLog = console.log;
|
||
console.log = function(...args) {
|
||
originalLog.apply(console, args);
|
||
// Älä tulosta teknisiä WGPU warningeja suoraan AI:n näytölle jos niitä on
|
||
let msg = args.join(' ');
|
||
if (msg.includes("wgpu") || msg.includes("vastaanotettu")) return; // Siistitään spämmäävät lokit näkymästä, koska niitä tulee nyt sata sekunnissa
|
||
|
||
const p = document.createElement('p');
|
||
p.textContent = '> ' + msg;
|
||
logBox.appendChild(p);
|
||
|
||
// Ehkäistään selaimen jumittuminen sadoista tuhansista lokiriveistä pitkässä GPU-ajossa
|
||
if (logBox.children.length > 30) {
|
||
logBox.removeChild(logBox.firstChild);
|
||
}
|
||
logBox.scrollTop = logBox.scrollHeight;
|
||
};
|
||
|
||
// 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)';
|
||
}
|
||
});
|
||
|
||
// Kytkemme sivuston UI-puolen (JS) omaan passiiviseen WebSocket-kuuntelijaan.
|
||
const uiSocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`);
|
||
uiSocket.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
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');
|
||
const msgDiv = document.createElement('div');
|
||
msgDiv.className = 'chat-msg';
|
||
msgDiv.style.borderLeftColor = 'var(--success-color)';
|
||
msgDiv.innerHTML = `<span style="color:var(--success-color)">[Järjestelmä] Uusi solmu (ID: ${data.node_id}) liittyi verkon työjohdon piiriin!</span>`;
|
||
chatBox.appendChild(msgDiv);
|
||
if (chatBox.children.length > 5) chatBox.removeChild(chatBox.firstChild);
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
} 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';
|
||
}
|
||
} else if (data.type === "pair_task") {
|
||
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> "${data.en}"</div>
|
||
<div><strong style="color:#d29922">FI</strong> "${data.fi}"</div>
|
||
</div>`;
|
||
chatBox.appendChild(msgDiv);
|
||
if (chatBox.children.length > 8) 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();
|
||
|
||
// 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}">${t.replace(/</g,'<')}</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">"${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">"${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 > 8) chatBox.removeChild(chatBox.firstChild);
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
} else if (data.type === "llm_done") {
|
||
// SmolLM / LLM-inferenssin tulos
|
||
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">"${data.prompt || ''}"</span>
|
||
</div>
|
||
<div style="font-size:14px;color:var(--text-color);line-height:1.5">
|
||
${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 > 8) chatBox.removeChild(chatBox.firstChild);
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
|
||
metrics.tasks++;
|
||
metrics.totalTokens += tokGen;
|
||
metrics.totalTimeMs += durMs;
|
||
updateMetrics();
|
||
|
||
console.log(`[${model}] ${tokGen} tokenia | ${typeof durMs === 'number' ? durMs.toFixed(0) : durMs}ms | ${tokS} tok/s | "${(data.response || '').substring(0, 60)}..."`);
|
||
} else if (data.type === "llm_chunk") {
|
||
// Streaming-token LLM:ltä — näytetään lokissa
|
||
// (chat-ikkunan streaming tulisi toteuttaa samalla logiikalla kuin pair_task/pair_done)
|
||
}
|
||
} catch(e) {}
|
||
};
|
||
|
||
btn.addEventListener('click', async () => {
|
||
// Kerätään laitteistotiedot
|
||
let hasWebGPU = false;
|
||
const deviceInfo = {
|
||
allocated_gb: 4,
|
||
cpu_cores: navigator.hardwareConcurrency || 0,
|
||
device_memory_gb: navigator.deviceMemory || 0,
|
||
platform: navigator.platform || "",
|
||
gpu: null,
|
||
selected_task: selectedTask
|
||
};
|
||
|
||
if (navigator.gpu) {
|
||
try {
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
if (adapter) {
|
||
hasWebGPU = true;
|
||
const info = adapter.info || {};
|
||
const maxBuf = Number(adapter.limits.maxBufferSize || 0);
|
||
// maxBufferSize antaa arvion VRAM:sta — tyypillisesti ~25% todellisesta
|
||
const estimatedVramGb = maxBuf > 0 ? Math.round(maxBuf / 1024 / 1024 / 1024 * 4) : 0;
|
||
deviceInfo.gpu = {
|
||
vendor: info.vendor || "",
|
||
architecture: info.architecture || "",
|
||
device: info.device || "",
|
||
description: info.description || "",
|
||
max_buffer_size: maxBuf,
|
||
max_compute_workgroups: adapter.limits.maxComputeWorkgroupsPerDimension || 0,
|
||
estimated_vram_gb: estimatedVramGb
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
const gpuStr = hasWebGPU ? (deviceInfo.gpu?.description || deviceInfo.gpu?.vendor || "WebGPU") : "ei GPU:ta";
|
||
const backendStr = hasWebGPU ? "WebGPU" : "CPU (NdArray)";
|
||
const vramStr = deviceInfo.gpu?.estimated_vram_gb ? `~${deviceInfo.gpu.estimated_vram_gb} GB` : "?";
|
||
|
||
// navigator.deviceMemory on rajoitettu max 8 GB:iin — merkitään arvio
|
||
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 = [
|
||
`Backend: <span>${backendStr}</span>`,
|
||
`GPU: <span>${gpuStr}</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(' · ');
|
||
|
||
// Yhteensopivuusbanneri
|
||
const banner = document.getElementById('compat-banner');
|
||
banner.style.display = 'block';
|
||
|
||
if (hasWebGPU) {
|
||
banner.className = 'compat-banner gpu';
|
||
banner.innerHTML = `GPU-kiihdytys aktiivinen — ${gpuStr}`;
|
||
} 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');
|
||
btn.style.display = 'none';
|
||
|
||
try {
|
||
console.log("Ladataan Burn Wasm -binääriä...");
|
||
await init();
|
||
window.wasm_active = true;
|
||
metrics.startTime = Date.now();
|
||
|
||
// 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};
|
||
const taskId = taskIds[selectedTask] || 0;
|
||
await start_agent_node(wsUrl, hasWebGPU, JSON.stringify(deviceInfo), taskId);
|
||
} catch(e) {
|
||
console.log("Virhe GPU-käynnistyksessä: " + e);
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|