FI/SV/EN kielituki about valmis (testaamatta)

This commit is contained in:
2026-04-03 09:56:18 +03:00
parent 2a242efbd8
commit 3d2b2342e0
4 changed files with 147 additions and 28 deletions

View File

@@ -26,9 +26,14 @@ Tässä on kooste projektin vaatimuksista, työtehtävistä ja niiden nykytilant
- Sijoittaa Hub-palvelin julkisesti saatavuusosoitteeseen `kipina.studio`.
### Tehtävät
- [ ] Tuotantopalvelimen käyttöönotto Nginxin tai Docker-compose kautta ehtojen täytyttyä
- [ ] Turvamekanismin lisäys: Varmistetaan, ettei kukaan lähetä "falskeja" vastauksia nodeilta
- [ ] Solmuille rekisteröitymismekanismi tai tulostaulukko
- [x] Tuotantopalvelimen käyttöönotto Docker-compose + Caddy TLS kautta (`kipina.studio`)
- [x] Deploy-skripti (`deploy.sh`) + Discord-webhook-notifikaatio julkaisuista
- [x] Admin-dashboard (`/admin`) Basic Auth -suojattuna, live-sessiot ja metriikat
- [x] REST API (`POST /api/v1/chat/completions`) task_id-pohjaisella vastausten reitityksellä
- [x] API timeout (120s) + selkeät virheilmoitukset (504 Gateway Timeout)
- [x] IP-pohjainen rate limiting (max 4 yhteyttä/IP) + origin-validointi
- [ ] Turvamekanismin lisäys: Varmistetaan, ettei kukaan lähetä "falskeja" vastauksia nodeilta (PoW/challenge-response)
- [x] SQLite-sessioseuranta (node_sessions + pair_results)
---
@@ -53,7 +58,38 @@ Tässä on kooste projektin vaatimuksista, työtehtävistä ja niiden nykytilant
- Kyetä lataamaan selaimen IndexedDB:hen satojen megatavujen painot massivisena fetch-hakuna, kääntää ne WebGPU-puskureihin (Buffers) ja suorittaa tekstigeneraatiota etänä ohjattuna verkosta käsin WebSocketia myöden.
### Tehtävät
- [ ] Refaktoroi Wasm-Noden (Burn.rs) paketti tuomaan Text-Tokenizerit (esim. BPE) ja kielimallin arkkitehtuuri käyttöön
- [ ] Koodaa Nodeen logiikka hakea / kasata mallin painot välimuistista "Chunk"-lohkoina valmiiksi
- [ ] Hub uudistetaan generoimaan pelkkien matikkavaikeuksien sijasta Text Prompts (esim. "Kirjoita haiku Suomesta") ja reitittämään työkuorman vapaalle solmulle
- [ ] Kipinän käyttöliittymään Chat-ikkuna Hubin striimaamien tulossanojen tarkkailuun reaaliajassa
- [x] Refaktoroi Wasm-Noden (Burn.rs) paketti tuomaan Text-Tokenizerit (BPE, Qwen2.5-Coder) ja kielimallin arkkitehtuuri käyttöön
- [x] Koodaa Nodeen logiikka hakea / kasata mallin painot välimuistista IndexedDB:hen (tokenizer.json + model weights)
- [x] Hub uudistetaan generoimaan Text Prompts ja reitittämään työkuorman vapaalle solmulle (broadcast + task_id-matching)
- [x] Kipinän käyttöliittymään Chat-ikkuna Hubin striimaamien tulossanojen tarkkailuun reaaliajassa (llm_chunk streaming)
- [x] SmolLM 135M — täysi transformer (Burn), ~1.2 tok/s CPU
- [x] Qwen2.5 0.5B — Candle-inferenssi, ChatML-muotoilu, ~0.4 tok/s CPU
- [x] Qwen2.5-Coder 0.5B & 3B — koodigeneraatio, streaming-tokenit, task_id-tuki
- [x] Phi-3 Mini — placeholder (liian suuri selaimelle, natiivisolmulle suunnitteilla)
- [x] EN/FI tokenisaatiovertailu overhead-laskennalla
- [x] Natiivisolmu (Rust + CUDA) — Qwen2.5 0.5B, ~50-100 tok/s RTX 4090, NVML GPU-metriikat
---
## 🚀 Vaihe 6: Agent Workspace & CLI (KÄYNNISSÄ)
### Tavoitteet
- Interaktiivinen terminaalipohjainen käyttöliittymä `kpn`-komennoilla.
- Agenttitiimi (Koodari, Testaaja, Manageri) muokattavilla system prompteilla.
- Agenttien ketjutus: manageri analysoi → koodari toteuttaa → testaaja arvioi.
### Tehtävät
- [x] KPN-terminaali selaimeen (interaktiivinen komentorivi, komentohistoria)
- [x] `kpn run <malli> "<prompti>"` — tehtävän lähetys REST API:n kautta
- [x] `kpn hello` — tervehdyskomento
- [x] `kpn pipeline "<tehtävä>"` — manageri → koodari → testaaja -ketjutus
- [x] `kpn status`, `kpn models`, `kpn clear`, `kpn help`
- [x] Agenttikortit (Koodari/Qwen-Coder, Testaaja/SmolLM, Manageri/KPN CLI)
- [x] Muokattavat system promptit per agentti (localStorage-tallennus)
- [x] Multi-select: yhteinen konteksti useammalle agentille
- [x] Streaming-vastaukset terminaalissa (llm_chunk + vilkkuva kursori)
- [x] URL-hash navigointi (`#agents`, `#codelab`, `#network`)
- [x] SPA fallback (ServeDir + ServeFile)
- [ ] Agenttien välinen keskustelu (manageri ohjaa koodaria ja testaajaa dynaamisesti)
- [ ] Tehtävähistoria ja tulosten tallennus
- [ ] CLI-työkalu (`kpn` binary) lokaaliin käyttöön

View File

@@ -851,23 +851,29 @@ async fn api_chat_completions(
let mut rx = state.stats_tx.subscribe();
let _ = state.stats_tx.send(msg.to_string());
while let Ok(msg_str) = rx.recv().await {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg_str) {
if v["type"].as_str() == Some("llm_done") {
if let Some(tid) = v["task_id"].as_str() {
if tid == payload.task_id {
let res = ChatCompletionResponse {
response: v["response"].as_str().unwrap_or("").to_string(),
model: v["model"].as_str().unwrap_or("").to_string(),
tokens_generated: v["tokens_generated"].as_u64().unwrap_or(0),
};
return axum::Json(res).into_response();
let timeout = tokio::time::timeout(std::time::Duration::from_secs(120), async move {
while let Ok(msg_str) = rx.recv().await {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg_str) {
if v["type"].as_str() == Some("llm_done") {
if let Some(tid) = v["task_id"].as_str() {
if tid == payload.task_id {
return Some(ChatCompletionResponse {
response: v["response"].as_str().unwrap_or("").to_string(),
model: v["model"].as_str().unwrap_or("").to_string(),
tokens_generated: v["tokens_generated"].as_u64().unwrap_or(0),
});
}
}
}
}
}
None
}).await;
match timeout {
Ok(Some(res)) => axum::Json(res).into_response(),
Ok(None) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Verkkovirhe: yhteys katkesi").into_response(),
Err(_) => (axum::http::StatusCode::GATEWAY_TIMEOUT, "Aikakatkaisu: yksikään solmu ei vastannut 120s sisällä").into_response(),
}
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Network Error").into_response()
}

View File

@@ -225,7 +225,8 @@ pub async fn run_coder_inference(prompt: String, ws: Rc<RefCell<WebSocket>>, use
if next_token != eos_token {
if let Ok(text) = tokenizer.decode(&[next_token], true) {
generated_text.push_str(&text);
let chunk = serde_json::json!({ "type": "llm_chunk", "token": text, "prompt": prompt, "model": "Qwen2.5-Coder" });
let mut chunk = serde_json::json!({ "type": "llm_chunk", "token": text, "prompt": prompt, "model": "Qwen2.5-Coder" });
if let Some(ref tid) = task_id { chunk.as_object_mut().unwrap().insert("task_id".to_string(), serde_json::json!(tid)); }
let _ = ws.borrow().send_with_str(&chunk.to_string());
}
tokens_generated += 1;
@@ -258,7 +259,8 @@ pub async fn run_coder_inference(prompt: String, ws: Rc<RefCell<WebSocket>>, use
if let Ok(text) = tokenizer.decode(&[next_token], true) {
generated_text.push_str(&text);
let chunk = serde_json::json!({ "type": "llm_chunk", "token": text, "prompt": prompt, "model": "Qwen2.5-Coder" });
let mut chunk = serde_json::json!({ "type": "llm_chunk", "token": text, "prompt": prompt, "model": "Qwen2.5-Coder" });
if let Some(ref tid) = task_id { chunk.as_object_mut().unwrap().insert("task_id".to_string(), serde_json::json!(tid)); }
let _ = ws.borrow().send_with_str(&chunk.to_string());
}
tokens_generated += 1;

View File

@@ -120,6 +120,7 @@
.main-panel.active { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } }
.code-output {
font-family: 'Courier New', Courier, monospace;
@@ -1286,36 +1287,87 @@
termPanel.scrollTop = termPanel.scrollHeight;
}
async function kpnRun(model, prompt) {
// 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) {
termLog(` → <span style="color:#58a6ff">${model}</span> käsittelee...`, '#8b949e');
try {
const taskId = crypto.randomUUID();
// Liitetään yhteinen konteksti + agentin oma system prompt
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);
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 }),
});
// Poistetaan streaming-rivi
if (activeStreams[taskId]) {
activeStreams[taskId].remove();
delete activeStreams[taskId];
}
if (!res.ok) {
termLog(` ✗ Virhe: ${res.status} ${res.statusText}`, '#f85149');
return;
const errText = await res.text().catch(() => res.statusText);
termLog(`${errText}`, '#f85149');
return null;
}
const data = await res.json();
const response = (data.response || '').trim();
const tokGen = data.tokens_generated || 0;
termLog(` <span style="color:#3fb950">✓</span> <span style="color:#58a6ff">${data.model || model}</span> <span style="color:#8b949e">(${tokGen} tok)</span>`);
termLog(` ${response.replace(/</g,'&lt;').replace(/\n/g,'\n ')}`, '#c9d1d9');
if (!silent) {
termLog(` ${response.replace(/</g,'&lt;').replace(/\n/g,'\n ')}`, '#c9d1d9');
}
return response;
} catch (e) {
termLog(`${e.message}`, '#f85149');
return null;
}
}
// Pipeline: manageri → koodari → testaaja
async function kpnPipeline(task) {
termLog(`<span style="color:#a371f7;font-weight:bold">━━━ Pipeline käynnistyy ━━━</span>`);
// Vaihe 1: Manageri analysoi
termLog(`\n<span style="color:#d29922;font-weight:bold">[1/3] Manageri</span> — tehtävän analyysi`);
const managerPrompt = `Analysoi seuraava ohjelmistokehitystehtävä ja kirjoita koodarille selkeä tekninen ohje mitä koodata. Vastaa lyhyesti.\n\nTehtävä: ${task}`;
const plan = await kpnRun(agentPrompts.manager.model, managerPrompt);
if (!plan) { termLog(' ✗ Pipeline keskeytyi (manageri)', '#f85149'); return; }
// Vaihe 2: Koodari toteuttaa
termLog(`\n<span style="color:#3fb950;font-weight:bold">[2/3] Koodari</span> — toteutus`);
const coderPrompt = `${plan}\n\nKirjoita koodi yllä olevan ohjeen mukaisesti.`;
const code = await kpnRun(agentPrompts.coder.model, coderPrompt);
if (!code) { termLog(' ✗ Pipeline keskeytyi (koodari)', '#f85149'); return; }
// Vaihe 3: Testaaja arvioi
termLog(`\n<span style="color:#58a6ff;font-weight:bold">[3/3] Testaaja</span> — arviointi`);
const testerPrompt = `Arvioi seuraava koodi lyhyesti. Onko siinä bugeja? Puuttuuko testejä? Anna arvosana 1-5.\n\nTehtävä: ${task}\n\nKoodi:\n${code}`;
await kpnRun(agentPrompts.tester.model, testerPrompt);
termLog(`\n<span style="color:#a371f7;font-weight:bold">━━━ Pipeline valmis ━━━</span>`);
}
function termExec(cmd) {
termLog(`<span class="terminal-prompt">$</span> ${cmd.replace(/</g,'&lt;')}`);
termHistory.unshift(cmd);
@@ -1331,6 +1383,7 @@
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;" — manageri → koodari → testaaja', '#a5d6ff');
termLog(' kpn status — verkon tila', '#a5d6ff');
termLog(' kpn models — käytettävissä olevat mallit', '#a5d6ff');
termLog(' kpn clear — tyhjennä terminaali', '#a5d6ff');
@@ -1358,6 +1411,18 @@
return;
}
if (sub === 'pipeline') {
const afterPipeline = cmd.replace(/^kpn\s+pipeline\s*/, '');
const pMatch = afterPipeline.match(/^"(.+)"$|^'(.+)'$|^(.+)$/);
const pTask = (pMatch && (pMatch[1] || pMatch[2] || pMatch[3] || '')).trim();
if (!pTask) {
termLog(' Käyttö: kpn pipeline "&lt;tehtävä&gt;"', '#f85149');
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;
@@ -1617,6 +1682,16 @@
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") {
// Terminaalin streaming: päivitetään aktiivinen rivi
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;
}
}
// Streaming: näytetään generointi reaaliaikaisesti
const model = data.model || '';
const isCoder = model.includes('Coder');