hyvä siitä tulee

This commit is contained in:
2026-04-02 18:16:41 +03:00
parent 6cdd695a3b
commit 31995fb278
8 changed files with 272 additions and 89 deletions

View File

@@ -152,6 +152,24 @@ impl NodeDb {
conn.last_insert_rowid()
}
pub fn update_session_task(&self, node_id: u64, task: &str) {
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
let _ = conn.execute(
"UPDATE node_sessions SET selected_task = ?1 WHERE node_id = ?2 AND disconnected_at IS NULL",
params![task, node_id as i64],
);
}
/// Sulkee saman IP:n viewer-sessiot kun aktiivinen node liittyy
pub fn close_viewers_by_ip(&self, ip: &str) {
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
let now = chrono::Utc::now().to_rfc3339();
let _ = conn.execute(
"UPDATE node_sessions SET disconnected_at = ?1 WHERE ip = ?2 AND disconnected_at IS NULL AND (selected_task = 'viewer' OR selected_task = 'codelab-viewer')",
params![now, ip],
);
}
pub fn close_session(&self, node_id: u64) {
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
let now = chrono::Utc::now().to_rfc3339();

View File

@@ -25,7 +25,7 @@ const ALLOWED_ORIGINS: &[&str] = &[
];
// Sallitut viestityyypit clientilta
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "download_progress", "user_text"];
const ALLOWED_MSG_TYPES: &[&str] = &["auth", "result", "pair_done", "llm_chunk", "llm_done", "download_progress", "user_text", "single_tokenize_done"];
struct AppState {
next_node_id: Mutex<u64>,
@@ -158,7 +158,7 @@ async function load() {
].map(s => `<div class="stat-card"><div class="val">${s.v}</div><div class="label">${s.l}</div></div>`).join('');
// Sessions — lajittelu: 1) aktiiviset nodet (online + ei viewer), 2) katsojat (online + viewer), 3) offline
const taskNames = {'tokenize':'Tokenisaatio','smollm-135m':'SmolLM 135M','qwen-05b':'Qwen2.5 0.5B','phi3-mini':'Phi-3 Mini','qwen-coder-05b':'Coder 0.5B','qwen-coder-3b':'Coder 3B','viewer':'Katsoja'};
const taskNames = {'tokenize':'Tokenisaatio','smollm-135m':'SmolLM 135M','qwen-05b':'Qwen2.5 0.5B','phi3-mini':'Phi-3 Mini','qwen-coder-05b':'Coder 0.5B','qwen-coder-3b':'Coder 3B','viewer':'Katsoja','codelab-viewer':'Koodilabra'};
sessions.sort((a, b) => {
const aOnline = !a.disconnected_at;
const bOnline = !b.disconnected_at;
@@ -321,28 +321,9 @@ async fn main() {
});
let _ = state_for_task.stats_tx.send(phi3_msg.to_string());
// Coder-promptit — pieniä Python-tehtäviä
let code_prompts = vec![
"Write a Python function that checks if a number is prime.",
"Write a Python function that reverses a string without using slicing.",
"Write a Python function to find the factorial of a number using recursion.",
"Write a Python function that returns the Fibonacci sequence up to n numbers.",
"Write a Python function to check if a string is a palindrome.",
"Write a Python function that sorts a list using bubble sort.",
"Write a Python function to count the occurrences of each character in a string.",
"Write a Python function that flattens a nested list.",
"Write a Python function to find the greatest common divisor of two numbers.",
"Write a Python function that converts Celsius to Fahrenheit.",
];
let code_idx = (rng_state as usize / 13) % code_prompts.len();
let coder_msg = serde_json::json!({
"type": "llm_prompt",
"prompt": code_prompts[code_idx],
"model": "qwen-coder",
});
let _ = state_for_task.stats_tx.send(coder_msg.to_string());
// Coder ei saa automaattisia tehtäviä — vain käyttäjän user_text
tracing::debug!("Tehtävät lähetetty: pair + smollm + qwen + phi3 + coder");
tracing::debug!("Tehtävät lähetetty: pair + smollm + qwen + phi3");
}
});
@@ -617,11 +598,21 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
map.insert(node_id, allocated);
}
// Tallennetaan sessiotieto tietokantaan
state.db.insert_session(node_id, &ip.to_string(), node_type, &json);
// Tallennetaan valittu tehtävä muistiin reititystä varten
let selected_task = json.get("selected_task").and_then(|v| v.as_str()).unwrap_or("tokenize").to_string();
let is_viewer = selected_task == "viewer" || selected_task == "codelab-viewer";
let existing = state.node_tasks.lock().unwrap().contains_key(&node_id);
if existing {
// Sama yhteys, eri tehtävä → päivitetään
state.db.update_session_task(node_id, &selected_task);
tracing::info!("Solmu {} päivitti tehtävän → {}", node_id, selected_task);
} else {
// Uusi yhteys — suljetaan saman IP:n viewer-sessiot jos tämä on aktiivinen node
if !is_viewer {
state.db.close_viewers_by_ip(&ip.to_string());
}
state.db.insert_session(node_id, &ip.to_string(), node_type, &json);
}
state.node_tasks.lock().unwrap().insert(node_id, selected_task);
if node_type == "native" {
@@ -726,6 +717,14 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
}
broadcast_stats(&state).await;
}
} else if msg_type == "single_tokenize_done" {
{
let mut json = json.clone();
if let Some(obj) = json.as_object_mut() {
obj.insert("node_id".to_string(), serde_json::json!(node_id));
}
let _ = state.stats_tx.send(json.to_string());
}
} else if msg_type == "llm_chunk" {
{
let mut json = json;
@@ -770,14 +769,11 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
tracing::info!("Solmu {} lähetti oman tekstin ({}): \"{}\"", node_id, task_type, &text[..text.len().min(80)]);
match task_type {
"tokenize" => {
// Tokenisoidaan käyttäjän teksti EN-puolella, FI jätetään tyhjäksi
let pair = serde_json::json!({
"type": "pair_task",
"en": text,
"fi": text,
"user_submitted": true,
let msg = serde_json::json!({
"type": "single_tokenize",
"text": text,
});
let _ = state.stats_tx.send(pair.to_string());
let _ = state.stats_tx.send(msg.to_string());
}
_ => {
// LLM-prompti

View File

@@ -26,9 +26,9 @@ web-sys = { version = "0.3.68", features = [
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
burn = { version = "0.21.0-pre.2", default-features = false, features = ["wgpu", "ndarray"] }
burn-wgpu = "0.21.0-pre.2"
burn-ndarray = "0.21.0-pre.2"
burn = { version = "0.14.0", features = ["wgpu", "ndarray"] }
burn-wgpu = "0.14.0"
burn-ndarray = "0.14.0"
wasm-bindgen-futures = "0.4"
console_error_panic_hook = "0.1.7"
reqwest = { version = "0.12", default-features = false, features = ["json"] }

View File

@@ -22,8 +22,15 @@ macro_rules! console_log {
static GPU_LOAD_PERCENT: AtomicU32 = AtomicU32::new(50);
static HAS_WEBGPU: AtomicBool = AtomicBool::new(true);
static SELECTED_TASK: AtomicU32 = AtomicU32::new(0);
// Estää rinnakkaiset LLM-inferenssit (vain yksi kerrallaan)
static LLM_BUSY: AtomicBool = AtomicBool::new(false);
// Käsitelläänkö hubin automaattisia tehtäviä
static AUTO_TASKS: AtomicBool = AtomicBool::new(true);
#[wasm_bindgen]
pub fn set_auto_tasks(enabled: bool) {
AUTO_TASKS.store(enabled, Ordering::SeqCst);
console_log!("[Wasm] Automaattiset tehtävät: {}", if enabled { "päällä" } else { "pois" });
}
#[wasm_bindgen]
pub fn set_gpu_load(load: u32) {
@@ -110,6 +117,30 @@ fn tokenize_text(tokenizer: &tokenizers::Tokenizer, text: &str) -> serde_json::V
}
}
/// Tokenisoi yksittäisen tekstin ja lähettää tuloksen hubille
async fn run_single_tokenize(text: String, ws: Rc<RefCell<WebSocket>>) {
let cached_tok = storage::load_from_idb("tokenizer.json").await.unwrap_or(None);
let Some(bytes) = cached_tok else { return; };
let Ok(tokenizer) = tokenizers::Tokenizer::from_bytes(&bytes) else { return; };
let perf = web_sys::window().unwrap().performance().unwrap();
let start = perf.now();
let result = tokenize_text(&tokenizer, &text);
let duration_ms = perf.now() - start;
let token_count = result["token_count"].as_u64().unwrap_or(0);
let cpt = result["chars_per_token"].as_f64().unwrap_or(0.0);
console_log!("Tokenisaatio: \"{}\" → {} tokenia | {:.2} m/t | {:.2}ms",
&text[..text.len().min(50)], token_count, cpt, duration_ms);
let msg = serde_json::json!({
"type": "single_tokenize_done",
"result": result,
"duration_ms": (duration_ms * 100.0).round() / 100.0,
});
let _ = ws.borrow().send_with_str(&msg.to_string());
}
/// Tokenisoi en/fi-parin, vertaa tehokkuutta ja lähettää tuloksen hubille
async fn run_pair_comparison(en_text: String, fi_text: String, ws: Rc<RefCell<WebSocket>>) {
let load_pct = GPU_LOAD_PERCENT.load(Ordering::SeqCst);
@@ -194,8 +225,9 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
let msg: String = txt.into();
let current_task = SELECTED_TASK.load(Ordering::SeqCst);
let auto_on = AUTO_TASKS.load(Ordering::SeqCst);
if msg.contains("pair_task") && current_task == 0 {
if msg.contains("pair_task") && current_task == 0 && auto_on {
// Vain tokenisaatiosolmut käsittelevät pair_task-viestejä
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
let en = task.get("en").and_then(|v| v.as_str()).unwrap_or("").to_string();
@@ -207,7 +239,17 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
});
}
}
} else if msg.contains("llm_prompt") && current_task == 1 {
} else if msg.contains("single_tokenize") && current_task == 0 {
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
let text = task.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string();
if !text.is_empty() {
let ws_for_async = ws_clone.clone();
wasm_bindgen_futures::spawn_local(async move {
run_single_tokenize(text, ws_for_async).await;
});
}
}
} else if msg.contains("llm_prompt") && current_task == 1 && auto_on {
// Vain SmolLM-solmut, ja vain yksi inferenssi kerrallaan
if LLM_BUSY.load(Ordering::SeqCst) {
// Ohitetaan — edellinen inferenssi vielä käynnissä
@@ -223,7 +265,7 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
});
}
}
} else if msg.contains("llm_prompt") && current_task == 2 {
} else if msg.contains("llm_prompt") && current_task == 2 && auto_on {
// Qwen2.5-0.5B
if LLM_BUSY.load(Ordering::SeqCst) {
} else if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {
@@ -237,7 +279,7 @@ pub async fn start_agent_node(hub_url: String, has_webgpu: bool, device_info_jso
});
}
}
} else if msg.contains("llm_prompt") && current_task == 3 {
} else if msg.contains("llm_prompt") && current_task == 3 && auto_on {
// Phi-3 Mini
if LLM_BUSY.load(Ordering::SeqCst) {
} else if let Ok(task) = serde_json::from_str::<serde_json::Value>(&msg) {

View File

@@ -199,6 +199,7 @@ pub async fn run_qwen_inference(prompt: String, ws: Rc<RefCell<WebSocket>>) {
let _ = ws.borrow().send_with_str(&chunk.to_string());
}
tokens_generated += 1;
crate::sleep_ms(0).await;
}
let gen_time = perf.now() - start_gen;

View File

@@ -262,6 +262,9 @@ pub async fn run_coder_inference(prompt: String, ws: Rc<RefCell<WebSocket>>, use
let _ = ws.borrow().send_with_str(&chunk.to_string());
}
tokens_generated += 1;
// Yield — vapautetaan selaimen event loop joka tokenin jälkeen
crate::sleep_ms(0).await;
}
let gen_time = perf.now() - start_gen;

View File

@@ -118,15 +118,12 @@ pub async fn run_smollm_inference(prompt: String, ws: Rc<RefCell<WebSocket>>) {
Err(e) => { console_log!("[SmolLM] Malli-virhe: {}", e); return; }
};
let use_gpu = crate::HAS_WEBGPU.load(std::sync::atomic::Ordering::SeqCst);
if use_gpu {
console_log!("[SmolLM] Burn WebGPU inferenssi...");
run_burn_inference::<burn::backend::Wgpu>(prompt, model_bytes, tokenizer, ws, perf.clone()).await;
} else {
// Burn 0.14 wgpu ei yhteensopiva nykyisten selainten kanssa (maxInterStageShaderComponents)
// Burn 0.21-pre.2 cubecl-runtime ei käänny Wasmille (println! puuttuu)
// → NdArray kunnes Burn 0.21 stable + Wasm-tuki
console_log!("[SmolLM] Burn NdArray (CPU) inferenssi...");
run_burn_inference::<burn::backend::NdArray>(prompt, model_bytes, tokenizer, ws, perf.clone()).await;
}
}
async fn run_burn_inference<B: burn::tensor::backend::Backend>(
prompt: String,

View File

@@ -119,6 +119,8 @@
.main-panel { display: none; }
.main-panel.active { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
.code-output {
font-family: 'Courier New', Courier, monospace;
background: #010409;
@@ -500,6 +502,15 @@
</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)">
Vastaanota automaattisia tehtäviä hubilta
</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">
@@ -655,7 +666,7 @@
</div>
<script type="module">
import init, { start_agent_node, set_gpu_load } from './pkg/node.js';
import init, { start_agent_node, set_gpu_load, set_auto_tasks } from './pkg/node.js';
// Päävälilehtien vaihto
window.switchMainTab = function(tab) {
@@ -664,6 +675,21 @@
document.getElementById('panel-' + tab).classList.add('active');
document.querySelector(`.main-tab[onclick*="${tab}"]`).classList.add('active');
window.location.hash = tab;
// 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,
}));
}
};
// URL-hash navigointi: #codelab tai #network
@@ -671,9 +697,24 @@
switchMainTab('codelab');
}
// 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 = '05b'; // '05b' tai '3b'
@@ -791,6 +832,13 @@
}
});
// 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');
@@ -815,13 +863,41 @@
// Kytkemme sivuston UI-puolen (JS) omaan passiiviseen WebSocket-kuuntelijaan.
const uiSocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`);
uiSocket.onopen = () => {
window._uiSocket = uiSocket;
uiSocket.onopen = async () => {
// 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;
// Lähetetään kevyt auth heti — admin näkee kävijän välittömästi
const hasGPU = !!navigator.gpu;
uiSocket.send(JSON.stringify({
type: 'auth',
status: 'viewer',
@@ -832,12 +908,18 @@
allocated_gb: 0,
selected_task: 'viewer',
has_webgpu: hasGPU,
gpu: gpuInfo,
}));
};
uiSocket.onclose = () => {
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';
}
};
uiSocket.onmessage = (event) => {
try {
@@ -871,6 +953,43 @@
} else {
dlBar.style.display = 'none';
}
} 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">${t.replace(/</g,'&lt;')}</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">"${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 > 8) chatBox.removeChild(chatBox.firstChild);
chatBox.scrollTop = chatBox.scrollHeight;
flashComputing();
} else if (data.type === "pair_task") {
chatBox.classList.remove('hidden');
if (chatBox.children.length === 1 && chatBox.children[0].textContent.includes('Odotetaan')) {
@@ -960,7 +1079,8 @@
if (chatBox.children.length > 8) chatBox.removeChild(chatBox.firstChild);
chatBox.scrollTop = chatBox.scrollHeight;
} else if (data.type === "llm_done") {
// SmolLM / LLM-inferenssin tulos
// Poistetaan streaming-kortti
chatBox.querySelector('.streaming-card')?.remove();
chatBox.classList.remove('hidden');
const nodeId = data.node_id || "?";
const model = data.model || "LLM";
@@ -998,53 +1118,56 @@
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)
// Streaming: näytetään generointi reaaliaikaisesti
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: "${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;
}
}
} catch(e) {}
};
btn.addEventListener('click', async () => {
// Kerätään laitteistotiedot
let hasWebGPU = false;
// 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: null,
gpu: detectedGpuInfo,
selected_task: selectedTask
};
if (navigator.gpu) {
try {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
try {
const testDevice = await adapter.requestDevice({ requiredLimits: { maxInterStageShaderComponents: 60 } });
hasWebGPU = true;
testDevice.destroy();
} catch(e) {
console.log("[WebGPU] Legacy-tuki rajoittaa WebGPU:n: " + e.message);
hasWebGPU = false; // Fallback to CPU NdArray avoiding WASM panic
}
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";
// Laskenta käyttää aina CPU:ta (Candle), WebGPU on vain tensorilaskennassa (Burn)
const computeBackend = (selectedTask === 'tokenize')
@@ -1184,6 +1307,9 @@
}
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;